MCP Server 2.0 (#452)

* MCP Server 2.0

- Updated application version from 2.23.14 to 2.24.0 in package.json.
- MCP Server improved with new features
- Migrated functionality from Dev Tools to MCP Server
- Improved getMonitoringProvider not to crash application when misconfigured
This commit is contained in:
Giancarlo Buomprisco
2026-02-11 20:42:01 +01:00
committed by GitHub
parent 059408a70a
commit f3ac595d06
123 changed files with 17803 additions and 5265 deletions

View File

@@ -0,0 +1,22 @@
# Email Templates Instructions
This package owns transactional email templates and renderers using React Email.
## Non-negotiables
1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss it.
2. New email renderer must be exported from `src/index.ts`.
3. Renderer contract: async function returning `{ html, subject }`.
4. i18n namespace must match locale filename in `src/locales/<lang>/<namespace>.json`.
5. Reuse shared primitives in `src/components/*` for layout/style consistency.
6. Include one clear CTA and a plain URL fallback in body copy.
7. Keep subject/body concise, action-first, non-spammy.
## When adding a new email
1. Add template in `src/emails/*.email.tsx`.
2. Add locale file in `src/locales/en/*-email.json` if template uses i18n.
3. Export template renderer from `src/index.ts`.
4. Add renderer to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`).
`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering will miss it.

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -10,7 +10,8 @@
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./registry": "./src/registry.ts"
},
"dependencies": {
"@react-email/components": "catalog:"
@@ -20,7 +21,10 @@
"@kit/i18n": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "catalog:"
"@types/node": "catalog:",
"@types/react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"typesVersions": {
"*": {

View File

@@ -0,0 +1,39 @@
import { renderAccountDeleteEmail } from './emails/account-delete.email';
import { renderInviteEmail } from './emails/invite.email';
import { renderOtpEmail } from './emails/otp.email';
/**
* Registry of email template renderers.
*
* This is used to render email templates dynamically. Ex. list all available email templates in the MCP server.
*
* @example
*
* const { html, subject } = await renderAccountDeleteEmail({
* userDisplayName: 'John Doe',
* productName: 'My SaaS App',
* });
*
* await mailer.sendEmail({
* to: 'user@example.com',
* from: 'noreply@yourdomain.com',
* subject,
* html,
* });
*
* @example
*
* const { html, subject } = await renderAccountDeleteEmail({
* userDisplayName: 'John Doe',
* productName: 'My SaaS App',
* });
*
*/
export const EMAIL_TEMPLATE_RENDERERS = {
'account-delete-email': renderAccountDeleteEmail,
'invite-email': renderInviteEmail,
'otp-email': renderOtpEmail,
};
export type EmailTemplateRenderer =
(typeof EMAIL_TEMPLATE_RENDERERS)[keyof typeof EMAIL_TEMPLATE_RENDERERS];

View File

@@ -22,6 +22,7 @@ export function createI18nSettings({
supportedLngs: languages,
fallbackLng: languages[0],
detection: undefined,
showSupportNotice: false,
lng,
preload: false as const,
lowerCaseLng: true as const,

View File

@@ -42,6 +42,7 @@ export async function initializeI18nClient(
.init(
{
...settings,
showSupportNotice: false,
detection: {
order: ['cookie', 'htmlTag', 'navigator'],
caches: ['cookie'],

View File

@@ -0,0 +1,16 @@
# MCP Server Instructions
This package owns Makerkit MCP tool/resource registration and adapters.
## Non-negotiables
1. Use service pattern: keep business/domain logic in `*.service.ts`, not MCP handlers.
2. `index.ts` in each tool folder is adapter only: parse input, call service, map output/errors.
3. Inject deps via `create*Deps` + `create*Service`; avoid hidden globals/singletons.
4. Keep schemas in `schema.ts`; validate all tool input with zod before service call.
5. Export public registration + service factory/types from each tool `index.ts`.
6. Add/maintain unit tests for service behavior and tool adapter behavior.
7. Register new tools/resources in `src/index.ts`.
8. Keep tool responses structured + stable; avoid breaking output shapes.
Service pattern is required to decouple logic from MCP server transport and keep logic testable/reusable.

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -2,10 +2,10 @@
"name": "@kit/mcp-server",
"private": true,
"version": "0.1.0",
"main": "./build/index.js",
"module": true,
"type": "module",
"main": "./build/index.cjs",
"bin": {
"makerkit-mcp-server": "./build/index.js"
"makerkit-mcp-server": "./build/index.cjs"
},
"exports": {
"./database": "./src/tools/database.ts",
@@ -13,28 +13,37 @@
"./migrations": "./src/tools/migrations.ts",
"./prd-manager": "./src/tools/prd-manager.ts",
"./prompts": "./src/tools/prompts.ts",
"./scripts": "./src/tools/scripts.ts"
"./scripts": "./src/tools/scripts.ts",
"./status": "./src/tools/status/index.ts",
"./prerequisites": "./src/tools/prerequisites/index.ts",
"./env": "./src/tools/env/index.ts",
"./env/model": "./src/tools/env/model.ts",
"./env/types": "./src/tools/env/types.ts",
"./dev": "./src/tools/dev/index.ts",
"./db": "./src/tools/db/index.ts",
"./emails": "./src/tools/emails/index.ts",
"./translations": "./src/tools/translations/index.ts",
"./run-checks": "./src/tools/run-checks/index.ts",
"./deps-upgrade-advisor": "./src/tools/deps-upgrade-advisor/index.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"build": "tsc",
"build:watch": "tsc --watch"
"build": "tsup",
"build:watch": "tsup --watch",
"test:unit": "vitest run"
},
"devDependencies": {
"@kit/email-templates": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@modelcontextprotocol/sdk": "1.26.0",
"@types/node": "catalog:",
"postgres": "3.4.8",
"tsup": "catalog:",
"typescript": "^5.9.3",
"vitest": "^4.0.18",
"zod": "catalog:"
},
"prettier": "@kit/prettier-config",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
"prettier": "@kit/prettier-config"
}

View File

@@ -6,10 +6,20 @@ import {
registerDatabaseResources,
registerDatabaseTools,
} from './tools/database';
import { registerKitDbTools } from './tools/db/index';
import { registerDepsUpgradeAdvisorTool } from './tools/deps-upgrade-advisor/index';
import { registerKitDevTools } from './tools/dev/index';
import { registerKitEmailTemplatesTools } from './tools/emails/index';
import { registerKitEnvTools } from './tools/env/index';
import { registerKitEmailsTools } from './tools/mailbox/index';
import { registerGetMigrationsTools } from './tools/migrations';
import { registerPRDTools } from './tools/prd-manager';
import { registerKitPrerequisitesTool } from './tools/prerequisites/index';
import { registerPromptsSystem } from './tools/prompts';
import { registerRunChecksTool } from './tools/run-checks/index';
import { registerScriptsTools } from './tools/scripts';
import { registerKitStatusTool } from './tools/status/index';
import { registerKitTranslationsTools } from './tools/translations/index';
async function main() {
// Create server instance
@@ -21,10 +31,20 @@ async function main() {
const transport = new StdioServerTransport();
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);
registerPromptsSystem(server);

View File

@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import { z } from 'zod/v3';
interface ComponentInfo {
name: string;
@@ -345,9 +345,12 @@ export function registerComponentsTools(server: McpServer) {
}
function createGetComponentsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_components',
'Get all available UI components from the @kit/ui package with descriptions',
{
description:
'Get all available UI components from the @kit/ui package with descriptions',
},
async () => {
const components = await ComponentsTool.getComponents();
@@ -371,13 +374,15 @@ function createGetComponentsTool(server: McpServer) {
}
function createGetComponentContentTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_component_content',
'Get the source code content of a specific UI component',
{
state: z.object({
componentName: z.string(),
}),
description: 'Get the source code content of a specific UI component',
inputSchema: {
state: z.object({
componentName: z.string(),
}),
},
},
async ({ state }) => {
const content = await ComponentsTool.getComponentContent(
@@ -397,13 +402,16 @@ function createGetComponentContentTool(server: McpServer) {
}
function createComponentsSearchTool(server: McpServer) {
return server.tool(
return server.registerTool(
'components_search',
'Search UI components by keyword in name, description, or category',
{
state: z.object({
query: z.string(),
}),
description:
'Search UI components by keyword in name, description, or category',
inputSchema: {
state: z.object({
query: z.string(),
}),
},
},
async ({ state }) => {
const components = await ComponentsTool.searchComponents(state.query);
@@ -439,13 +447,16 @@ function createComponentsSearchTool(server: McpServer) {
}
function createGetComponentPropsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_component_props',
'Extract component props, interfaces, and variants from a UI component',
{
state: z.object({
componentName: z.string(),
}),
description:
'Extract component props, interfaces, and variants from a UI component',
inputSchema: {
state: z.object({
componentName: z.string(),
}),
},
},
async ({ state }) => {
const propsInfo = await ComponentsTool.getComponentProps(

View File

@@ -2,7 +2,7 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { readFile, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import postgres from 'postgres';
import { z } from 'zod';
import { z } from 'zod/v3';
const DATABASE_URL =
process.env.DATABASE_URL ||
@@ -1135,9 +1135,12 @@ export function registerDatabaseResources(server: McpServer) {
}
function createGetSchemaFilesTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_schema_files',
'🔥 DATABASE SCHEMA FILES (SOURCE OF TRUTH - ALWAYS CURRENT) - Use these over migrations!',
{
description:
'🔥 DATABASE SCHEMA FILES (SOURCE OF TRUTH - ALWAYS CURRENT) - Use these over migrations!',
},
async () => {
const schemaFiles = await DatabaseTool.getSchemaFiles();
@@ -1168,9 +1171,12 @@ function createGetSchemaFilesTool(server: McpServer) {
}
function createGetFunctionsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_database_functions',
'Get all database functions with descriptions and usage guidance',
{
description:
'Get all database functions with descriptions and usage guidance',
},
async () => {
const functions = await DatabaseTool.getFunctions();
@@ -1202,13 +1208,16 @@ function createGetFunctionsTool(server: McpServer) {
}
function createGetFunctionDetailsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_function_details',
'Get detailed information about a specific database function',
{
state: z.object({
functionName: z.string(),
}),
description:
'Get detailed information about a specific database function',
inputSchema: {
state: z.object({
functionName: z.string(),
}),
},
},
async ({ state }) => {
const func = await DatabaseTool.getFunctionDetails(state.functionName);
@@ -1253,13 +1262,15 @@ Source File: ${func.sourceFile}`,
}
function createSearchFunctionsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'search_database_functions',
'Search database functions by name, description, or purpose',
{
state: z.object({
query: z.string(),
}),
description: 'Search database functions by name, description, or purpose',
inputSchema: {
state: z.object({
query: z.string(),
}),
},
},
async ({ state }) => {
const functions = await DatabaseTool.searchFunctions(state.query);
@@ -1295,13 +1306,16 @@ function createSearchFunctionsTool(server: McpServer) {
}
function createGetSchemaContentTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_schema_content',
'📋 Get raw schema file content (CURRENT DATABASE STATE) - Source of truth for database structure',
{
state: z.object({
fileName: z.string(),
}),
description:
'📋 Get raw schema file content (CURRENT DATABASE STATE) - Source of truth for database structure',
inputSchema: {
state: z.object({
fileName: z.string(),
}),
},
},
async ({ state }) => {
const content = await DatabaseTool.getSchemaContent(state.fileName);
@@ -1319,13 +1333,16 @@ function createGetSchemaContentTool(server: McpServer) {
}
function createGetSchemasByTopicTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_schemas_by_topic',
'🎯 Find schema files by topic (accounts, auth, billing, permissions, etc.) - Fastest way to find relevant schemas',
{
state: z.object({
topic: z.string(),
}),
description:
'🎯 Find schema files by topic (accounts, auth, billing, permissions, etc.) - Fastest way to find relevant schemas',
inputSchema: {
state: z.object({
topic: z.string(),
}),
},
},
async ({ state }) => {
const schemas = await DatabaseTool.getSchemasByTopic(state.topic);
@@ -1368,13 +1385,16 @@ function createGetSchemasByTopicTool(server: McpServer) {
}
function createGetSchemaBySectionTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_schema_by_section',
'📂 Get specific schema by section name (Accounts, Permissions, etc.) - Direct access to schema sections',
{
state: z.object({
section: z.string(),
}),
description:
'📂 Get specific schema by section name (Accounts, Permissions, etc.) - Direct access to schema sections',
inputSchema: {
state: z.object({
section: z.string(),
}),
},
},
async ({ state }) => {
const schema = await DatabaseTool.getSchemaBySection(state.section);
@@ -1414,9 +1434,12 @@ function createGetSchemaBySectionTool(server: McpServer) {
}
function createDatabaseSummaryTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_database_summary',
'📊 Get comprehensive database overview with tables, enums, and functions',
{
description:
'📊 Get comprehensive database overview with tables, enums, and functions',
},
async () => {
const tables = await DatabaseTool.getAllProjectTables();
const enums = await DatabaseTool.getAllEnums();
@@ -1468,9 +1491,11 @@ function createDatabaseSummaryTool(server: McpServer) {
}
function createDatabaseTablesListTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_database_tables',
'📋 Get list of all project-defined database tables',
{
description: '📋 Get list of all project-defined database tables',
},
async () => {
const tables = await DatabaseTool.getAllProjectTables();
@@ -1487,14 +1512,17 @@ function createDatabaseTablesListTool(server: McpServer) {
}
function createGetTableInfoTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_table_info',
'🗂️ Get detailed table schema with columns, foreign keys, and indexes',
{
state: z.object({
schema: z.string().default('public'),
tableName: z.string(),
}),
description:
'🗂️ Get detailed table schema with columns, foreign keys, and indexes',
inputSchema: {
state: z.object({
schema: z.string().default('public'),
tableName: z.string(),
}),
},
},
async ({ state }) => {
try {
@@ -1526,13 +1554,15 @@ function createGetTableInfoTool(server: McpServer) {
}
function createGetEnumInfoTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_enum_info',
'🏷️ Get enum type definition with all possible values',
{
state: z.object({
enumName: z.string(),
}),
description: '🏷️ Get enum type definition with all possible values',
inputSchema: {
state: z.object({
enumName: z.string(),
}),
},
},
async ({ state }) => {
try {
@@ -1573,9 +1603,11 @@ function createGetEnumInfoTool(server: McpServer) {
}
function createGetAllEnumsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_all_enums',
'🏷️ Get all enum types and their values',
{
description: '🏷️ Get all enum types and their values',
},
async () => {
try {
const enums = await DatabaseTool.getAllEnums();

View File

@@ -0,0 +1,248 @@
import { describe, expect, it, vi } from 'vitest';
import { type KitDbServiceDeps, createKitDbService } from '../kit-db.service';
function createDeps(
overrides: Partial<KitDbServiceDeps> = {},
): KitDbServiceDeps {
return {
rootPath: '/repo',
async resolveVariantContext() {
return {
variant: 'next-supabase',
variantFamily: 'supabase',
tool: 'supabase',
};
},
async executeCommand() {
return { stdout: '', stderr: '', exitCode: 0 };
},
async isPortOpen() {
return false;
},
async fileExists() {
return false;
},
async readdir() {
return [];
},
async readJsonFile() {
return {};
},
...overrides,
};
}
describe('KitDbService.status', () => {
it('reports pending migrations when CLI output is unavailable', async () => {
const deps = createDeps({
async fileExists(path: string) {
return path.includes('supabase/migrations');
},
async readdir() {
return ['20260101010101_create_table.sql'];
},
async executeCommand() {
throw new Error('supabase missing');
},
});
const service = createKitDbService(deps);
const result = await service.status();
expect(result.migrations.pending).toBe(1);
expect(result.migrations.pending_names).toEqual([
'20260101010101_create_table',
]);
});
it('treats local migrations as applied when connected', async () => {
const deps = createDeps({
async fileExists(path: string) {
return path.includes('supabase/migrations');
},
async readdir() {
return ['20260101010101_create_table.sql'];
},
async executeCommand() {
throw new Error('supabase missing');
},
async isPortOpen() {
return true;
},
});
const service = createKitDbService(deps);
const result = await service.status();
expect(result.migrations.applied).toBe(1);
expect(result.migrations.pending).toBe(0);
});
it('parses supabase migrations list output', async () => {
const deps = createDeps({
async executeCommand(command, args) {
if (command === 'supabase' && args.join(' ') === 'migrations list') {
return {
stdout:
'20260101010101_create_table | applied\n20260202020202_add_billing | not applied\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
});
const service = createKitDbService(deps);
const result = await service.status();
expect(result.migrations.applied).toBe(1);
expect(result.migrations.pending).toBe(1);
expect(result.migrations.pending_names).toEqual([
'20260202020202_add_billing',
]);
});
it('maps id/name columns to local migration names', async () => {
const deps = createDeps({
async fileExists(path: string) {
return path.includes('supabase/migrations');
},
async readdir() {
return ['20240319163440_roles-seed.sql'];
},
async executeCommand(command, args) {
if (command === 'supabase' && args.join(' ') === 'migrations list') {
return {
stdout:
'20240319163440 | roles-seed | Applied\n20240401010101 | add-billing | Pending\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
});
const service = createKitDbService(deps);
const result = await service.status();
expect(result.migrations.applied).toBe(1);
expect(result.migrations.pending).toBe(1);
expect(result.migrations.pending_names).toEqual([
'20240401010101_add-billing',
]);
});
it('treats id/name list with no status as applied', async () => {
const deps = createDeps({
async fileExists(path: string) {
return path.includes('supabase/migrations');
},
async readdir() {
return [
'20240319163440_roles-seed.sql',
'20240401010101_add-billing.sql',
];
},
async executeCommand(command, args) {
if (command === 'supabase' && args.join(' ') === 'migrations list') {
return {
stdout:
'20240319163440 | roles-seed\n20240401010101 | add-billing\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
});
const service = createKitDbService(deps);
const result = await service.status();
expect(result.migrations.applied).toBe(2);
expect(result.migrations.pending).toBe(0);
});
});
describe('KitDbService.migrate', () => {
it('throws when target is not latest', async () => {
const service = createKitDbService(createDeps());
await expect(
service.migrate({ target: '20260101010101_create_table' }),
).rejects.toThrow(/target "latest"/);
});
it('returns applied migrations from pending list', async () => {
const deps = createDeps({
async fileExists(path: string) {
return path.includes('supabase/migrations');
},
async readdir() {
return ['20260101010101_create_table.sql'];
},
async executeCommand(command, args) {
if (command === 'supabase' && args.join(' ') === 'migrations list') {
return {
stdout: '20260101010101_create_table | pending\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
});
const service = createKitDbService(deps);
const result = await service.migrate({ target: 'latest' });
expect(result.applied).toEqual(['20260101010101_create_table']);
expect(result.total_applied).toBe(1);
});
});
describe('KitDbService.reset', () => {
it('requires confirm true', async () => {
const service = createKitDbService(createDeps());
await expect(service.reset({ confirm: false })).rejects.toThrow(
/confirm: true/,
);
});
});
describe('KitDbService.seed', () => {
it('uses db:seed script when available', async () => {
const exec = vi.fn(async (_command: string, _args: string[]) => ({
stdout: '',
stderr: '',
exitCode: 0,
}));
const deps = createDeps({
async readJsonFile() {
return { scripts: { 'db:seed': 'tsx scripts/seed.ts' } };
},
async executeCommand(command, args) {
return exec(command, args);
},
});
const service = createKitDbService(deps);
await service.seed();
expect(exec).toHaveBeenCalledWith('pnpm', [
'--filter',
'web',
'run',
'db:seed',
]);
});
});

View File

@@ -0,0 +1,365 @@
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 { type KitDbServiceDeps, createKitDbService } from './kit-db.service';
import {
KitDbMigrateInputSchema,
KitDbMigrateOutputSchema,
KitDbResetInputSchema,
KitDbResetOutputSchema,
KitDbSeedInputSchema,
KitDbSeedOutputSchema,
KitDbStatusInputSchema,
KitDbStatusOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
type TextContent = {
type: 'text';
text: string;
};
export function registerKitDbTools(server: McpServer) {
const service = createKitDbService(createKitDbDeps());
server.registerTool(
'kit_db_status',
{
description: 'Check database connectivity and migrations state',
inputSchema: KitDbStatusInputSchema,
outputSchema: KitDbStatusOutputSchema,
},
async (input) => {
KitDbStatusInputSchema.parse(input);
try {
const result = await service.status();
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_db_status', error);
}
},
);
server.registerTool(
'kit_db_migrate',
{
description: 'Apply pending database migrations',
inputSchema: KitDbMigrateInputSchema,
outputSchema: KitDbMigrateOutputSchema,
},
async (input) => {
try {
const parsed = KitDbMigrateInputSchema.parse(input);
const result = await service.migrate(parsed);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_db_migrate', error);
}
},
);
server.registerTool(
'kit_db_seed',
{
description: 'Run database seed scripts',
inputSchema: KitDbSeedInputSchema,
outputSchema: KitDbSeedOutputSchema,
},
async (input) => {
KitDbSeedInputSchema.parse(input);
try {
const result = await service.seed();
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_db_seed', error);
}
},
);
server.registerTool(
'kit_db_reset',
{
description: 'Reset the database after confirmation',
inputSchema: KitDbResetInputSchema,
outputSchema: KitDbResetOutputSchema,
},
async (input) => {
try {
const parsed = KitDbResetInputSchema.parse(input);
const result = await service.reset(parsed);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_db_reset', error);
}
},
);
}
export function createKitDbDeps(rootPath = process.cwd()): KitDbServiceDeps {
return {
rootPath,
async resolveVariantContext() {
const configuredVariant = await readConfiguredVariant(rootPath);
if (configuredVariant) {
return mapVariant(configuredVariant);
}
if (await pathExists(join(rootPath, 'apps', 'web', 'supabase'))) {
return mapVariant('next-supabase');
}
const webPackage = await readJsonIfPresent(
join(rootPath, 'apps', 'web', 'package.json'),
);
const dependencies = {
...(webPackage?.dependencies ?? {}),
...(webPackage?.devDependencies ?? {}),
} as Record<string, unknown>;
if ('prisma' in dependencies || '@prisma/client' in dependencies) {
return mapVariant('next-prisma');
}
if ('drizzle-kit' in dependencies || 'drizzle-orm' in dependencies) {
return mapVariant('next-drizzle');
}
return mapVariant('next-supabase');
},
async executeCommand(command: string, args: string[]) {
const result = await executeWithFallback(rootPath, command, args);
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
};
},
async isPortOpen(port: number) {
return checkPort(port);
},
async fileExists(path: string) {
return pathExists(path);
},
async readdir(path: string) {
return readdir(path);
},
async readJsonFile(path: string) {
const raw = await readFile(path, 'utf8');
return JSON.parse(raw) as unknown;
},
};
}
function mapVariant(variant: string) {
if (variant === 'next-prisma') {
return {
variant,
variantFamily: 'orm',
tool: 'prisma',
} as const;
}
if (variant === 'next-drizzle') {
return {
variant,
variantFamily: 'orm',
tool: 'drizzle-kit',
} as const;
}
if (variant === 'react-router-supabase') {
return {
variant,
variantFamily: 'supabase',
tool: 'supabase',
} as const;
}
return {
variant: variant.includes('prisma') ? variant : 'next-supabase',
variantFamily: 'supabase',
tool: 'supabase',
} as const;
}
async function readConfiguredVariant(rootPath: string) {
const configPath = join(rootPath, '.makerkit', 'config.json');
try {
await access(configPath);
const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<
string,
unknown
>;
return (
readString(config, 'variant') ??
readString(config, 'template') ??
readString(config, 'kitVariant')
);
} catch {
return null;
}
}
function readString(obj: Record<string, unknown>, key: string) {
const value = obj[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
async function executeWithFallback(
rootPath: string,
command: string,
args: string[],
) {
try {
return await execFileAsync(command, args, {
cwd: rootPath,
});
} catch (error) {
if (isLocalCliCandidate(command)) {
const localBinCandidates = [
join(rootPath, 'node_modules', '.bin', command),
join(rootPath, 'apps', 'web', 'node_modules', '.bin', command),
];
for (const localBin of localBinCandidates) {
try {
return await execFileAsync(localBin, args, {
cwd: rootPath,
});
} catch {
// Try next local binary candidate.
}
}
try {
return await execFileAsync('pnpm', ['exec', command, ...args], {
cwd: rootPath,
});
} catch {
return execFileAsync(
'pnpm',
['--filter', 'web', 'exec', command, ...args],
{
cwd: rootPath,
},
);
}
}
if (command === 'pnpm' || command === 'docker') {
return execFileAsync(command, args, {
cwd: rootPath,
});
}
throw error;
}
}
function isLocalCliCandidate(command: string) {
return (
command === 'supabase' || command === 'drizzle-kit' || command === 'prisma'
);
}
async function pathExists(path: string) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function readJsonIfPresent(path: string) {
try {
const content = await readFile(path, 'utf8');
return JSON.parse(content) as {
dependencies?: Record<string, unknown>;
devDependencies?: Record<string, unknown>;
};
} catch {
return null;
}
}
async function checkPort(port: number) {
return new Promise<boolean>((resolve) => {
const socket = new Socket();
socket.setTimeout(200);
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('timeout', () => {
socket.destroy();
resolve(false);
});
socket.once('error', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, '127.0.0.1');
});
}
function buildErrorResponse(tool: string, error: unknown) {
const message = `${tool} failed: ${toErrorMessage(error)}`;
return {
isError: true,
structuredContent: {
error: {
message,
},
},
content: buildTextContent(message),
};
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
function buildTextContent(text: string): TextContent[] {
return [{ type: 'text', text }];
}
export { createKitDbService } from './kit-db.service';
export type { KitDbServiceDeps } from './kit-db.service';
export type { KitDbStatusOutput } from './schema';

View File

@@ -0,0 +1,505 @@
import { join } from 'node:path';
import type {
DbTool,
KitDbMigrateInput,
KitDbMigrateOutput,
KitDbResetInput,
KitDbResetOutput,
KitDbSeedOutput,
KitDbStatusOutput,
} from './schema';
type VariantFamily = 'supabase' | 'orm';
interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface VariantContext {
variant: string;
variantFamily: VariantFamily;
tool: DbTool;
}
interface MigrationStatus {
applied: string[];
pending: string[];
}
interface SeedScript {
command: string;
args: string[];
}
export interface KitDbServiceDeps {
rootPath: string;
resolveVariantContext(): Promise<VariantContext>;
executeCommand(command: string, args: string[]): Promise<CommandResult>;
isPortOpen(port: number): Promise<boolean>;
fileExists(path: string): Promise<boolean>;
readdir(path: string): Promise<string[]>;
readJsonFile(path: string): Promise<unknown>;
}
const SUPABASE_PORT = 54321;
const ORM_PORT = 5432;
export function createKitDbService(deps: KitDbServiceDeps) {
return new KitDbService(deps);
}
export class KitDbService {
constructor(private readonly deps: KitDbServiceDeps) {}
async status(): Promise<KitDbStatusOutput> {
const variant = await this.deps.resolveVariantContext();
const connected = await this.isConnected(variant);
const migrations = await this.getMigrationSummary(variant, {
connected,
});
return {
connected,
tool: variant.tool,
migrations: {
applied: migrations.applied.length,
pending: migrations.pending.length,
pending_names: migrations.pending,
},
};
}
async migrate(input: KitDbMigrateInput): Promise<KitDbMigrateOutput> {
const variant = await this.deps.resolveVariantContext();
if (input.target !== 'latest') {
throw new Error(
`Specific migration targets are not supported for ${variant.tool} in this kit. Use target "latest".`,
);
}
const pending = await this.getPendingMigrationNames(variant);
await this.runMigrations(variant);
return {
applied: pending,
total_applied: pending.length,
status: 'success',
};
}
async seed(): Promise<KitDbSeedOutput> {
const variant = await this.deps.resolveVariantContext();
const seedScript = await this.resolveSeedScript(variant);
await this.deps.executeCommand(seedScript.command, seedScript.args);
return {
status: 'success',
message: 'Seed data applied successfully',
};
}
async reset(input: KitDbResetInput): Promise<KitDbResetOutput> {
if (!input.confirm) {
throw new Error('Database reset requires confirm: true');
}
const variant = await this.deps.resolveVariantContext();
if (variant.variantFamily === 'supabase') {
await this.deps.executeCommand('supabase', ['db', 'reset']);
} else {
await this.deps.executeCommand('docker', ['compose', 'down', '-v']);
await this.deps.executeCommand('docker', [
'compose',
'up',
'-d',
'postgres',
]);
await this.runMigrations(variant);
}
return {
status: 'success',
message: 'Database reset and migrations re-applied',
};
}
private async isConnected(variant: VariantContext) {
const port =
variant.variantFamily === 'supabase' ? SUPABASE_PORT : ORM_PORT;
return this.deps.isPortOpen(port);
}
private async getMigrationSummary(
variant: VariantContext,
options: {
connected?: boolean;
} = {},
): Promise<MigrationStatus> {
const localMigrations = await this.listLocalMigrations(variant);
if (variant.variantFamily === 'supabase') {
const parsed = await this.tryParseSupabaseMigrations(localMigrations);
if (parsed) {
return parsed;
}
}
if (
variant.variantFamily === 'supabase' &&
options.connected &&
localMigrations.length > 0
) {
return {
applied: localMigrations,
pending: [],
};
}
return {
applied: [],
pending: localMigrations,
};
}
private async getPendingMigrationNames(variant: VariantContext) {
const summary = await this.getMigrationSummary(variant);
return summary.pending;
}
private async runMigrations(variant: VariantContext) {
if (variant.tool === 'supabase') {
await this.deps.executeCommand('supabase', ['db', 'push']);
return;
}
if (variant.tool === 'drizzle-kit') {
await this.deps.executeCommand('drizzle-kit', ['push']);
return;
}
await this.deps.executeCommand('prisma', ['db', 'push']);
}
private async resolveSeedScript(
variant: VariantContext,
): Promise<SeedScript> {
const customScript = await this.findSeedScript();
if (customScript) {
return {
command: 'pnpm',
args: ['--filter', 'web', 'run', customScript],
};
}
if (variant.tool === 'supabase') {
return {
command: 'supabase',
args: ['db', 'seed'],
};
}
if (variant.tool === 'prisma') {
return {
command: 'prisma',
args: ['db', 'seed'],
};
}
throw new Error(
'No seed command configured. Add a db:seed or seed script to apps/web/package.json.',
);
}
private async findSeedScript() {
const packageJsonPath = join(
this.deps.rootPath,
'apps',
'web',
'package.json',
);
const packageJson = await this.readObject(packageJsonPath);
const scripts = this.readObjectValue(packageJson, 'scripts');
if (this.readString(scripts, 'db:seed')) {
return 'db:seed';
}
if (this.readString(scripts, 'seed')) {
return 'seed';
}
return null;
}
private async listLocalMigrations(variant: VariantContext) {
const migrationsDir = await this.resolveMigrationsDir(variant);
if (!migrationsDir) {
return [];
}
const entries = await this.deps.readdir(migrationsDir);
return this.filterMigrationNames(variant, entries);
}
private async resolveMigrationsDir(variant: VariantContext) {
if (variant.tool === 'supabase') {
const supabaseDir = join(
this.deps.rootPath,
'apps',
'web',
'supabase',
'migrations',
);
return (await this.deps.fileExists(supabaseDir)) ? supabaseDir : null;
}
if (variant.tool === 'prisma') {
const prismaDir = join(
this.deps.rootPath,
'apps',
'web',
'prisma',
'migrations',
);
return (await this.deps.fileExists(prismaDir)) ? prismaDir : null;
}
const drizzleDir = join(
this.deps.rootPath,
'apps',
'web',
'drizzle',
'migrations',
);
if (await this.deps.fileExists(drizzleDir)) {
return drizzleDir;
}
const fallbackDir = join(this.deps.rootPath, 'drizzle', 'migrations');
return (await this.deps.fileExists(fallbackDir)) ? fallbackDir : null;
}
private filterMigrationNames(variant: VariantContext, entries: string[]) {
if (variant.tool === 'prisma') {
return entries.filter((entry) => entry.trim().length > 0);
}
return entries
.filter((entry) => entry.endsWith('.sql'))
.map((entry) => entry.replace(/\.sql$/, ''));
}
private async tryParseSupabaseMigrations(localMigrations: string[]) {
try {
const localResult = await this.deps.executeCommand('supabase', [
'migrations',
'list',
'--local',
]);
const parsedLocal = parseSupabaseMigrationsList(
localResult.stdout,
localMigrations,
);
if (parsedLocal) {
return parsedLocal;
}
} catch {
// Fall through to remote attempt.
}
try {
const remoteResult = await this.deps.executeCommand('supabase', [
'migrations',
'list',
]);
return parseSupabaseMigrationsList(remoteResult.stdout, localMigrations);
} catch {
return null;
}
}
private async readObject(path: string): Promise<Record<string, unknown>> {
try {
const value = await this.deps.readJsonFile(path);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
} catch {
return {};
}
}
private readObjectValue(obj: Record<string, unknown>, key: string) {
const value = obj[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
private readString(obj: Record<string, unknown>, key: string) {
const value = obj[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
}
function parseSupabaseMigrationsList(
output: string,
localMigrations: string[],
): MigrationStatus | null {
const applied = new Set<string>();
const pending = new Set<string>();
const appliedCandidates = new Set<string>();
const lines = output.split('\n');
const migrationsById = buildMigrationIdMap(localMigrations);
let sawStatus = false;
let sawId = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const status = extractSupabaseStatus(trimmed);
const nameFromLine = extractMigrationName(
trimmed,
localMigrations,
migrationsById,
);
if (nameFromLine) {
sawId = true;
}
if (!status) {
if (nameFromLine) {
appliedCandidates.add(nameFromLine);
}
continue;
}
sawStatus = true;
if (!nameFromLine) {
continue;
}
if (status === 'applied') {
applied.add(nameFromLine);
} else {
pending.add(nameFromLine);
}
}
if (!sawStatus && sawId && appliedCandidates.size > 0) {
const appliedList = Array.from(appliedCandidates);
const pendingList = localMigrations.filter(
(migration) => !appliedCandidates.has(migration),
);
return {
applied: appliedList,
pending: pendingList,
};
}
if (applied.size === 0 && pending.size === 0) {
return null;
}
return {
applied: Array.from(applied),
pending: Array.from(pending),
};
}
function extractMigrationName(
line: string,
candidates: string[],
migrationsById: Map<string, string>,
) {
const directMatch = line.match(/\b\d{14}_[a-z0-9_]+\b/i);
if (directMatch?.[0]) {
return directMatch[0];
}
const columns = line
.split('|')
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (columns.length >= 2) {
const id = columns.find((value) => /^\d{14}$/.test(value));
if (id) {
const byId = migrationsById.get(id);
if (byId) {
return byId;
}
const nameColumn = columns[1];
const normalizedName = normalizeMigrationName(nameColumn);
const candidate = `${id}_${normalizedName}`;
const exactMatch = candidates.find(
(migration) =>
migration.toLowerCase() === candidate.toLowerCase() ||
normalizeMigrationName(migration) === normalizedName,
);
return exactMatch ?? candidate;
}
}
return candidates.find((name) => line.includes(name)) ?? null;
}
function extractSupabaseStatus(line: string) {
const lower = line.toLowerCase();
if (/\b(not applied|pending|missing)\b/.test(lower)) {
return 'pending';
}
if (/\b(applied|completed)\b/.test(lower)) {
return 'applied';
}
return null;
}
function buildMigrationIdMap(migrations: string[]) {
const map = new Map<string, string>();
for (const migration of migrations) {
const match = migration.match(/^(\d{14})_(.+)$/);
if (match?.[1]) {
map.set(match[1], migration);
}
}
return map;
}
function normalizeMigrationName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9_-]/g, '');
}

View File

@@ -0,0 +1,53 @@
import { z } from 'zod/v3';
const DbToolSchema = z.enum(['supabase', 'drizzle-kit', 'prisma']);
const MigrationStatusSchema = z.object({
applied: z.number(),
pending: z.number(),
pending_names: z.array(z.string()),
});
export const KitDbStatusInputSchema = z.object({});
export const KitDbStatusOutputSchema = z.object({
connected: z.boolean(),
tool: DbToolSchema,
migrations: MigrationStatusSchema,
});
export const KitDbMigrateInputSchema = z.object({
target: z.string().default('latest'),
});
export const KitDbMigrateOutputSchema = z.object({
applied: z.array(z.string()),
total_applied: z.number(),
status: z.literal('success'),
});
export const KitDbSeedInputSchema = z.object({});
export const KitDbSeedOutputSchema = z.object({
status: z.literal('success'),
message: z.string(),
});
export const KitDbResetInputSchema = z.object({
confirm: z.boolean().default(false),
});
export const KitDbResetOutputSchema = z.object({
status: z.literal('success'),
message: z.string(),
});
export type DbTool = z.infer<typeof DbToolSchema>;
export type KitDbStatusInput = z.infer<typeof KitDbStatusInputSchema>;
export type KitDbStatusOutput = z.infer<typeof KitDbStatusOutputSchema>;
export type KitDbMigrateInput = z.infer<typeof KitDbMigrateInputSchema>;
export type KitDbMigrateOutput = z.infer<typeof KitDbMigrateOutputSchema>;
export type KitDbSeedInput = z.infer<typeof KitDbSeedInputSchema>;
export type KitDbSeedOutput = z.infer<typeof KitDbSeedOutputSchema>;
export type KitDbResetInput = z.infer<typeof KitDbResetInputSchema>;
export type KitDbResetOutput = z.infer<typeof KitDbResetOutputSchema>;

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
type DepsUpgradeAdvisorDeps,
createDepsUpgradeAdvisorService,
} from '../deps-upgrade-advisor.service';
function createDeps(
output: unknown,
overrides: Partial<DepsUpgradeAdvisorDeps> = {},
): DepsUpgradeAdvisorDeps {
return {
async executeCommand() {
return {
stdout: JSON.stringify(output),
stderr: '',
exitCode: 0,
};
},
nowIso() {
return '2026-02-09T00:00:00.000Z';
},
...overrides,
};
}
describe('DepsUpgradeAdvisorService', () => {
it('flags major updates as potentially breaking', async () => {
const service = createDepsUpgradeAdvisorService(
createDeps([
{
name: 'zod',
current: '3.25.0',
wanted: '3.26.0',
latest: '4.0.0',
workspace: 'root',
dependencyType: 'dependencies',
},
]),
);
const result = await service.advise({});
const zod = result.recommendations.find((item) => item.package === 'zod');
expect(zod?.update_type).toBe('major');
expect(zod?.potentially_breaking).toBe(true);
expect(zod?.risk).toBe('high');
});
it('prefers wanted for major updates when includeMajor is false', async () => {
const service = createDepsUpgradeAdvisorService(
createDeps([
{
name: 'example-lib',
current: '1.2.0',
wanted: '1.9.0',
latest: '2.1.0',
workspace: 'root',
dependencyType: 'dependencies',
},
]),
);
const result = await service.advise({});
const item = result.recommendations[0];
expect(item?.recommended_target).toBe('1.9.0');
});
it('filters out dev dependencies when requested', async () => {
const service = createDepsUpgradeAdvisorService(
createDeps([
{
name: 'vitest',
current: '2.1.0',
wanted: '2.1.8',
latest: '2.1.8',
workspace: 'root',
dependencyType: 'devDependencies',
},
{
name: 'zod',
current: '3.25.0',
wanted: '3.25.1',
latest: '3.25.1',
workspace: 'root',
dependencyType: 'dependencies',
},
]),
);
const result = await service.advise({
state: { includeDevDependencies: false },
});
expect(result.recommendations).toHaveLength(1);
expect(result.recommendations[0]?.package).toBe('zod');
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { registerDepsUpgradeAdvisorToolWithDeps } from '../index';
import { DepsUpgradeAdvisorOutputSchema } from '../schema';
interface RegisteredTool {
name: string;
handler: (input: unknown) => Promise<Record<string, unknown>>;
}
describe('registerDepsUpgradeAdvisorTool', () => {
it('registers deps_upgrade_advisor and returns typed structured output', async () => {
const tools: RegisteredTool[] = [];
const server = {
registerTool(
name: string,
_config: Record<string, unknown>,
handler: (input: unknown) => Promise<Record<string, unknown>>,
) {
tools.push({ name, handler });
return {};
},
};
registerDepsUpgradeAdvisorToolWithDeps(server as never, {
async executeCommand() {
return {
stdout: '[]',
stderr: '',
exitCode: 0,
};
},
nowIso() {
return '2026-02-09T00:00:00.000Z';
},
});
expect(tools).toHaveLength(1);
expect(tools[0]?.name).toBe('deps_upgrade_advisor');
const result = await tools[0]!.handler({});
const parsed = DepsUpgradeAdvisorOutputSchema.parse(
result.structuredContent,
);
expect(parsed.generated_at).toBeTruthy();
expect(Array.isArray(parsed.recommendations)).toBe(true);
});
});

View File

@@ -0,0 +1,307 @@
import type {
DepsUpgradeAdvisorInput,
DepsUpgradeAdvisorOutput,
DepsUpgradeRecommendation,
} from './schema';
interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface OutdatedDependency {
package: string;
workspace: string;
dependencyType: string;
current: string;
wanted: string;
latest: string;
}
export interface DepsUpgradeAdvisorDeps {
executeCommand(command: string, args: string[]): Promise<CommandResult>;
nowIso(): string;
}
export function createDepsUpgradeAdvisorService(deps: DepsUpgradeAdvisorDeps) {
return new DepsUpgradeAdvisorService(deps);
}
export class DepsUpgradeAdvisorService {
constructor(private readonly deps: DepsUpgradeAdvisorDeps) {}
async advise(
input: DepsUpgradeAdvisorInput,
): Promise<DepsUpgradeAdvisorOutput> {
const includeMajor = input.state?.includeMajor ?? false;
const maxPackages = input.state?.maxPackages ?? 50;
const includeDevDependencies = input.state?.includeDevDependencies ?? true;
const warnings: string[] = [];
const outdated = await this.getOutdatedDependencies(warnings);
const filtered = outdated.filter((item) => {
if (includeDevDependencies) {
return true;
}
return !item.dependencyType.toLowerCase().includes('dev');
});
const recommendations = filtered
.map((item) => toRecommendation(item, includeMajor))
.sort(sortRecommendations)
.slice(0, maxPackages);
const major = recommendations.filter(
(item) => item.update_type === 'major',
);
const safe = recommendations.filter((item) => item.update_type !== 'major');
if (!includeMajor && major.length > 0) {
warnings.push(
`${major.length} major upgrades were excluded from immediate recommendations. Re-run with includeMajor=true to include them.`,
);
}
return {
generated_at: this.deps.nowIso(),
summary: {
total_outdated: filtered.length,
recommended_now: recommendations.filter((item) =>
includeMajor ? true : item.update_type !== 'major',
).length,
major_available: filtered
.map((item) => toRecommendation(item, true))
.filter((item) => item.update_type === 'major').length,
minor_or_patch_available: filtered
.map((item) => toRecommendation(item, true))
.filter(
(item) =>
item.update_type === 'minor' || item.update_type === 'patch',
).length,
},
recommendations,
grouped_commands: {
safe_batch_command: buildBatchCommand(
safe.map((item) => `${item.package}@${item.recommended_target}`),
),
major_batch_command: includeMajor
? buildBatchCommand(
major.map((item) => `${item.package}@${item.recommended_target}`),
)
: null,
},
warnings,
};
}
private async getOutdatedDependencies(warnings: string[]) {
const attempts: string[][] = [
['outdated', '--recursive', '--format', 'json'],
['outdated', '--recursive', '--json'],
];
let lastError: Error | null = null;
for (const args of attempts) {
const result = await this.deps.executeCommand('pnpm', args);
if (!result.stdout.trim()) {
if (result.exitCode === 0) {
return [] as OutdatedDependency[];
}
warnings.push(
`pnpm ${args.join(' ')} returned no JSON output (exit code ${result.exitCode}).`,
);
lastError = new Error(result.stderr || 'Missing command output');
continue;
}
try {
return normalizeOutdatedJson(JSON.parse(result.stdout));
} catch (error) {
lastError = error instanceof Error ? error : new Error('Invalid JSON');
}
}
throw lastError ?? new Error('Unable to retrieve outdated dependencies');
}
}
function toRecommendation(
dependency: OutdatedDependency,
includeMajor: boolean,
): DepsUpgradeRecommendation {
const updateType = getUpdateType(dependency.current, dependency.latest);
const risk =
updateType === 'major' ? 'high' : updateType === 'minor' ? 'medium' : 'low';
const target =
updateType === 'major' && !includeMajor
? dependency.wanted
: dependency.latest;
return {
package: dependency.package,
workspace: dependency.workspace,
dependency_type: dependency.dependencyType,
current: dependency.current,
wanted: dependency.wanted,
latest: dependency.latest,
update_type: updateType,
risk,
potentially_breaking: updateType === 'major',
recommended_target: target,
recommended_command: `pnpm up -r ${dependency.package}@${target}`,
reason:
updateType === 'major' && !includeMajor
? 'Major version available and potentially breaking; recommended target is the highest non-major range match.'
: `Recommended ${updateType} update based on current vs latest version.`,
};
}
function normalizeOutdatedJson(value: unknown): OutdatedDependency[] {
if (Array.isArray(value)) {
return value.map(normalizeOutdatedItem).filter((item) => item !== null);
}
if (isRecord(value)) {
const rows: OutdatedDependency[] = [];
for (const [workspace, data] of Object.entries(value)) {
if (!isRecord(data)) {
continue;
}
for (const [name, info] of Object.entries(data)) {
if (!isRecord(info)) {
continue;
}
const current = readString(info, 'current');
const wanted = readString(info, 'wanted');
const latest = readString(info, 'latest');
if (!current || !wanted || !latest) {
continue;
}
rows.push({
package: name,
workspace,
dependencyType: readString(info, 'dependencyType') ?? 'unknown',
current,
wanted,
latest,
});
}
}
return rows;
}
return [];
}
function normalizeOutdatedItem(value: unknown): OutdatedDependency | null {
if (!isRecord(value)) {
return null;
}
const name =
readString(value, 'name') ??
readString(value, 'package') ??
readString(value, 'pkgName');
const current = readString(value, 'current');
const wanted = readString(value, 'wanted');
const latest = readString(value, 'latest');
if (!name || !current || !wanted || !latest) {
return null;
}
return {
package: name,
workspace:
readString(value, 'workspace') ??
readString(value, 'dependent') ??
readString(value, 'location') ??
'root',
dependencyType:
readString(value, 'dependencyType') ??
readString(value, 'packageType') ??
'unknown',
current,
wanted,
latest,
};
}
function getUpdateType(current: string, latest: string) {
const currentVersion = parseSemver(current);
const latestVersion = parseSemver(latest);
if (!currentVersion || !latestVersion) {
return 'unknown' as const;
}
if (latestVersion.major > currentVersion.major) {
return 'major' as const;
}
if (latestVersion.minor > currentVersion.minor) {
return 'minor' as const;
}
if (latestVersion.patch > currentVersion.patch) {
return 'patch' as const;
}
return 'unknown' as const;
}
function parseSemver(input: string) {
const match = input.match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}
function buildBatchCommand(upgrades: string[]) {
if (upgrades.length === 0) {
return null;
}
return `pnpm up -r ${upgrades.join(' ')}`;
}
function sortRecommendations(
a: DepsUpgradeRecommendation,
b: DepsUpgradeRecommendation,
) {
const rank: Record<DepsUpgradeRecommendation['risk'], number> = {
high: 0,
medium: 1,
low: 2,
};
return rank[a.risk] - rank[b.risk] || a.package.localeCompare(b.package);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function readString(record: Record<string, unknown>, key: string) {
const value = record[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}

View File

@@ -0,0 +1,122 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
type DepsUpgradeAdvisorDeps,
createDepsUpgradeAdvisorService,
} from './deps-upgrade-advisor.service';
import {
DepsUpgradeAdvisorInputSchema,
DepsUpgradeAdvisorOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
export function registerDepsUpgradeAdvisorTool(server: McpServer) {
return registerDepsUpgradeAdvisorToolWithDeps(
server,
createDepsUpgradeAdvisorDeps(),
);
}
export function registerDepsUpgradeAdvisorToolWithDeps(
server: McpServer,
deps: DepsUpgradeAdvisorDeps,
) {
const service = createDepsUpgradeAdvisorService(deps);
return server.registerTool(
'deps_upgrade_advisor',
{
description:
'Analyze outdated dependencies and return risk-bucketed upgrade recommendations',
inputSchema: DepsUpgradeAdvisorInputSchema,
outputSchema: DepsUpgradeAdvisorOutputSchema,
},
async (input) => {
try {
const parsed = DepsUpgradeAdvisorInputSchema.parse(input);
const result = await service.advise(parsed);
return {
structuredContent: result,
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `deps_upgrade_advisor failed: ${toErrorMessage(error)}`,
},
],
};
}
},
);
}
function createDepsUpgradeAdvisorDeps(): DepsUpgradeAdvisorDeps {
const rootPath = process.cwd();
return {
async executeCommand(command, args) {
try {
const result = await execFileAsync(command, args, {
cwd: rootPath,
maxBuffer: 1024 * 1024 * 10,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
};
} catch (error) {
if (isExecError(error)) {
return {
stdout: error.stdout ?? '',
stderr: error.stderr ?? '',
exitCode: error.code,
};
}
throw error;
}
},
nowIso() {
return new Date().toISOString();
},
};
}
interface ExecError extends Error {
code: number;
stdout?: string;
stderr?: string;
}
function isExecError(error: unknown): error is ExecError {
return error instanceof Error && 'code' in error;
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
export {
createDepsUpgradeAdvisorService,
type DepsUpgradeAdvisorDeps,
} from './deps-upgrade-advisor.service';
export type { DepsUpgradeAdvisorOutput } from './schema';

View File

@@ -0,0 +1,52 @@
import { z } from 'zod/v3';
export const DepsUpgradeAdvisorInputSchema = z.object({
state: z
.object({
includeMajor: z.boolean().optional(),
maxPackages: z.number().int().min(1).max(200).optional(),
includeDevDependencies: z.boolean().optional(),
})
.optional(),
});
export const DepsUpgradeRecommendationSchema = z.object({
package: z.string(),
workspace: z.string(),
dependency_type: z.string(),
current: z.string(),
wanted: z.string(),
latest: z.string(),
update_type: z.enum(['major', 'minor', 'patch', 'unknown']),
risk: z.enum(['high', 'medium', 'low']),
potentially_breaking: z.boolean(),
recommended_target: z.string(),
recommended_command: z.string(),
reason: z.string(),
});
export const DepsUpgradeAdvisorOutputSchema = z.object({
generated_at: z.string(),
summary: z.object({
total_outdated: z.number().int().min(0),
recommended_now: z.number().int().min(0),
major_available: z.number().int().min(0),
minor_or_patch_available: z.number().int().min(0),
}),
recommendations: z.array(DepsUpgradeRecommendationSchema),
grouped_commands: z.object({
safe_batch_command: z.string().nullable(),
major_batch_command: z.string().nullable(),
}),
warnings: z.array(z.string()),
});
export type DepsUpgradeAdvisorInput = z.infer<
typeof DepsUpgradeAdvisorInputSchema
>;
export type DepsUpgradeRecommendation = z.infer<
typeof DepsUpgradeRecommendationSchema
>;
export type DepsUpgradeAdvisorOutput = z.infer<
typeof DepsUpgradeAdvisorOutputSchema
>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,494 @@
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 {
DEFAULT_PORT_CONFIG,
type KitDevServiceDeps,
createKitDevService,
} from './kit-dev.service';
import {
KitDevStartInputSchema,
KitDevStartOutputSchema,
KitDevStatusInputSchema,
KitDevStatusOutputSchema,
KitDevStopInputSchema,
KitDevStopOutputSchema,
KitMailboxStatusInputSchema,
KitMailboxStatusOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
export function registerKitDevTools(server: McpServer) {
const service = createKitDevService(createKitDevDeps());
server.registerTool(
'kit_dev_start',
{
description: 'Start all or selected local development services',
inputSchema: KitDevStartInputSchema,
outputSchema: KitDevStartOutputSchema,
},
async (input) => {
const parsedInput = KitDevStartInputSchema.parse(input);
try {
const result = await service.start(parsedInput);
return {
structuredContent: result,
content: [{ type: 'text', text: JSON.stringify(result) }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `kit_dev_start failed: ${toErrorMessage(error)}`,
},
],
};
}
},
);
server.registerTool(
'kit_dev_stop',
{
description: 'Stop all or selected local development services',
inputSchema: KitDevStopInputSchema,
outputSchema: KitDevStopOutputSchema,
},
async (input) => {
const parsedInput = KitDevStopInputSchema.parse(input);
try {
const result = await service.stop(parsedInput);
return {
structuredContent: result,
content: [{ type: 'text', text: JSON.stringify(result) }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `kit_dev_stop failed: ${toErrorMessage(error)}`,
},
],
};
}
},
);
server.registerTool(
'kit_dev_status',
{
description:
'Check status for app, database, mailbox, and stripe local services',
inputSchema: KitDevStatusInputSchema,
outputSchema: KitDevStatusOutputSchema,
},
async (input) => {
KitDevStatusInputSchema.parse(input);
try {
const result = await service.status();
return {
structuredContent: result,
content: [{ type: 'text', text: JSON.stringify(result) }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `kit_dev_status failed: ${toErrorMessage(error)}`,
},
],
};
}
},
);
server.registerTool(
'kit_mailbox_status',
{
description:
'Check local mailbox health with graceful fallback fields for UI state',
inputSchema: KitMailboxStatusInputSchema,
outputSchema: KitMailboxStatusOutputSchema,
},
async (input) => {
KitMailboxStatusInputSchema.parse(input);
try {
const result = await service.mailboxStatus();
return {
structuredContent: result,
content: [{ type: 'text', text: JSON.stringify(result) }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `kit_mailbox_status failed: ${toErrorMessage(error)}`,
},
],
};
}
},
);
}
export function createKitDevDeps(rootPath = process.cwd()): KitDevServiceDeps {
return {
rootPath,
async resolveVariantContext() {
const packageJson = await readJsonIfPresent(
join(rootPath, 'apps', 'web', 'package.json'),
);
const hasSupabase = await pathExists(
join(rootPath, 'apps', 'web', 'supabase'),
);
const dependencies = {
...(packageJson?.dependencies ?? {}),
...(packageJson?.devDependencies ?? {}),
} as Record<string, unknown>;
const framework =
'react-router' in dependencies || '@react-router/dev' in dependencies
? 'react-router'
: 'nextjs';
if (hasSupabase) {
return {
variant:
framework === 'react-router'
? 'react-router-supabase'
: 'next-supabase',
variantFamily: 'supabase',
framework,
} as const;
}
return {
variant:
framework === 'react-router'
? 'react-router-drizzle'
: 'next-drizzle',
variantFamily: 'orm',
framework,
} as const;
},
async resolvePortConfig() {
const configTomlPath = join(
rootPath,
'apps',
'web',
'supabase',
'config.toml',
);
let supabaseApiPort = DEFAULT_PORT_CONFIG.supabaseApiPort;
let supabaseStudioPort = DEFAULT_PORT_CONFIG.supabaseStudioPort;
try {
const toml = await readFile(configTomlPath, 'utf8');
supabaseApiPort = parseTomlSectionPort(toml, 'api') ?? supabaseApiPort;
supabaseStudioPort =
parseTomlSectionPort(toml, 'studio') ?? supabaseStudioPort;
} catch {
// config.toml not present or unreadable — use defaults.
}
return {
appPort: DEFAULT_PORT_CONFIG.appPort,
supabaseApiPort,
supabaseStudioPort,
mailboxApiPort: DEFAULT_PORT_CONFIG.mailboxApiPort,
mailboxPort: DEFAULT_PORT_CONFIG.mailboxPort,
ormDbPort: DEFAULT_PORT_CONFIG.ormDbPort,
stripeWebhookPath: DEFAULT_PORT_CONFIG.stripeWebhookPath,
};
},
async executeCommand(command: string, args: string[]) {
const result = await executeWithFallback(rootPath, command, args);
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
};
},
async spawnDetached(command: string, args: string[]) {
const child = spawn(command, args, {
cwd: rootPath,
detached: true,
stdio: 'ignore',
});
child.unref();
if (!child.pid) {
throw new Error(`Failed to spawn ${command}`);
}
return {
pid: child.pid,
};
},
async isPortOpen(port: number) {
return checkPort(port);
},
async fetchJson(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
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;
}
},
async isProcessRunning(pid: number) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
},
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 [];
}
},
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);
}
},
async sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
},
};
}
async function executeWithFallback(
rootPath: string,
command: string,
args: string[],
) {
try {
return await execFileAsync(command, args, {
cwd: rootPath,
});
} catch (error) {
if (isLocalCliCandidate(command)) {
const localBinCandidates = [
join(rootPath, 'node_modules', '.bin', command),
join(rootPath, 'apps', 'web', 'node_modules', '.bin', command),
];
for (const localBin of localBinCandidates) {
try {
return await execFileAsync(localBin, args, {
cwd: rootPath,
});
} catch {
// Try next local binary candidate.
}
}
try {
return await execFileAsync('pnpm', ['exec', command, ...args], {
cwd: rootPath,
});
} catch {
return execFileAsync(
'pnpm',
['--filter', 'web', 'exec', command, ...args],
{
cwd: rootPath,
},
);
}
}
throw error;
}
}
function isLocalCliCandidate(command: string) {
return command === 'supabase' || command === 'stripe';
}
async function pathExists(path: string) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function readJsonIfPresent(path: string) {
try {
const content = await readFile(path, 'utf8');
return JSON.parse(content) as {
dependencies?: Record<string, unknown>;
devDependencies?: Record<string, unknown>;
};
} catch {
return null;
}
}
async function checkPort(port: number) {
return new Promise<boolean>((resolve) => {
const socket = new Socket();
socket.setTimeout(200);
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('timeout', () => {
socket.destroy();
resolve(false);
});
socket.once('error', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, '127.0.0.1');
});
}
function parseTomlSectionPort(
content: string,
section: string,
): number | undefined {
const lines = content.split('\n');
let inSection = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[')) {
inSection = trimmed === `[${section}]`;
continue;
}
if (inSection) {
const match = trimmed.match(/^port\s*=\s*(\d+)/);
if (match) {
return Number(match[1]);
}
}
}
return undefined;
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
export { createKitDevService, DEFAULT_PORT_CONFIG } from './kit-dev.service';
export type { KitDevServiceDeps, PortConfig } from './kit-dev.service';
export type {
KitDevStartOutput,
KitDevStatusOutput,
KitDevStopOutput,
} from './schema';

View File

@@ -0,0 +1,723 @@
import type {
DevServiceId,
DevServiceSelection,
DevServiceStatusItem,
KitDevStartInput,
KitDevStartOutput,
KitDevStatusOutput,
KitDevStopInput,
KitDevStopOutput,
KitMailboxStatusOutput,
} from './schema';
type VariantFamily = 'supabase' | 'orm';
type Framework = 'nextjs' | 'react-router';
type SignalName = 'SIGTERM' | 'SIGKILL';
interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface ProcessInfo {
pid: number;
command: string;
}
interface SpawnedProcess {
pid: number;
}
interface VariantContext {
variant: string;
variantFamily: VariantFamily;
framework: Framework;
}
export interface PortConfig {
appPort: number;
supabaseApiPort: number;
supabaseStudioPort: number;
mailboxApiPort: number;
mailboxPort: number;
ormDbPort: number;
stripeWebhookPath: string;
}
export const DEFAULT_PORT_CONFIG: PortConfig = {
appPort: 3000,
supabaseApiPort: 54321,
supabaseStudioPort: 54323,
mailboxApiPort: 54324,
mailboxPort: 8025,
ormDbPort: 5432,
stripeWebhookPath: '/api/billing/webhook',
};
export interface KitDevServiceDeps {
rootPath: string;
resolveVariantContext(): Promise<VariantContext>;
resolvePortConfig(): Promise<PortConfig>;
executeCommand(command: string, args: string[]): Promise<CommandResult>;
spawnDetached(command: string, args: string[]): Promise<SpawnedProcess>;
isPortOpen(port: number): Promise<boolean>;
getPortProcess(port: number): Promise<ProcessInfo | null>;
isProcessRunning(pid: number): Promise<boolean>;
findProcessesByName(pattern: string): Promise<ProcessInfo[]>;
killProcess(pid: number, signal?: SignalName): Promise<void>;
sleep(ms: number): Promise<void>;
fetchJson(url: string): Promise<unknown>;
}
interface MailboxHealth {
connected: boolean;
running: boolean;
apiReachable: boolean;
url: string;
port: number;
reason?: string;
diagnostics?: Record<string, string>;
}
export function createKitDevService(deps: KitDevServiceDeps) {
return new KitDevService(deps);
}
export class KitDevService {
constructor(private readonly deps: KitDevServiceDeps) {}
async start(input: KitDevStartInput): Promise<KitDevStartOutput> {
const selectedServices = this.expandServices(input.services);
const variant = await this.deps.resolveVariantContext();
const ports = await this.deps.resolvePortConfig();
const startedPids: Partial<Record<DevServiceId, number>> = {};
if (selectedServices.includes('database')) {
const running = await this.isDatabaseRunning(variant, ports);
if (!running) {
await this.startDatabase(variant);
}
}
if (
selectedServices.includes('mailbox') &&
variant.variantFamily === 'supabase'
) {
const mailbox = await this.collectMailboxHealth(variant, ports);
if (!mailbox.connected) {
await this.startDatabase(variant);
}
}
if (selectedServices.includes('app')) {
const running = await this.deps.isPortOpen(ports.appPort);
if (!running) {
startedPids.app = await this.startApp(variant, ports);
}
}
if (selectedServices.includes('stripe')) {
const procs = await this.deps.findProcessesByName('stripe.*listen');
if (procs.length === 0) {
startedPids.stripe = await this.startStripe(ports);
}
}
const status = await this.collectStatus(variant, ports, startedPids);
return {
services: status.filter((service) =>
selectedServices.includes(service.id),
),
};
}
async stop(input: KitDevStopInput): Promise<KitDevStopOutput> {
const selectedServices = this.expandServices(input.services);
const variant = await this.deps.resolveVariantContext();
const ports = await this.deps.resolvePortConfig();
const stopped = new Set<DevServiceId>();
if (selectedServices.includes('stripe')) {
const procs = await this.deps.findProcessesByName('stripe.*listen');
for (const proc of procs) {
await this.stopProcess(proc.pid);
}
stopped.add('stripe');
}
if (selectedServices.includes('app')) {
const proc = await this.deps.getPortProcess(ports.appPort);
if (proc) {
await this.stopProcess(proc.pid);
}
stopped.add('app');
}
const shouldStopDatabase =
selectedServices.includes('database') ||
(selectedServices.includes('mailbox') &&
variant.variantFamily === 'supabase');
if (shouldStopDatabase) {
try {
await this.stopDatabase(variant);
} catch {
// Best-effort — the database process may already be stopped or
// the CLI may not be available.
}
if (selectedServices.includes('database')) {
stopped.add('database');
}
if (selectedServices.includes('mailbox')) {
stopped.add('mailbox');
}
} else if (selectedServices.includes('mailbox')) {
stopped.add('mailbox');
}
return {
stopped: Array.from(stopped),
};
}
async status(): Promise<KitDevStatusOutput> {
const variant = await this.deps.resolveVariantContext();
const ports = await this.deps.resolvePortConfig();
const services = await this.collectStatus(variant, ports);
return {
services,
};
}
async mailboxStatus(): Promise<KitMailboxStatusOutput> {
const variant = await this.deps.resolveVariantContext();
const ports = await this.deps.resolvePortConfig();
const mailbox = await this.collectMailboxHealth(variant, ports);
return {
connected: mailbox.connected,
running: mailbox.running,
api_reachable: mailbox.apiReachable,
url: mailbox.url,
port: mailbox.port,
reason: mailbox.reason,
};
}
private expandServices(services: DevServiceSelection[]): DevServiceId[] {
if (services.includes('all')) {
return ['app', 'database', 'mailbox', 'stripe'];
}
const normalized = services.map((service) =>
service === 'mailpit' ? 'mailbox' : service,
);
return Array.from(new Set(normalized)) as DevServiceId[];
}
private async isDatabaseRunning(
variant: VariantContext,
ports: PortConfig,
): Promise<boolean> {
if (variant.variantFamily === 'supabase') {
const apiOpen = await this.deps.isPortOpen(ports.supabaseApiPort);
const studioOpen = await this.deps.isPortOpen(ports.supabaseStudioPort);
return apiOpen || studioOpen;
}
return this.deps.isPortOpen(ports.ormDbPort);
}
private async startDatabase(variant: VariantContext) {
if (variant.variantFamily === 'supabase') {
await this.deps.executeCommand('pnpm', [
'--filter',
'web',
'supabase:start',
]);
await this.deps.sleep(400);
return;
}
await this.deps.executeCommand('docker', [
'compose',
'up',
'-d',
'postgres',
]);
}
private async stopDatabase(variant: VariantContext) {
if (variant.variantFamily === 'supabase') {
await this.deps.executeCommand('pnpm', [
'--filter',
'web',
'supabase:stop',
]);
return;
}
await this.deps.executeCommand('docker', ['compose', 'stop', 'postgres']);
}
private async startApp(
variant: VariantContext,
ports: PortConfig,
): Promise<number> {
const args =
variant.framework === 'react-router'
? ['exec', 'react-router', 'dev', '--port', String(ports.appPort)]
: [
'--filter',
'web',
'exec',
'next',
'dev',
'--port',
String(ports.appPort),
];
const process = await this.deps.spawnDetached('pnpm', args);
await this.deps.sleep(500);
return process.pid;
}
private async startStripe(ports: PortConfig): Promise<number> {
const webhookUrl = `http://localhost:${ports.appPort}${ports.stripeWebhookPath}`;
const process = await this.deps.spawnDetached('pnpm', [
'exec',
'stripe',
'listen',
'--forward-to',
webhookUrl,
]);
return process.pid;
}
private async collectStatus(
variant: VariantContext,
ports: PortConfig,
startedPids: Partial<Record<DevServiceId, number>> = {},
): Promise<DevServiceStatusItem[]> {
const app = await this.collectAppStatus(variant, ports, startedPids);
const database = await this.collectDatabaseStatus(variant, ports);
const mailbox = await this.collectMailboxStatus(variant, ports);
const stripe = await this.collectStripeStatus(ports, startedPids);
return [app, database, mailbox, stripe];
}
private async collectAppStatus(
variant: VariantContext,
ports: PortConfig,
startedPids: Partial<Record<DevServiceId, number>> = {},
): Promise<DevServiceStatusItem> {
const name =
variant.framework === 'react-router'
? 'React Router Dev Server'
: 'Next.js Dev Server';
const portOpen = await this.deps.isPortOpen(ports.appPort);
const proc = portOpen
? await this.deps.getPortProcess(ports.appPort)
: null;
// If we just started the app, the port may not be open yet.
// Fall back to checking if the spawned process is alive.
const justStartedPid = startedPids.app;
const justStartedAlive = justStartedPid
? await this.deps.isProcessRunning(justStartedPid)
: false;
const running = portOpen || justStartedAlive;
return {
id: 'app',
name,
status: running ? 'running' : 'stopped',
port: ports.appPort,
url: running ? `http://localhost:${ports.appPort}` : undefined,
pid: proc?.pid ?? (justStartedAlive ? justStartedPid : null) ?? null,
};
}
private async collectDatabaseStatus(
variant: VariantContext,
ports: PortConfig,
): Promise<DevServiceStatusItem> {
if (variant.variantFamily === 'supabase') {
const extras = await this.resolveSupabaseExtras();
const apiPort =
extractPortFromUrl(extras.api_url) ?? ports.supabaseApiPort;
const studioPort =
extractPortFromUrl(extras.studio_url) ?? ports.supabaseStudioPort;
const portOpen = await this.deps.isPortOpen(apiPort);
const studioOpen = await this.deps.isPortOpen(studioPort);
const running = portOpen || studioOpen;
return {
id: 'database',
name: 'Supabase',
status: running ? 'running' : 'stopped',
port: apiPort,
url:
extras.api_url ??
(running ? `http://127.0.0.1:${apiPort}` : undefined),
extras: running
? {
...(extras.studio_url ? { studio_url: extras.studio_url } : {}),
...(extras.anon_key ? { anon_key: extras.anon_key } : {}),
...(extras.service_role_key
? { service_role_key: extras.service_role_key }
: {}),
}
: undefined,
};
}
const running = await this.deps.isPortOpen(ports.ormDbPort);
return {
id: 'database',
name: 'PostgreSQL',
status: running ? 'running' : 'stopped',
port: ports.ormDbPort,
url: running ? `postgresql://localhost:${ports.ormDbPort}` : undefined,
};
}
private async collectStripeStatus(
ports: PortConfig,
startedPids: Partial<Record<DevServiceId, number>> = {},
): Promise<DevServiceStatusItem> {
const procs = await this.deps.findProcessesByName('stripe.*listen');
const justStartedPid = startedPids.stripe;
const justStartedAlive = justStartedPid
? await this.deps.isProcessRunning(justStartedPid)
: false;
const running = procs.length > 0 || justStartedAlive;
const pid =
procs[0]?.pid ?? (justStartedAlive ? justStartedPid : undefined);
const webhookUrl = procs[0]?.command
? (extractForwardToUrl(procs[0].command) ??
`http://localhost:${ports.appPort}${ports.stripeWebhookPath}`)
: `http://localhost:${ports.appPort}${ports.stripeWebhookPath}`;
return {
id: 'stripe',
name: 'Stripe CLI',
status: running ? 'running' : 'stopped',
pid: pid ?? null,
webhook_url: running ? webhookUrl : undefined,
};
}
private async collectMailboxStatus(
variant: VariantContext,
ports: PortConfig,
): Promise<DevServiceStatusItem> {
const mailbox = await this.collectMailboxHealth(variant, ports);
return {
id: 'mailbox',
name: 'Mailbox',
status: mailbox.running
? 'running'
: mailbox.connected && !mailbox.apiReachable
? 'error'
: 'stopped',
port: mailbox.port,
url: mailbox.url,
extras: mailbox.diagnostics,
};
}
private async collectMailboxHealth(
variant: VariantContext,
ports: PortConfig,
): Promise<MailboxHealth> {
const mailboxUrl = `http://localhost:${ports.mailboxPort}`;
const mailboxApiUrl = `http://127.0.0.1:${ports.mailboxApiPort}/api/v1/info`;
if (variant.variantFamily !== 'supabase') {
return {
connected: false,
running: false,
apiReachable: false,
url: mailboxUrl,
port: ports.mailboxPort,
reason: 'Mailbox is only available for Supabase variants',
};
}
const [apiReachable, containerStatus] = await Promise.all([
this.checkMailboxApi(mailboxApiUrl),
this.resolveMailboxContainerStatus(),
]);
if (apiReachable.ok) {
return {
connected: true,
running: true,
apiReachable: true,
url: mailboxUrl,
port: ports.mailboxPort,
};
}
if (containerStatus.running) {
const reason =
'Mailbox container is running, but Mailpit API is unreachable';
return {
connected: true,
running: false,
apiReachable: false,
url: mailboxUrl,
port: ports.mailboxPort,
reason,
diagnostics: {
reason,
api_url: mailboxApiUrl,
...(apiReachable.error ? { api_error: apiReachable.error } : {}),
...(containerStatus.source
? { container_source: containerStatus.source }
: {}),
...(containerStatus.details
? { container_details: containerStatus.details }
: {}),
},
};
}
return {
connected: false,
running: false,
apiReachable: false,
url: mailboxUrl,
port: ports.mailboxPort,
reason: 'Mailbox is not running',
diagnostics: {
reason: 'Mailbox is not running',
api_url: mailboxApiUrl,
...(apiReachable.error ? { api_error: apiReachable.error } : {}),
...(containerStatus.details
? { container_details: containerStatus.details }
: {}),
},
};
}
private async resolveMailboxContainerStatus(): Promise<{
running: boolean;
source?: string;
details?: string;
}> {
const dockerComposeResult = await this.tryGetRunningServicesFromCommand(
'docker',
[
'compose',
'-f',
'docker-compose.dev.yml',
'ps',
'--status',
'running',
'--services',
],
'docker-compose.dev.yml',
);
if (dockerComposeResult.running) {
return dockerComposeResult;
}
const supabaseDockerResult = await this.tryGetRunningServicesFromCommand(
'docker',
['ps', '--format', '{{.Names}}'],
'docker ps',
);
return supabaseDockerResult;
}
private async tryGetRunningServicesFromCommand(
command: string,
args: string[],
source: string,
): Promise<{ running: boolean; source?: string; details?: string }> {
try {
const result = await this.deps.executeCommand(command, args);
const serviceLines = result.stdout
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter(Boolean);
const running = serviceLines.some((line) =>
/(mailpit|inbucket)/.test(line),
);
return {
running,
source,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return {
running: false,
source,
details: message,
};
}
}
private async checkMailboxApi(url: string): Promise<{
ok: boolean;
error?: string;
}> {
try {
await this.deps.fetchJson(url);
return { ok: true };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
private async resolveSupabaseExtras() {
try {
const result = await this.deps.executeCommand('pnpm', [
'--filter',
'web',
'supabase',
'status',
'-o',
'env',
]);
return parseSupabaseEnvOutput(result.stdout);
} catch {
try {
const result = await this.deps.executeCommand('pnpm', [
'--filter',
'web',
'supabase:status',
]);
return parseSupabaseTextOutput(result.stdout);
} catch {
return {
api_url: undefined,
studio_url: undefined,
anon_key: undefined,
service_role_key: undefined,
};
}
}
}
private async stopProcess(pid: number) {
try {
await this.deps.killProcess(pid, 'SIGTERM');
await this.deps.sleep(200);
const running = await this.deps.isProcessRunning(pid);
if (running) {
await this.deps.killProcess(pid, 'SIGKILL');
}
} catch {
// noop - process may already be dead.
}
}
}
function parseSupabaseEnvOutput(output: string) {
const values: Record<string, string> = {};
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const idx = trimmed.indexOf('=');
if (idx <= 0) {
continue;
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (key) {
values[key] = value;
}
}
return {
api_url: values.API_URL,
studio_url: values.STUDIO_URL,
anon_key: values.ANON_KEY,
service_role_key: values.SERVICE_ROLE_KEY,
};
}
function extractPortFromUrl(url: string | undefined): number | undefined {
if (!url) {
return undefined;
}
try {
const parsed = new URL(url);
const port = Number(parsed.port);
return Number.isFinite(port) && port > 0 ? port : undefined;
} catch {
return undefined;
}
}
function parseSupabaseTextOutput(output: string) {
const findValue = (label: string) => {
const regex = new RegExp(`${label}\\s*:\\s*(.+)`);
const match = output.match(regex);
return match?.[1]?.trim();
};
return {
api_url: findValue('API URL'),
studio_url: findValue('Studio URL'),
anon_key: findValue('anon key'),
service_role_key: findValue('service_role key'),
};
}
function extractForwardToUrl(command: string): string | undefined {
const match = command.match(/--forward-to\s+(\S+)/);
return match?.[1];
}

View File

@@ -0,0 +1,69 @@
import { z } from 'zod/v3';
const DevServiceIdSchema = z.enum(['app', 'database', 'stripe', 'mailbox']);
const DevServiceSelectionSchema = z.enum([
'all',
'app',
'database',
'stripe',
'mailbox',
'mailpit',
]);
const DevServiceStatusItemSchema = z.object({
id: DevServiceIdSchema,
name: z.string(),
status: z.enum(['running', 'stopped', 'error']),
port: z.number().nullable().optional(),
url: z.string().optional(),
pid: z.number().nullable().optional(),
webhook_url: z.string().optional(),
extras: z.record(z.string()).optional(),
});
export const KitDevStartInputSchema = z.object({
services: z.array(DevServiceSelectionSchema).min(1).default(['all']),
});
export const KitDevStartOutputSchema = z.object({
services: z.array(DevServiceStatusItemSchema),
});
export const KitDevStopInputSchema = z.object({
services: z.array(DevServiceSelectionSchema).min(1).default(['all']),
});
export const KitDevStopOutputSchema = z.object({
stopped: z.array(DevServiceIdSchema),
});
export const KitDevStatusInputSchema = z.object({});
export const KitDevStatusOutputSchema = z.object({
services: z.array(DevServiceStatusItemSchema),
});
export const KitMailboxStatusInputSchema = z.object({});
export const KitMailboxStatusOutputSchema = z.object({
connected: z.boolean(),
running: z.boolean(),
api_reachable: z.boolean(),
url: z.string().optional(),
port: z.number().optional(),
reason: z.string().optional(),
});
export type DevServiceId = z.infer<typeof DevServiceIdSchema>;
export type DevServiceSelection = z.infer<typeof DevServiceSelectionSchema>;
export type DevServiceStatusItem = z.infer<typeof DevServiceStatusItemSchema>;
export type KitDevStartInput = z.infer<typeof KitDevStartInputSchema>;
export type KitDevStartOutput = z.infer<typeof KitDevStartOutputSchema>;
export type KitDevStopInput = z.infer<typeof KitDevStopInputSchema>;
export type KitDevStopOutput = z.infer<typeof KitDevStopOutputSchema>;
export type KitDevStatusInput = z.infer<typeof KitDevStatusInputSchema>;
export type KitDevStatusOutput = z.infer<typeof KitDevStatusOutputSchema>;
export type KitMailboxStatusInput = z.infer<typeof KitMailboxStatusInputSchema>;
export type KitMailboxStatusOutput = z.infer<
typeof KitMailboxStatusOutputSchema
>;

View File

@@ -0,0 +1,292 @@
import { describe, expect, it } from 'vitest';
import {
type KitEmailsDeps,
createKitEmailsService,
} from '../kit-emails.service';
function createDeps(
files: Record<string, string>,
directories: string[],
): KitEmailsDeps {
const store = { ...files };
const dirSet = new Set(directories);
return {
rootPath: '/repo',
async readFile(filePath: string) {
if (!(filePath in store)) {
const error = new Error(
`ENOENT: no such file: ${filePath}`,
) as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return store[filePath]!;
},
async readdir(dirPath: string) {
if (!dirSet.has(dirPath)) {
return [];
}
return Object.keys(store)
.filter((p) => {
const parent = p.substring(0, p.lastIndexOf('/'));
return parent === dirPath;
})
.map((p) => p.substring(p.lastIndexOf('/') + 1));
},
async fileExists(filePath: string) {
return filePath in store || dirSet.has(filePath);
},
async renderReactEmail() {
return null;
},
};
}
const REACT_DIR = '/repo/packages/email-templates/src/emails';
const SUPABASE_DIR = '/repo/apps/web/supabase/templates';
describe('KitEmailsService.list', () => {
it('discovers React Email templates with -email suffix in id', async () => {
const deps = createDeps(
{
[`${REACT_DIR}/invite.email.tsx`]:
'export function renderInviteEmail() {}',
[`${REACT_DIR}/otp.email.tsx`]: 'export function renderOtpEmail() {}',
},
[REACT_DIR],
);
const service = createKitEmailsService(deps);
const result = await service.list();
expect(result.templates).toHaveLength(2);
expect(result.categories).toEqual(['transactional']);
expect(result.total).toBe(2);
const invite = result.templates.find((t) => t.id === 'invite-email');
expect(invite).toBeDefined();
expect(invite!.name).toBe('Invite');
expect(invite!.category).toBe('transactional');
expect(invite!.file).toBe(
'packages/email-templates/src/emails/invite.email.tsx',
);
const otp = result.templates.find((t) => t.id === 'otp-email');
expect(otp).toBeDefined();
expect(otp!.name).toBe('Otp');
});
it('discovers Supabase Auth HTML templates', async () => {
const deps = createDeps(
{
[`${SUPABASE_DIR}/magic-link.html`]: '<html>magic</html>',
[`${SUPABASE_DIR}/reset-password.html`]: '<html>reset</html>',
},
[SUPABASE_DIR],
);
const service = createKitEmailsService(deps);
const result = await service.list();
expect(result.templates).toHaveLength(2);
expect(result.categories).toEqual(['supabase-auth']);
const magicLink = result.templates.find((t) => t.id === 'magic-link');
expect(magicLink).toBeDefined();
expect(magicLink!.name).toBe('Magic Link');
expect(magicLink!.category).toBe('supabase-auth');
expect(magicLink!.file).toBe('apps/web/supabase/templates/magic-link.html');
});
it('discovers both types and returns sorted categories', async () => {
const deps = createDeps(
{
[`${REACT_DIR}/invite.email.tsx`]:
'export function renderInviteEmail() {}',
[`${SUPABASE_DIR}/confirm-email.html`]: '<html>confirm</html>',
},
[REACT_DIR, SUPABASE_DIR],
);
const service = createKitEmailsService(deps);
const result = await service.list();
expect(result.templates).toHaveLength(2);
expect(result.categories).toEqual(['supabase-auth', 'transactional']);
expect(result.total).toBe(2);
});
it('handles empty directories gracefully', async () => {
const deps = createDeps({}, []);
const service = createKitEmailsService(deps);
const result = await service.list();
expect(result.templates).toEqual([]);
expect(result.categories).toEqual([]);
expect(result.total).toBe(0);
});
it('ignores non-email files in the directories', async () => {
const deps = createDeps(
{
[`${REACT_DIR}/invite.email.tsx`]:
'export function renderInviteEmail() {}',
[`${REACT_DIR}/utils.ts`]: 'export const helper = true;',
[`${REACT_DIR}/README.md`]: '# readme',
[`${SUPABASE_DIR}/magic-link.html`]: '<html>magic</html>',
[`${SUPABASE_DIR}/config.json`]: '{}',
},
[REACT_DIR, SUPABASE_DIR],
);
const service = createKitEmailsService(deps);
const result = await service.list();
expect(result.templates).toHaveLength(2);
});
it('avoids id collision between React otp-email and Supabase otp', async () => {
const deps = createDeps(
{
[`${REACT_DIR}/otp.email.tsx`]: 'export function renderOtpEmail() {}',
[`${SUPABASE_DIR}/otp.html`]: '<html>otp</html>',
},
[REACT_DIR, SUPABASE_DIR],
);
const service = createKitEmailsService(deps);
const result = await service.list();
const ids = result.templates.map((t) => t.id);
expect(ids).toContain('otp-email');
expect(ids).toContain('otp');
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('KitEmailsService.read', () => {
it('reads a React Email template and extracts props', async () => {
const source = `
interface Props {
teamName: string;
teamLogo?: string;
inviter: string | undefined;
invitedUserEmail: string;
link: string;
productName: string;
language?: string;
}
export async function renderInviteEmail(props: Props) {}
`;
const deps = createDeps(
{
[`${REACT_DIR}/invite.email.tsx`]: source,
},
[REACT_DIR],
);
const service = createKitEmailsService(deps);
const result = await service.read({ id: 'invite-email' });
expect(result.id).toBe('invite-email');
expect(result.name).toBe('Invite');
expect(result.category).toBe('transactional');
expect(result.source).toBe(source);
expect(result.props).toEqual([
{ name: 'teamName', type: 'string', required: true },
{ name: 'teamLogo', type: 'string', required: false },
{ name: 'inviter', type: 'string | undefined', required: true },
{ name: 'invitedUserEmail', type: 'string', required: true },
{ name: 'link', type: 'string', required: true },
{ name: 'productName', type: 'string', required: true },
{ name: 'language', type: 'string', required: false },
]);
});
it('reads a Supabase HTML template with empty props', async () => {
const html = '<html><body>Magic Link</body></html>';
const deps = createDeps(
{
[`${SUPABASE_DIR}/magic-link.html`]: html,
},
[SUPABASE_DIR],
);
const service = createKitEmailsService(deps);
const result = await service.read({ id: 'magic-link' });
expect(result.id).toBe('magic-link');
expect(result.source).toBe(html);
expect(result.props).toEqual([]);
});
it('throws for unknown template id', async () => {
const deps = createDeps({}, []);
const service = createKitEmailsService(deps);
await expect(service.read({ id: 'nonexistent' })).rejects.toThrow(
'Email template not found: "nonexistent"',
);
});
it('handles templates without Props interface', async () => {
const source =
'export async function renderSimpleEmail() { return { html: "" }; }';
const deps = createDeps(
{
[`${REACT_DIR}/simple.email.tsx`]: source,
},
[REACT_DIR],
);
const service = createKitEmailsService(deps);
const result = await service.read({ id: 'simple-email' });
expect(result.props).toEqual([]);
});
});
describe('Path safety', () => {
it('rejects ids with path traversal', async () => {
const deps = createDeps({}, []);
const service = createKitEmailsService(deps);
await expect(service.read({ id: '../etc/passwd' })).rejects.toThrow(
'Template id must not contain ".."',
);
});
it('rejects ids with forward slashes', async () => {
const deps = createDeps({}, []);
const service = createKitEmailsService(deps);
await expect(service.read({ id: 'foo/bar' })).rejects.toThrow(
'Template id must not include path separators',
);
});
it('rejects ids with backslashes', async () => {
const deps = createDeps({}, []);
const service = createKitEmailsService(deps);
await expect(service.read({ id: 'foo\\bar' })).rejects.toThrow(
'Template id must not include path separators',
);
});
});

View File

@@ -0,0 +1,109 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
type KitEmailsDeps,
createKitEmailsDeps,
createKitEmailsService,
} from './kit-emails.service';
import {
KitEmailsListInputSchema,
KitEmailsListOutputSchema,
KitEmailsReadInputSchema,
KitEmailsReadOutputSchema,
} from './schema';
type TextContent = {
type: 'text';
text: string;
};
export function registerKitEmailTemplatesTools(server: McpServer) {
const service = createKitEmailsService(createKitEmailsDeps());
server.registerTool(
'kit_email_templates_list',
{
description:
'List project email template files (React Email + Supabase auth templates), not received inbox messages',
inputSchema: KitEmailsListInputSchema,
outputSchema: KitEmailsListOutputSchema,
},
async () => {
try {
const result = await service.list();
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_email_templates_list', error);
}
},
);
server.registerTool(
'kit_email_templates_read',
{
description:
'Read a project email template source file by template id, with extracted props and optional rendered HTML sample',
inputSchema: KitEmailsReadInputSchema,
outputSchema: KitEmailsReadOutputSchema,
},
async (input) => {
try {
const { id } = KitEmailsReadInputSchema.parse(input);
const result = await service.read({ id });
const content: TextContent[] = [];
// Return source, props, and metadata
const { renderedHtml, ...metadata } = result;
content.push({ type: 'text', text: JSON.stringify(metadata) });
// Include rendered HTML as a separate content block
if (renderedHtml) {
content.push({
type: 'text',
text: `\n\n--- Rendered HTML ---\n\n${renderedHtml}`,
});
}
return {
structuredContent: result,
content,
};
} catch (error) {
return buildErrorResponse('kit_email_templates_read', error);
}
},
);
}
export const registerKitEmailsTools = registerKitEmailTemplatesTools;
function buildErrorResponse(tool: string, error: unknown) {
const message = `${tool} failed: ${toErrorMessage(error)}`;
return {
isError: true,
content: buildTextContent(message),
};
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
function buildTextContent(text: string): TextContent[] {
return [{ type: 'text', text }];
}
export { createKitEmailsService, createKitEmailsDeps };
export type { KitEmailsDeps };
export type { KitEmailsListOutput, KitEmailsReadOutput } from './schema';

View File

@@ -0,0 +1,289 @@
import path from 'node:path';
import { EMAIL_TEMPLATE_RENDERERS } from '@kit/email-templates/registry';
import type { KitEmailsListOutput, KitEmailsReadOutput } from './schema';
export interface KitEmailsDeps {
rootPath: string;
readFile(filePath: string): Promise<string>;
readdir(dirPath: string): Promise<string[]>;
fileExists(filePath: string): Promise<boolean>;
renderReactEmail(
sampleProps: Record<string, string>,
templateId: string,
): Promise<string | null>;
}
interface EmailTemplate {
id: string;
name: string;
category: string;
file: string;
description: string;
}
export function createKitEmailsService(deps: KitEmailsDeps) {
return new KitEmailsService(deps);
}
export class KitEmailsService {
constructor(private readonly deps: KitEmailsDeps) {}
async list(): Promise<KitEmailsListOutput> {
const templates: EmailTemplate[] = [];
const reactTemplates = await this.discoverReactEmailTemplates();
const supabaseTemplates = await this.discoverSupabaseAuthTemplates();
templates.push(...reactTemplates, ...supabaseTemplates);
const categories = [...new Set(templates.map((t) => t.category))].sort();
return {
templates,
categories,
total: templates.length,
};
}
async read(input: { id: string }): Promise<KitEmailsReadOutput> {
assertSafeId(input.id);
const { templates } = await this.list();
const template = templates.find((t) => t.id === input.id);
if (!template) {
throw new Error(`Email template not found: "${input.id}"`);
}
const absolutePath = path.resolve(this.deps.rootPath, template.file);
ensureInsideRoot(absolutePath, this.deps.rootPath, input.id);
const source = await this.deps.readFile(absolutePath);
const isReactEmail = absolutePath.includes('packages/email-templates');
const props = isReactEmail ? extractPropsFromSource(source) : [];
let renderedHtml: string | null = null;
if (isReactEmail) {
const sampleProps = buildSampleProps(props);
renderedHtml = await this.deps.renderReactEmail(sampleProps, template.id);
}
return {
id: template.id,
name: template.name,
category: template.category,
file: template.file,
source,
props,
renderedHtml,
};
}
private async discoverReactEmailTemplates(): Promise<EmailTemplate[]> {
const dir = path.join('packages', 'email-templates', 'src', 'emails');
const absoluteDir = path.resolve(this.deps.rootPath, dir);
if (!(await this.deps.fileExists(absoluteDir))) {
return [];
}
const files = await this.deps.readdir(absoluteDir);
const templates: EmailTemplate[] = [];
for (const file of files) {
if (!file.endsWith('.email.tsx')) {
continue;
}
const stem = file.replace(/\.email\.tsx$/, '');
const id = `${stem}-email`;
const name = humanize(stem);
templates.push({
id,
name,
category: 'transactional',
file: path.join(dir, file),
description: `${name} transactional email template`,
});
}
return templates.sort((a, b) => a.id.localeCompare(b.id));
}
private async discoverSupabaseAuthTemplates(): Promise<EmailTemplate[]> {
const dir = path.join('apps', 'web', 'supabase', 'templates');
const absoluteDir = path.resolve(this.deps.rootPath, dir);
if (!(await this.deps.fileExists(absoluteDir))) {
return [];
}
const files = await this.deps.readdir(absoluteDir);
const templates: EmailTemplate[] = [];
for (const file of files) {
if (!file.endsWith('.html')) {
continue;
}
const id = file.replace(/\.html$/, '');
const name = humanize(id);
templates.push({
id,
name,
category: 'supabase-auth',
file: path.join(dir, file),
description: `${name} Supabase auth email template`,
});
}
return templates.sort((a, b) => a.id.localeCompare(b.id));
}
}
function humanize(kebab: string): string {
return kebab
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function extractPropsFromSource(
source: string,
): Array<{ name: string; type: string; required: boolean }> {
const interfaceMatch = source.match(/interface\s+Props\s*\{([^}]*)\}/);
if (!interfaceMatch?.[1]) {
return [];
}
const body = interfaceMatch[1];
const props: Array<{ name: string; type: string; required: boolean }> = [];
const propRegex = /(\w+)(\??):\s*([^;\n]+)/g;
let match: RegExpExecArray | null;
while ((match = propRegex.exec(body)) !== null) {
const name = match[1]!;
const optional = match[2] === '?';
const type = match[3]!.trim();
props.push({
name,
type,
required: !optional,
});
}
return props;
}
function ensureInsideRoot(resolved: string, root: string, input: string) {
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
if (!resolved.startsWith(normalizedRoot) && resolved !== root) {
throw new Error(
`Invalid path: "${input}" resolves outside the project root`,
);
}
return resolved;
}
function buildSampleProps(
props: Array<{ name: string; type: string; required: boolean }>,
): Record<string, string> {
const sample: Record<string, string> = {};
for (const prop of props) {
if (prop.name === 'language') continue;
sample[prop.name] = SAMPLE_PROP_VALUES[prop.name] ?? `Sample ${prop.name}`;
}
return sample;
}
const SAMPLE_PROP_VALUES: Record<string, string> = {
productName: 'Makerkit',
teamName: 'Acme Team',
inviter: 'John Doe',
invitedUserEmail: 'user@example.com',
link: 'https://example.com/action',
otp: '123456',
email: 'user@example.com',
name: 'Jane Doe',
userName: 'Jane Doe',
};
function assertSafeId(id: string) {
if (id.includes('..')) {
throw new Error('Template id must not contain ".."');
}
if (id.includes('/') || id.includes('\\')) {
throw new Error('Template id must not include path separators');
}
}
export function createKitEmailsDeps(rootPath = process.cwd()): KitEmailsDeps {
return {
rootPath,
async readFile(filePath: string) {
const fs = await import('node:fs/promises');
return fs.readFile(filePath, 'utf8');
},
async readdir(dirPath: string) {
const fs = await import('node:fs/promises');
return fs.readdir(dirPath);
},
async fileExists(filePath: string) {
const fs = await import('node:fs/promises');
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
},
async renderReactEmail(
sampleProps: Record<string, string>,
templateId?: string,
) {
const renderFromRegistry =
typeof templateId === 'string'
? EMAIL_TEMPLATE_RENDERERS?.[templateId]
: undefined;
if (typeof renderFromRegistry === 'function') {
const result = await renderFromRegistry(sampleProps);
if (typeof result === 'string') {
return result;
}
if (
typeof result === 'object' &&
result !== null &&
'html' in result &&
typeof (result as { html: unknown }).html === 'string'
) {
return (result as { html: string }).html;
}
return null;
}
throw new Error(`Email template renderer not found: "${templateId}"`);
},
};
}

View File

@@ -0,0 +1,46 @@
import { z } from 'zod/v3';
export const KitEmailsListInputSchema = z.object({});
const EmailTemplateSchema = z.object({
id: z.string(),
name: z.string(),
category: z.string(),
file: z.string(),
description: z.string(),
});
const KitEmailsListSuccessOutputSchema = z.object({
templates: z.array(EmailTemplateSchema),
categories: z.array(z.string()),
total: z.number(),
});
export const KitEmailsListOutputSchema = KitEmailsListSuccessOutputSchema;
export const KitEmailsReadInputSchema = z.object({
id: z.string().min(1),
});
const PropSchema = z.object({
name: z.string(),
type: z.string(),
required: z.boolean(),
});
const KitEmailsReadSuccessOutputSchema = z.object({
id: z.string(),
name: z.string(),
category: z.string(),
file: z.string(),
source: z.string(),
props: z.array(PropSchema),
renderedHtml: z.string().nullable(),
});
export const KitEmailsReadOutputSchema = KitEmailsReadSuccessOutputSchema;
export type KitEmailsListInput = z.infer<typeof KitEmailsListInputSchema>;
export type KitEmailsListOutput = z.infer<typeof KitEmailsListOutputSchema>;
export type KitEmailsReadInput = z.infer<typeof KitEmailsReadInputSchema>;
export type KitEmailsReadOutput = z.infer<typeof KitEmailsReadOutputSchema>;

View File

@@ -0,0 +1,845 @@
import { describe, expect, it } from 'vitest';
import { type KitEnvDeps, createKitEnvService } from '../kit-env.service';
import { processEnvDefinitions } from '../scanner';
import { KitEnvUpdateInputSchema } from '../schema';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createDeps(
files: Record<string, string> = {},
overrides: Partial<KitEnvDeps> = {},
): KitEnvDeps & { _store: Record<string, string> } {
const store = { ...files };
return {
rootPath: '/repo',
async readFile(filePath: string) {
if (!(filePath in store)) {
const error = new Error(
`ENOENT: no such file: ${filePath}`,
) as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return store[filePath]!;
},
async writeFile(filePath: string, content: string) {
store[filePath] = content;
},
async fileExists(filePath: string) {
return filePath in store;
},
...overrides,
get _store() {
return store;
},
} as KitEnvDeps & { _store: Record<string, string> };
}
// ---------------------------------------------------------------------------
// getSchema
// ---------------------------------------------------------------------------
describe('KitEnvService.getSchema', () => {
it('returns grouped env variables with expected shape', async () => {
const service = createKitEnvService(createDeps());
const result = await service.getSchema();
expect(result.groups.length).toBeGreaterThan(0);
for (const group of result.groups) {
expect(group.name).toBeTruthy();
expect(group.description).toBeTruthy();
expect(group.variables.length).toBeGreaterThan(0);
for (const variable of group.variables) {
expect(variable.key).toBeTruthy();
expect(typeof variable.required).toBe('boolean');
expect(typeof variable.sensitive).toBe('boolean');
expect(
['string', 'url', 'email', 'number', 'boolean', 'enum'].includes(
variable.type,
),
).toBe(true);
}
}
});
it('includes Stripe variables with dependency metadata', async () => {
const service = createKitEnvService(createDeps());
const result = await service.getSchema();
const billingGroup = result.groups.find((g) => g.name === 'Billing');
expect(billingGroup).toBeDefined();
const stripeSecret = billingGroup!.variables.find(
(v) => v.key === 'STRIPE_SECRET_KEY',
);
expect(stripeSecret).toBeDefined();
expect(stripeSecret!.sensitive).toBe(true);
expect(stripeSecret!.dependencies).toBeDefined();
expect(stripeSecret!.dependencies!.length).toBeGreaterThan(0);
expect(stripeSecret!.dependencies![0]!.variable).toBe(
'NEXT_PUBLIC_BILLING_PROVIDER',
);
});
});
// ---------------------------------------------------------------------------
// update
// ---------------------------------------------------------------------------
describe('KitEnvService.update', () => {
it('replaces an existing key in-place', async () => {
const deps = createDeps({
'/repo/apps/web/.env.local':
'# Comment\nEMAIL_SENDER=team@example.com\nOTHER=foo\n',
});
const service = createKitEnvService(deps);
const result = await service.update({
key: 'EMAIL_SENDER',
value: 'hello@example.com',
file: '.env.local',
});
expect(result.success).toBe(true);
const content = deps._store['/repo/apps/web/.env.local']!;
expect(content).toContain('EMAIL_SENDER=hello@example.com');
// preserves comment
expect(content).toContain('# Comment');
// preserves other keys
expect(content).toContain('OTHER=foo');
});
it('appends new key when it does not exist', async () => {
const deps = createDeps({
'/repo/apps/web/.env.local': 'EXISTING=value\n',
});
const service = createKitEnvService(deps);
await service.update({
key: 'NEW_KEY',
value: 'new_value',
file: '.env.local',
});
const content = deps._store['/repo/apps/web/.env.local']!;
expect(content).toContain('EXISTING=value');
expect(content).toContain('NEW_KEY=new_value');
});
it('creates file if it does not exist', async () => {
const deps = createDeps({});
const service = createKitEnvService(deps);
await service.update({
key: 'BRAND_NEW',
value: 'value',
file: '.env.local',
});
const content = deps._store['/repo/apps/web/.env.local']!;
expect(content).toContain('BRAND_NEW=value');
});
it('throws when key is missing', async () => {
const service = createKitEnvService(createDeps());
await expect(service.update({ value: 'v' })).rejects.toThrow(
'Both key and value are required',
);
});
it('throws when value is missing', async () => {
const service = createKitEnvService(createDeps());
await expect(service.update({ key: 'FOO' })).rejects.toThrow(
'Both key and value are required',
);
});
it('resolves default file for secret key in development mode', async () => {
const deps = createDeps({});
const service = createKitEnvService(deps);
// STRIPE_SECRET_KEY is marked as secret in the model
await service.update({
key: 'STRIPE_SECRET_KEY',
value: 'sk_test_123',
mode: 'development',
});
// Secret keys default to .env.local in dev mode
expect(deps._store['/repo/apps/web/.env.local']).toContain(
'STRIPE_SECRET_KEY=sk_test_123',
);
});
it('resolves default file for key without explicit secret flag (defaults to secret)', async () => {
const deps = createDeps({});
const service = createKitEnvService(deps);
// NEXT_PUBLIC_SITE_URL has no explicit `secret` field in the model.
// resolveDefaultFile defaults unknown to secret=true (conservative),
// so it should go to .env.local in development mode.
await service.update({
key: 'NEXT_PUBLIC_SITE_URL',
value: 'http://localhost:3000',
mode: 'development',
});
expect(deps._store['/repo/apps/web/.env.local']).toContain(
'NEXT_PUBLIC_SITE_URL=http://localhost:3000',
);
});
it('resolves default file for secret key in production mode', async () => {
const deps = createDeps({});
const service = createKitEnvService(deps);
await service.update({
key: 'STRIPE_SECRET_KEY',
value: 'sk_live_abc',
mode: 'production',
});
// Secret keys in prod default to .env.production.local
expect(deps._store['/repo/apps/web/.env.production.local']).toContain(
'STRIPE_SECRET_KEY=sk_live_abc',
);
});
it('does not default file in MCP schema', () => {
const parsed = KitEnvUpdateInputSchema.parse({
key: 'FOO',
value: 'bar',
mode: 'production',
});
expect(parsed.file).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Path traversal prevention
// ---------------------------------------------------------------------------
describe('KitEnvService — path traversal prevention', () => {
it('rejects file paths that traverse outside web directory', async () => {
const service = createKitEnvService(createDeps());
await expect(service.rawRead('../../../../etc/passwd')).rejects.toThrow(
'resolves outside the web app directory',
);
});
it('rejects rawWrite with traversal path', async () => {
const service = createKitEnvService(createDeps());
await expect(
service.rawWrite('../../../etc/evil', 'malicious'),
).rejects.toThrow('resolves outside the web app directory');
});
it('rejects update with traversal file path', async () => {
const service = createKitEnvService(createDeps());
await expect(
service.update({ key: 'FOO', value: 'bar', file: '../../.env' }),
).rejects.toThrow('resolves outside the web app directory');
});
it('allows valid file names within web directory', async () => {
const deps = createDeps({
'/repo/apps/web/.env.local': 'KEY=val',
});
const service = createKitEnvService(deps);
const result = await service.rawRead('.env.local');
expect(result.exists).toBe(true);
});
});
// ---------------------------------------------------------------------------
// rawRead / rawWrite
// ---------------------------------------------------------------------------
describe('KitEnvService.rawRead', () => {
it('returns content when file exists', async () => {
const service = createKitEnvService(
createDeps({
'/repo/apps/web/.env.local': '# My env\nFOO=bar\n',
}),
);
const result = await service.rawRead('.env.local');
expect(result.exists).toBe(true);
expect(result.content).toBe('# My env\nFOO=bar\n');
});
it('returns empty + exists:false when file missing', async () => {
const service = createKitEnvService(createDeps({}));
const result = await service.rawRead('.env.local');
expect(result.exists).toBe(false);
expect(result.content).toBe('');
});
});
describe('KitEnvService.rawWrite', () => {
it('overwrites file with raw content', async () => {
const deps = createDeps({
'/repo/apps/web/.env.local': 'OLD=content',
});
const service = createKitEnvService(deps);
const result = await service.rawWrite('.env.local', '# New\nNEW=value');
expect(result.success).toBe(true);
expect(deps._store['/repo/apps/web/.env.local']).toBe('# New\nNEW=value');
});
it('creates file when it does not exist', async () => {
const deps = createDeps({});
const service = createKitEnvService(deps);
await service.rawWrite('.env.production', 'PROD_KEY=val');
expect(deps._store['/repo/apps/web/.env.production']).toBe('PROD_KEY=val');
});
});
// ---------------------------------------------------------------------------
// read — mode-based file precedence
// ---------------------------------------------------------------------------
describe('KitEnvService.read — file precedence', () => {
it('returns variables with mode information when pointing to real workspace', async () => {
// Use the actual monorepo root — will scan real .env files
const service = createKitEnvService(
createDeps(
{},
{ rootPath: process.cwd().replace(/\/packages\/mcp-server$/, '') },
),
);
const result = await service.read('development');
expect(result.mode).toBe('development');
expect(typeof result.variables).toBe('object');
});
});
// ---------------------------------------------------------------------------
// getAppState / getVariable — injected fs
// ---------------------------------------------------------------------------
describe('KitEnvService — injected fs', () => {
it('getAppState reads from injected fs', async () => {
const deps = createDeps(
{
'/repo/apps/web/.env': 'FOO=bar\n',
},
{
readdir: async (dirPath: string) => {
if (dirPath === '/repo/apps') {
return ['web'];
}
return [];
},
stat: async (path: string) => ({
isDirectory: () => path === '/repo/apps/web',
}),
},
);
const service = createKitEnvService(deps);
const states = await service.getAppState('development');
expect(states).toHaveLength(1);
expect(states[0]!.variables['FOO']!.effectiveValue).toBe('bar');
});
it('getVariable reads from injected fs', async () => {
const deps = createDeps(
{
'/repo/apps/web/.env': 'HELLO=world\n',
},
{
readdir: async (dirPath: string) => {
if (dirPath === '/repo/apps') {
return ['web'];
}
return [];
},
stat: async (path: string) => ({
isDirectory: () => path === '/repo/apps/web',
}),
},
);
const service = createKitEnvService(deps);
const value = await service.getVariable('HELLO', 'development');
expect(value).toBe('world');
});
});
// ---------------------------------------------------------------------------
// processEnvDefinitions — override chains
// ---------------------------------------------------------------------------
describe('processEnvDefinitions — override chains', () => {
it('resolves override with development precedence (.env < .env.development < .env.local)', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://base.com',
source: '.env',
},
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://dev.com',
source: '.env.development',
},
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://local.com',
source: '.env.local',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const variable = result.variables['NEXT_PUBLIC_SITE_URL'];
expect(variable).toBeDefined();
// .env.local has highest precedence in development
expect(variable!.effectiveValue).toBe('https://local.com');
expect(variable!.effectiveSource).toBe('.env.local');
expect(variable!.isOverridden).toBe(true);
expect(variable!.definitions).toHaveLength(3);
});
it('resolves override with production precedence (.env < .env.production < .env.local < .env.production.local)', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://base.com',
source: '.env',
},
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://prod.com',
source: '.env.production',
},
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://local.com',
source: '.env.local',
},
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://prod-local.com',
source: '.env.production.local',
},
],
};
const result = processEnvDefinitions(envInfo, 'production');
const variable = result.variables['NEXT_PUBLIC_SITE_URL'];
expect(variable!.effectiveValue).toBe('https://prod-local.com');
expect(variable!.effectiveSource).toBe('.env.production.local');
expect(variable!.isOverridden).toBe(true);
});
it('marks single-source variable as NOT overridden', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://site.com',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const variable = result.variables['NEXT_PUBLIC_SITE_URL'];
expect(variable!.isOverridden).toBe(false);
expect(variable!.effectiveSource).toBe('.env');
});
});
// ---------------------------------------------------------------------------
// processEnvDefinitions — conditional requirements (Stripe keys)
// ---------------------------------------------------------------------------
describe('processEnvDefinitions — conditional requirements', () => {
it('flags STRIPE_SECRET_KEY as invalid when billing provider is stripe and key is missing', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
value: 'stripe',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const stripeKey = result.variables['STRIPE_SECRET_KEY'];
expect(stripeKey).toBeDefined();
expect(stripeKey!.effectiveSource).toBe('MISSING');
expect(stripeKey!.validation.success).toBe(false);
expect(stripeKey!.validation.error.issues.length).toBeGreaterThan(0);
// Regression guard: contextual message must be preserved, NOT replaced
// by generic "required but missing"
expect(
stripeKey!.validation.error.issues.some((i) =>
i.includes('NEXT_PUBLIC_BILLING_PROVIDER'),
),
).toBe(true);
});
it('does NOT flag STRIPE_SECRET_KEY when billing provider is lemon-squeezy', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
value: 'lemon-squeezy',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const stripeKey = result.variables['STRIPE_SECRET_KEY'];
expect(stripeKey).toBeUndefined();
});
it('flags LEMON_SQUEEZY_SECRET_KEY as invalid when provider is lemon-squeezy and key is missing', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
value: 'lemon-squeezy',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const lsKey = result.variables['LEMON_SQUEEZY_SECRET_KEY'];
expect(lsKey).toBeDefined();
expect(lsKey!.effectiveSource).toBe('MISSING');
expect(lsKey!.validation.success).toBe(false);
});
it('validates Stripe key format (must start with sk_ or rk_)', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
value: 'stripe',
source: '.env',
},
{
key: 'STRIPE_SECRET_KEY',
value: 'invalid_key_123',
source: '.env.local',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const stripeKey = result.variables['STRIPE_SECRET_KEY'];
expect(stripeKey).toBeDefined();
expect(stripeKey!.validation.success).toBe(false);
expect(
stripeKey!.validation.error.issues.some(
(i) =>
i.toLowerCase().includes('sk_') || i.toLowerCase().includes('rk_'),
),
).toBe(true);
});
it('passes Stripe key validation when key format is correct', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
value: 'stripe',
source: '.env',
},
{
key: 'STRIPE_SECRET_KEY',
value: 'sk_test_abc123',
source: '.env.local',
},
{
key: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY',
value: 'pk_test_abc123',
source: '.env',
},
{
key: 'STRIPE_WEBHOOK_SECRET',
value: 'whsec_abc123',
source: '.env.local',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const stripeKey = result.variables['STRIPE_SECRET_KEY'];
expect(stripeKey!.validation.success).toBe(true);
});
});
// ---------------------------------------------------------------------------
// processEnvDefinitions — cross-variable validations
// ---------------------------------------------------------------------------
describe('processEnvDefinitions — cross-variable validations', () => {
it('flags SUPABASE_SERVICE_ROLE_KEY when same as ANON_KEY', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_SUPABASE_URL',
value: 'http://localhost:54321',
source: '.env',
},
{
key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
value: 'same-key',
source: '.env',
},
{
key: 'SUPABASE_SERVICE_ROLE_KEY',
value: 'same-key',
source: '.env.local',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const serviceKey = result.variables['SUPABASE_SERVICE_ROLE_KEY'];
expect(serviceKey).toBeDefined();
expect(serviceKey!.validation.success).toBe(false);
expect(
serviceKey!.validation.error.issues.some((i) =>
i.toLowerCase().includes('different'),
),
).toBe(true);
});
it('passes when SUPABASE_SERVICE_ROLE_KEY differs from ANON_KEY', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_SUPABASE_URL',
value: 'http://localhost:54321',
source: '.env',
},
{
key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
value: 'anon-key-123',
source: '.env',
},
{
key: 'SUPABASE_SERVICE_ROLE_KEY',
value: 'service-key-456',
source: '.env.local',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const serviceKey = result.variables['SUPABASE_SERVICE_ROLE_KEY'];
expect(serviceKey!.validation.success).toBe(true);
});
});
// ---------------------------------------------------------------------------
// processEnvDefinitions — mode-aware URL validation
// ---------------------------------------------------------------------------
describe('processEnvDefinitions — mode-aware validations', () => {
it('accepts http:// SITE_URL in development mode', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'http://localhost:3000',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const siteUrl = result.variables['NEXT_PUBLIC_SITE_URL'];
expect(siteUrl!.validation.success).toBe(true);
});
it('rejects http:// SITE_URL in production mode', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'http://example.com',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'production');
const siteUrl = result.variables['NEXT_PUBLIC_SITE_URL'];
expect(siteUrl!.validation.success).toBe(false);
expect(
siteUrl!.validation.error.issues.some((i) =>
i.toLowerCase().includes('https'),
),
).toBe(true);
});
it('accepts https:// SITE_URL in production mode', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_SITE_URL',
value: 'https://example.com',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'production');
const siteUrl = result.variables['NEXT_PUBLIC_SITE_URL'];
expect(siteUrl!.validation.success).toBe(true);
});
});
// ---------------------------------------------------------------------------
// processEnvDefinitions — missing required variables
// ---------------------------------------------------------------------------
describe('processEnvDefinitions — missing required variables', () => {
it('injects missing required variables with MISSING source', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_BILLING_PROVIDER',
value: 'stripe',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
// NEXT_PUBLIC_SITE_URL is required and missing
const siteUrl = result.variables['NEXT_PUBLIC_SITE_URL'];
expect(siteUrl).toBeDefined();
expect(siteUrl!.effectiveSource).toBe('MISSING');
expect(siteUrl!.validation.success).toBe(false);
});
});
// ---------------------------------------------------------------------------
// processEnvDefinitions — CAPTCHA conditional dependency
// ---------------------------------------------------------------------------
describe('processEnvDefinitions — CAPTCHA conditional dependency', () => {
it('flags CAPTCHA_SECRET_TOKEN as required when CAPTCHA_SITE_KEY is set', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [
{
key: 'NEXT_PUBLIC_CAPTCHA_SITE_KEY',
value: 'cap_site_123',
source: '.env',
},
],
};
const result = processEnvDefinitions(envInfo, 'development');
const captchaSecret = result.variables['CAPTCHA_SECRET_TOKEN'];
expect(captchaSecret).toBeDefined();
expect(captchaSecret!.effectiveSource).toBe('MISSING');
expect(captchaSecret!.validation.success).toBe(false);
});
it('does NOT flag CAPTCHA_SECRET_TOKEN when CAPTCHA_SITE_KEY is empty/absent', () => {
const envInfo = {
appName: 'web',
filePath: '/repo/apps/web',
variables: [],
};
const result = processEnvDefinitions(envInfo, 'development');
const captchaSecret = result.variables['CAPTCHA_SECRET_TOKEN'];
expect(captchaSecret).toBeUndefined();
});
});

View File

@@ -0,0 +1,177 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
type KitEnvDeps,
createKitEnvDeps,
createKitEnvService,
} from './kit-env.service';
import {
KitEnvRawReadInputSchema,
KitEnvRawReadOutputSchema,
KitEnvRawWriteInputSchema,
KitEnvRawWriteOutputSchema,
KitEnvReadInputSchema,
KitEnvReadOutputSchema,
KitEnvSchemaInputSchema,
KitEnvSchemaOutputSchema,
KitEnvUpdateInputSchema,
KitEnvUpdateOutputSchema,
} from './schema';
type TextContent = {
type: 'text';
text: string;
};
export function registerKitEnvTools(server: McpServer) {
const service = createKitEnvService(createKitEnvDeps());
server.registerTool(
'kit_env_schema',
{
description: 'Return environment variable schema for this kit variant',
inputSchema: KitEnvSchemaInputSchema,
outputSchema: KitEnvSchemaOutputSchema,
},
async () => {
try {
const result = await service.getSchema();
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_env_schema', error);
}
},
);
server.registerTool(
'kit_env_read',
{
description: 'Read environment variables and validation state',
inputSchema: KitEnvReadInputSchema,
outputSchema: KitEnvReadOutputSchema,
},
async (input) => {
try {
const parsed = KitEnvReadInputSchema.parse(input);
const result = await service.read(parsed.mode);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_env_read', error);
}
},
);
server.registerTool(
'kit_env_update',
{
description: 'Update one environment variable in a target .env file',
inputSchema: KitEnvUpdateInputSchema,
outputSchema: KitEnvUpdateOutputSchema,
},
async (input) => {
try {
const parsed = KitEnvUpdateInputSchema.parse(input);
const result = await service.update(parsed);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_env_update', error);
}
},
);
server.registerTool(
'kit_env_raw_read',
{
description: 'Read raw content of an .env file',
inputSchema: KitEnvRawReadInputSchema,
outputSchema: KitEnvRawReadOutputSchema,
},
async (input) => {
try {
const parsed = KitEnvRawReadInputSchema.parse(input);
const result = await service.rawRead(parsed.file);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_env_raw_read', error);
}
},
);
server.registerTool(
'kit_env_raw_write',
{
description: 'Write raw content to an .env file',
inputSchema: KitEnvRawWriteInputSchema,
outputSchema: KitEnvRawWriteOutputSchema,
},
async (input) => {
try {
const parsed = KitEnvRawWriteInputSchema.parse(input);
const result = await service.rawWrite(parsed.file, parsed.content);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_env_raw_write', error);
}
},
);
}
function buildErrorResponse(tool: string, error: unknown) {
const message = `${tool} failed: ${toErrorMessage(error)}`;
return {
isError: true,
content: buildTextContent(message),
};
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
function buildTextContent(text: string): TextContent[] {
return [{ type: 'text', text }];
}
export {
createKitEnvService,
createKitEnvDeps,
envVariables,
findWorkspaceRoot,
scanMonorepoEnv,
processEnvDefinitions,
getEnvState,
getVariable,
} from './public-api';
export type { KitEnvDeps };
export type { EnvVariableModel } from './model';
export type {
EnvMode,
AppEnvState,
EnvFileInfo,
EnvVariableState,
} from './types';

View File

@@ -0,0 +1,320 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { envVariables } from './model';
import { getEnvState } from './scanner';
import type { EnvMode, ScanFs } from './types';
export interface KitEnvDeps {
rootPath: string;
readFile(filePath: string): Promise<string>;
writeFile(filePath: string, content: string): Promise<void>;
fileExists(filePath: string): Promise<boolean>;
readdir?(dirPath: string): Promise<string[]>;
stat?(path: string): Promise<{ isDirectory(): boolean }>;
}
export function createKitEnvService(deps: KitEnvDeps) {
return new KitEnvService(deps);
}
export class KitEnvService {
constructor(private readonly deps: KitEnvDeps) {}
async getSchema() {
const groups = new Map<
string,
Array<{
key: string;
label: string;
description: string;
required: boolean;
type: 'string' | 'url' | 'email' | 'number' | 'boolean' | 'enum';
sensitive: boolean;
values?: string[];
hint?: string;
dependencies?: Array<{ variable: string; condition: string }>;
}>
>();
for (const variable of envVariables) {
const category = variable.category;
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push({
key: variable.name,
label: variable.displayName,
description: variable.description,
required: variable.required ?? false,
type: mapType(variable.type),
sensitive: variable.secret ?? false,
values: variable.values?.filter(
(v): v is string => typeof v === 'string',
),
hint: variable.hint,
dependencies: variable.contextualValidation?.dependencies.map(
(dep) => ({
variable: dep.variable,
condition: dep.message,
}),
),
});
}
return {
groups: Array.from(groups.entries()).map(([name, variables]) => ({
name,
description: `${name} configuration`,
variables,
})),
};
}
async read(mode: EnvMode) {
const scanFs = this.getScanFs();
const states = await getEnvState({
mode,
apps: ['web'],
rootDir: this.deps.rootPath,
fs: scanFs,
});
const webState = states.find((state) => state.appName === 'web');
if (!webState) {
return {
mode,
variables: {},
};
}
const allVariables = Object.values(webState.variables).reduce(
(acc, variable) => {
acc[variable.key] = variable.effectiveValue;
return acc;
},
{} as Record<string, string>,
);
const variables = Object.fromEntries(
Object.entries(webState.variables).map(([key, variable]) => {
const model = envVariables.find((item) => item.name === key);
return [
key,
{
key,
value: variable.effectiveValue,
source: variable.effectiveSource,
isOverridden: variable.isOverridden,
overrideChain:
variable.definitions.length > 1
? variable.definitions.map((definition) => ({
source: definition.source,
value: definition.value,
}))
: undefined,
validation: {
valid: variable.validation.success,
errors: variable.validation.error.issues,
},
dependencies: model?.contextualValidation?.dependencies.map(
(dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const satisfied = dep.condition(dependencyValue, allVariables);
return {
variable: dep.variable,
condition: dep.message,
satisfied,
};
},
),
},
];
}),
);
return {
mode,
variables,
};
}
async update(input: {
key?: string;
value?: string;
file?: string;
mode?: EnvMode;
}) {
if (!input.key || typeof input.value !== 'string') {
throw new Error('Both key and value are required for kit_env_update');
}
const fileName =
input.file ??
this.resolveDefaultFile(input.key, input.mode ?? 'development');
const targetPath = this.resolveWebFile(fileName);
let content = '';
if (await this.deps.fileExists(targetPath)) {
content = await this.deps.readFile(targetPath);
}
const lines = content.length > 0 ? content.split('\n') : [];
let replaced = false;
const updatedLines = lines.map((line) => {
if (line.startsWith(`${input.key}=`)) {
replaced = true;
return `${input.key}=${input.value}`;
}
return line;
});
if (!replaced) {
if (
updatedLines.length > 0 &&
updatedLines[updatedLines.length - 1] !== ''
) {
updatedLines.push('');
}
updatedLines.push(`${input.key}=${input.value}`);
}
await this.deps.writeFile(targetPath, updatedLines.join('\n'));
return {
success: true,
message: `Updated ${input.key} in ${fileName}`,
};
}
async rawRead(file: string) {
const targetPath = this.resolveWebFile(file);
if (!(await this.deps.fileExists(targetPath))) {
return {
content: '',
exists: false,
};
}
return {
content: await this.deps.readFile(targetPath),
exists: true,
};
}
async rawWrite(file: string, content: string) {
const targetPath = this.resolveWebFile(file);
await this.deps.writeFile(targetPath, content);
return {
success: true,
message: `Saved ${file}`,
};
}
async getVariable(key: string, mode: EnvMode) {
const result = await this.read(mode);
return result.variables[key]?.value ?? '';
}
async getAppState(mode: EnvMode) {
const scanFs = this.getScanFs();
const states = await getEnvState({
mode,
apps: ['web'],
rootDir: this.deps.rootPath,
fs: scanFs,
});
return states;
}
private resolveDefaultFile(key: string, mode: EnvMode) {
const model = envVariables.find((item) => item.name === key);
const isSecret = model?.secret ?? true;
if (mode === 'production') {
return isSecret ? '.env.production.local' : '.env.production';
}
return isSecret ? '.env.local' : '.env.development';
}
private resolveWebFile(fileName: string) {
const webDir = path.resolve(this.deps.rootPath, 'apps', 'web');
const resolved = path.resolve(webDir, fileName);
// Prevent path traversal outside the web app directory
if (!resolved.startsWith(webDir + path.sep) && resolved !== webDir) {
throw new Error(
`Invalid file path: "${fileName}" resolves outside the web app directory`,
);
}
return resolved;
}
private getScanFs(): ScanFs | undefined {
if (!this.deps.readdir || !this.deps.stat) {
return undefined;
}
return {
readFile: (filePath) => this.deps.readFile(filePath),
readdir: (dirPath) => this.deps.readdir!(dirPath),
stat: (path) => this.deps.stat!(path),
};
}
}
function mapType(
type?: string,
): 'string' | 'url' | 'email' | 'number' | 'boolean' | 'enum' {
if (
type === 'url' ||
type === 'email' ||
type === 'number' ||
type === 'boolean' ||
type === 'enum'
) {
return type;
}
return 'string';
}
export function createKitEnvDeps(rootPath = process.cwd()): KitEnvDeps {
return {
rootPath,
readFile(filePath: string) {
return fs.readFile(filePath, 'utf8');
},
writeFile(filePath: string, content: string) {
return fs.writeFile(filePath, content, 'utf8');
},
readdir(dirPath: string) {
return fs.readdir(dirPath);
},
stat(path: string) {
return fs.stat(path);
},
async fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
},
};
}

1430
packages/mcp-server/src/tools/env/model.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
export { envVariables } from './model';
export {
findWorkspaceRoot,
getEnvState,
getVariable,
processEnvDefinitions,
scanMonorepoEnv,
} from './scanner';
export { createKitEnvDeps, createKitEnvService } from './kit-env.service';

View File

@@ -0,0 +1,480 @@
import fs from 'fs/promises';
import { existsSync } from 'node:fs';
import path from 'path';
import { envVariables } from './model';
import {
AppEnvState,
EnvFileInfo,
EnvMode,
EnvVariableState,
ScanFs,
ScanOptions,
} from './types';
// Define precedence order for each mode
const ENV_FILE_PRECEDENCE: Record<EnvMode, string[]> = {
development: [
'.env',
'.env.development',
'.env.local',
'.env.development.local',
],
production: [
'.env',
'.env.production',
'.env.local',
'.env.production.local',
],
};
function getSourcePrecedence(source: string, mode: EnvMode): number {
return ENV_FILE_PRECEDENCE[mode].indexOf(source);
}
export async function scanMonorepoEnv(
options: ScanOptions,
): Promise<EnvFileInfo[]> {
const {
rootDir = findWorkspaceRoot(process.cwd()),
apps = ['web'],
mode,
} = options;
const defaultFs: ScanFs = {
readFile: (filePath) => fs.readFile(filePath, 'utf-8'),
readdir: (dirPath) => fs.readdir(dirPath),
stat: (path) => fs.stat(path),
};
const fsApi = options.fs ?? defaultFs;
const envTypes = ENV_FILE_PRECEDENCE[mode];
const appsDir = path.join(rootDir, 'apps');
const results: EnvFileInfo[] = [];
try {
const appDirs = await fsApi.readdir(appsDir);
for (const appName of appDirs) {
if (apps.length > 0 && !apps.includes(appName)) {
continue;
}
const appDir = path.join(appsDir, appName);
const stat = await fsApi.stat(appDir);
if (!stat.isDirectory()) {
continue;
}
const appInfo: EnvFileInfo = {
appName,
filePath: appDir,
variables: [],
};
for (const envType of envTypes) {
const envPath = path.join(appDir, envType);
try {
const content = await fsApi.readFile(envPath);
const vars = parseEnvFile(content, envType);
appInfo.variables.push(...vars);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`Error reading ${envPath}:`, error);
}
}
}
results.push(appInfo);
}
} catch (error) {
console.error('Error scanning monorepo:', error);
throw error;
}
return results;
}
function parseEnvFile(content: string, source: string) {
const variables: Array<{ key: string; value: string; source: string }> = [];
const lines = content.split('\n');
for (const line of lines) {
// Skip comments and empty lines
if (line.trim().startsWith('#') || !line.trim()) {
continue;
}
// Match KEY=VALUE pattern, handling quotes
const match = line.match(/^([^=]+)=(.*)$/);
if (match) {
const [, key = '', rawValue] = match;
let value = rawValue ?? '';
// Remove quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// Handle escaped quotes within the value
value = value
.replace(/\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\\\/g, '\\');
variables.push({
key: key.trim(),
value: value.trim(),
source,
});
}
}
return variables;
}
export function processEnvDefinitions(
envInfo: EnvFileInfo,
mode: EnvMode,
): AppEnvState {
const variableMap: Record<string, EnvVariableState> = {};
// First pass: Collect all definitions
for (const variable of envInfo.variables) {
if (!variable) {
continue;
}
const model = envVariables.find((v) => variable.key === v.name);
if (!variableMap[variable.key]) {
variableMap[variable.key] = {
key: variable.key,
isVisible: true,
definitions: [],
effectiveValue: variable.value,
effectiveSource: variable.source,
isOverridden: false,
category: model ? model.category : 'Custom',
validation: {
success: true,
error: {
issues: [],
},
},
};
}
const varState = variableMap[variable.key];
if (!varState) {
continue;
}
varState.definitions.push({
key: variable.key,
value: variable.value,
source: variable.source,
});
}
// Second pass: Determine effective values and override status
for (const key in variableMap) {
const varState = variableMap[key];
if (!varState) {
continue;
}
// Sort definitions by mode-specific precedence
varState.definitions.sort(
(a, b) =>
getSourcePrecedence(a.source, mode) -
getSourcePrecedence(b.source, mode),
);
if (varState.definitions.length > 1) {
const lastDef = varState.definitions[varState.definitions.length - 1];
if (!lastDef) {
continue;
}
const highestPrecedence = getSourcePrecedence(lastDef.source, mode);
varState.isOverridden = true;
varState.effectiveValue = lastDef.value;
varState.effectiveSource = lastDef.source;
// Check for conflicts at highest precedence
const conflictingDefs = varState.definitions.filter(
(def) => getSourcePrecedence(def.source, mode) === highestPrecedence,
);
if (conflictingDefs.length > 1) {
varState.effectiveSource = `${varState.effectiveSource} (conflict)`;
}
}
}
// Build a lookup of all effective values once (used by validations below)
const allVariables: Record<string, string> = {};
for (const key in variableMap) {
const varState = variableMap[key];
if (varState) {
allVariables[varState.key] = varState.effectiveValue;
}
}
// after computing the effective values, we can check for errors
for (const key in variableMap) {
const model = envVariables.find((v) => key === v.name);
const varState = variableMap[key];
if (!varState) {
continue;
}
let validation: {
success: boolean;
error: {
issues: string[];
};
} = { success: true, error: { issues: [] } };
if (model) {
// First check if it's required but missing (use pre-computed allVariables)
if (model.required && !varState.effectiveValue) {
validation = {
success: false,
error: {
issues: [
`This variable is required but missing from your environment files`,
],
},
};
} else if (model.contextualValidation) {
// Then check contextual validation
const dependenciesMet = model.contextualValidation.dependencies.some(
(dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
return dep.condition(dependencyValue, allVariables);
},
);
if (dependenciesMet) {
// Only check for missing value or run validation if dependencies are met
if (!varState.effectiveValue) {
const dependencyErrors = model.contextualValidation.dependencies
.map((dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(
dependencyValue,
allVariables,
);
if (shouldValidate) {
const { success } = model.contextualValidation!.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (success) {
return null;
}
return dep.message;
}
return null;
})
.filter((message): message is string => message !== null);
validation = {
success: dependencyErrors.length === 0,
error: {
issues: dependencyErrors
.map((message) => message)
.filter((message) => !!message),
},
};
} else {
// If we have a value and dependencies are met, run contextual validation
const result = model.contextualValidation.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (!result.success) {
validation = {
success: false,
error: {
issues: result.error.issues
.map((issue) => issue.message)
.filter((message) => !!message),
},
};
}
}
}
} else if (model.validate && varState.effectiveValue) {
// Only run regular validation if:
// 1. There's no contextual validation
// 2. There's a value to validate
const result = model.validate({
value: varState.effectiveValue,
variables: allVariables,
mode,
});
if (!result.success) {
validation = {
success: false,
error: {
issues: result.error.issues
.map((issue) => issue.message)
.filter((message) => !!message),
},
};
}
}
}
varState.validation = validation;
}
// Final pass: Validate missing variables that are marked as required
// or as having contextual validation
for (const model of envVariables) {
// If the variable exists in appState, use that
const existingVar = variableMap[model.name];
if (existingVar) {
// If the variable is already in the map, skip it
continue;
}
if (model.contextualValidation) {
// Check if any dependency condition is met for this missing variable
const errors = model.contextualValidation.dependencies.flatMap((dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(dependencyValue, allVariables);
if (!shouldValidate) {
return [];
}
// Validate with the missing variable's empty value
const validation = model.contextualValidation!.validate({
value: '',
variables: allVariables,
mode,
});
if (!validation.success) {
return [dep.message];
}
return [];
});
if (errors.length === 0) {
continue;
}
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: errors,
},
},
};
} else if (model.required) {
// Required but no contextual validation — generic required error
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: [
`This variable is required but missing from your environment files`,
],
},
},
};
}
}
return {
appName: envInfo.appName,
filePath: envInfo.filePath,
mode,
variables: variableMap,
};
}
export async function getEnvState(
options: ScanOptions,
): Promise<AppEnvState[]> {
const envInfos = await scanMonorepoEnv(options);
return envInfos.map((info) => processEnvDefinitions(info, options.mode));
}
export async function getVariable(key: string, mode: EnvMode) {
// Get the processed environment state for all apps (you can limit to 'web' via options)
const envStates = await getEnvState({ mode, apps: ['web'] });
// Find the state for the "web" app.
const webState = envStates.find((state) => state.appName === 'web');
// Return the effectiveValue based on override status.
return webState?.variables[key]?.effectiveValue ?? '';
}
export function findWorkspaceRoot(startPath: string) {
let current = startPath;
for (let depth = 0; depth < 6; depth++) {
const maybeWorkspace = path.join(current, 'pnpm-workspace.yaml');
if (existsSync(maybeWorkspace)) {
return current;
}
const parent = path.join(current, '..');
if (parent === current) {
break;
}
current = parent;
}
return startPath;
}

View File

@@ -0,0 +1,102 @@
import { z } from 'zod/v3';
export const KitEnvModeSchema = z.enum(['development', 'production']);
export const KitEnvSchemaInputSchema = z.object({});
export const KitEnvReadInputSchema = z.object({
mode: KitEnvModeSchema.default('development'),
});
export const KitEnvUpdateInputSchema = z.object({
key: z.string().min(1),
value: z.string(),
mode: KitEnvModeSchema.optional(),
file: z.string().optional(),
});
export const KitEnvRawReadInputSchema = z.object({
file: z.string().min(1),
});
export const KitEnvRawWriteInputSchema = z.object({
file: z.string().min(1),
content: z.string(),
});
export const KitEnvSchemaOutputSchema = z.object({
groups: z.array(
z.object({
name: z.string(),
description: z.string(),
variables: z.array(
z.object({
key: z.string(),
label: z.string(),
description: z.string(),
required: z.boolean(),
type: z.enum(['string', 'url', 'email', 'number', 'boolean', 'enum']),
sensitive: z.boolean(),
values: z.array(z.string()).optional(),
hint: z.string().optional(),
dependencies: z
.array(
z.object({
variable: z.string(),
condition: z.string(),
}),
)
.optional(),
}),
),
}),
),
});
export const KitEnvReadOutputSchema = z.object({
mode: KitEnvModeSchema,
variables: z.record(
z.object({
key: z.string(),
value: z.string(),
source: z.string(),
isOverridden: z.boolean(),
overrideChain: z
.array(
z.object({
source: z.string(),
value: z.string(),
}),
)
.optional(),
validation: z.object({
valid: z.boolean(),
errors: z.array(z.string()),
}),
dependencies: z
.array(
z.object({
variable: z.string(),
condition: z.string(),
satisfied: z.boolean(),
}),
)
.optional(),
}),
),
});
export const KitEnvUpdateOutputSchema = z.object({
success: z.boolean(),
message: z.string(),
});
export const KitEnvRawReadOutputSchema = z.object({
content: z.string(),
exists: z.boolean(),
});
export const KitEnvRawWriteOutputSchema = z.object({
success: z.boolean(),
message: z.string(),
});

View File

@@ -0,0 +1,54 @@
export type EnvMode = 'development' | 'production';
export type ScanFs = {
readFile: (filePath: string) => Promise<string>;
readdir: (dirPath: string) => Promise<string[]>;
stat: (path: string) => Promise<{ isDirectory(): boolean }>;
};
export type ScanOptions = {
apps?: string[];
rootDir?: string;
mode: EnvMode;
fs?: ScanFs;
};
export type EnvDefinition = {
key: string;
value: string;
source: string;
};
export type EnvVariableState = {
key: string;
category: string;
definitions: EnvDefinition[];
effectiveValue: string;
isOverridden: boolean;
effectiveSource: string;
isVisible: boolean;
validation: {
success: boolean;
error: {
issues: string[];
};
};
};
export type AppEnvState = {
appName: string;
filePath: string;
mode: EnvMode;
variables: Record<string, EnvVariableState>;
};
export type EnvFileInfo = {
appName: string;
filePath: string;
variables: Array<{
key: string;
value: string;
source: string;
}>;
};

View File

@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest';
import {
type KitMailboxDeps,
createKitMailboxService,
} from '../kit-mailbox.service';
function createDeps(overrides: Partial<KitMailboxDeps> = {}): KitMailboxDeps {
return {
async executeCommand() {
return {
stdout: '',
stderr: '',
exitCode: 0,
};
},
async isPortOpen() {
return true;
},
async fetchJson() {
return {};
},
async requestJson() {
return {};
},
...overrides,
};
}
describe('KitMailboxService', () => {
it('lists messages from Mailpit API', async () => {
const service = createKitMailboxService(
createDeps({
async executeCommand() {
return { stdout: 'mailpit\n', stderr: '', exitCode: 0 };
},
async fetchJson(url: string) {
if (url.endsWith('/info')) {
return { version: 'v1' };
}
return {
total: 1,
unread: 1,
count: 1,
messages: [
{
ID: 'abc',
MessageID: 'm-1',
Subject: 'Welcome',
From: [{ Name: 'Makerkit', Address: 'noreply@makerkit.dev' }],
To: [{ Address: 'user@example.com' }],
Created: '2025-01-01T00:00:00Z',
Size: 123,
Read: false,
ReadAt: null,
},
],
};
},
}),
);
const result = await service.list({ start: 0, limit: 50 });
expect(result.mail_server.running).toBe(true);
expect(result.mail_server.running_via_docker).toBe(true);
expect(result.total).toBe(1);
expect(result.messages[0]).toEqual({
id: 'abc',
message_id: 'm-1',
subject: 'Welcome',
from: ['Makerkit <noreply@makerkit.dev>'],
to: ['user@example.com'],
created_at: '2025-01-01T00:00:00Z',
size: 123,
read: false,
});
expect(result.messages[0]?.readAt).toBeUndefined();
});
it('reads single message details', async () => {
const service = createKitMailboxService(
createDeps({
async executeCommand() {
return { stdout: '', stderr: '', exitCode: 0 };
},
async fetchJson(url: string) {
if (url.endsWith('/info')) {
return { version: 'v1' };
}
if (url.includes('/message/')) {
return {
ID: 'abc',
MessageID: 'm-1',
Subject: 'Welcome',
From: [{ Address: 'noreply@makerkit.dev' }],
To: [{ Address: 'user@example.com' }],
Cc: [{ Address: 'team@example.com' }],
Bcc: [],
Text: ['Hello user'],
HTML: ['<p>Hello user</p>'],
Headers: {
Subject: ['Welcome'],
},
Read: true,
ReadAt: '2025-01-01T00:05:00Z',
Size: 456,
Created: '2025-01-01T00:00:00Z',
};
}
return {};
},
}),
);
const result = await service.read({ id: 'abc' });
expect(result.id).toBe('abc');
expect(result.subject).toBe('Welcome');
expect(result.from).toEqual(['noreply@makerkit.dev']);
expect(result.to).toEqual(['user@example.com']);
expect(result.cc).toEqual(['team@example.com']);
expect(result.text).toBe('Hello user');
expect(result.html).toBe('<p>Hello user</p>');
expect(result.read).toBe(true);
expect(result.readAt).toBe('2025-01-01T00:05:00Z');
expect(result.headers).toEqual({ Subject: ['Welcome'] });
});
it('updates read status for a message', async () => {
const service = createKitMailboxService(
createDeps({
async requestJson(url: string) {
expect(url).toContain('/message/abc/read');
return {
id: 'abc',
read: true,
readAt: '2025-01-01T00:10:00Z',
};
},
}),
);
const result = await service.setReadStatus({ id: 'abc', read: true });
expect(result).toEqual({
id: 'abc',
read: true,
readAt: '2025-01-01T00:10:00Z',
});
});
it('throws if mailpit runs in docker but API port is not open', async () => {
const service = createKitMailboxService(
createDeps({
async isPortOpen() {
return false;
},
async executeCommand() {
return { stdout: 'mailpit\n', stderr: '', exitCode: 0 };
},
}),
);
await expect(service.list({ start: 0, limit: 50 })).rejects.toThrow(
'Mailpit appears running in docker but API is unreachable at http://127.0.0.1:8025',
);
});
it('throws if mailpit is not running', async () => {
const service = createKitMailboxService(
createDeps({
async isPortOpen() {
return false;
},
async executeCommand() {
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
);
await expect(service.list({ start: 0, limit: 50 })).rejects.toThrow(
'Mailpit is not running. Start local services with "pnpm compose:dev:up".',
);
});
});

View File

@@ -0,0 +1,194 @@
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 {
type KitMailboxDeps,
createKitMailboxService,
} from './kit-mailbox.service';
import {
KitEmailsListInputSchema,
KitEmailsListOutputSchema,
KitEmailsReadInputSchema,
KitEmailsReadOutputSchema,
KitEmailsSetReadStatusInputSchema,
KitEmailsSetReadStatusOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
type TextContent = {
type: 'text';
text: string;
};
export function registerKitEmailsTools(server: McpServer) {
const service = createKitMailboxService(createKitMailboxDeps());
server.registerTool(
'kit_emails_list',
{
description:
'List received emails from the local Mailpit inbox (runtime mailbox, not source templates)',
inputSchema: KitEmailsListInputSchema,
outputSchema: KitEmailsListOutputSchema,
},
async (input) => {
try {
const parsed = KitEmailsListInputSchema.parse(input);
const result = await service.list(parsed);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_emails_list', error);
}
},
);
server.registerTool(
'kit_emails_read',
{
description:
'Read a received email from the local Mailpit inbox by message id (includes text/html/headers)',
inputSchema: KitEmailsReadInputSchema,
outputSchema: KitEmailsReadOutputSchema,
},
async (input) => {
try {
const parsed = KitEmailsReadInputSchema.parse(input);
const result = await service.read(parsed);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_emails_read', error);
}
},
);
server.registerTool(
'kit_emails_set_read_status',
{
description:
'Set read/unread status for a received email in the local Mailpit inbox',
inputSchema: KitEmailsSetReadStatusInputSchema,
outputSchema: KitEmailsSetReadStatusOutputSchema,
},
async (input) => {
try {
const parsed = KitEmailsSetReadStatusInputSchema.parse(input);
const result = await service.setReadStatus(parsed);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_emails_set_read_status', error);
}
},
);
}
export function createKitMailboxDeps(rootPath = process.cwd()): KitMailboxDeps {
return {
async executeCommand(command: string, args: string[]) {
const result = await execFileAsync(command, args, { cwd: rootPath });
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
};
},
async isPortOpen(port: number) {
return checkPort(port);
},
async fetchJson(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Mailpit API request failed with status ${response.status}`,
);
}
return response.json();
},
async requestJson(url: string, init) {
const response = await fetch(url, {
method: init?.method ?? 'GET',
headers: init?.headers,
body: init?.body,
});
if (!response.ok) {
throw new Error(
`Mailpit API request failed with status ${response.status}`,
);
}
return response.json();
},
};
}
async function checkPort(port: number) {
return new Promise<boolean>((resolve) => {
const socket = new Socket();
socket.setTimeout(200);
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('timeout', () => {
socket.destroy();
resolve(false);
});
socket.once('error', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, '127.0.0.1');
});
}
function buildErrorResponse(tool: string, error: unknown) {
const message = `${tool} failed: ${toErrorMessage(error)}`;
return {
isError: true,
content: buildTextContent(message),
};
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
function buildTextContent(text: string): TextContent[] {
return [{ type: 'text', text }];
}
export { createKitMailboxService } from './kit-mailbox.service';
export type { KitMailboxDeps } from './kit-mailbox.service';
export type {
KitEmailsListOutput,
KitEmailsReadOutput,
KitEmailsSetReadStatusOutput,
} from './schema';

View File

@@ -0,0 +1,261 @@
import type {
KitEmailsListInput,
KitEmailsListOutput,
KitEmailsReadInput,
KitEmailsReadOutput,
KitEmailsSetReadStatusInput,
KitEmailsSetReadStatusOutput,
} from './schema';
interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
export interface KitMailboxDeps {
executeCommand(command: string, args: string[]): Promise<CommandResult>;
isPortOpen(port: number): Promise<boolean>;
fetchJson(url: string): Promise<unknown>;
requestJson(
url: string,
init?: {
method?: string;
body?: string;
headers?: Record<string, string>;
},
): Promise<unknown>;
}
interface MailServerStatus {
running: boolean;
running_via_docker: boolean;
api_base_url: string;
}
const MAILPIT_HTTP_PORT = 54324;
const MAILPIT_API_BASE_URL = 'http://127.0.0.1:54324/api/v1';
export function createKitMailboxService(deps: KitMailboxDeps) {
return new KitMailboxService(deps);
}
export class KitMailboxService {
constructor(private readonly deps: KitMailboxDeps) {}
async list(input: KitEmailsListInput): Promise<KitEmailsListOutput> {
const mailServer = await this.ensureMailServerReady();
const payload = asRecord(
await this.deps.fetchJson(
`${MAILPIT_API_BASE_URL}/messages?start=${input.start}&limit=${input.limit}`,
),
);
const messages = asArray(payload.messages ?? payload.Messages).map(
(message) => this.toSummary(asRecord(message)),
);
return {
mail_server: mailServer,
start: toNumber(payload.start ?? payload.Start) ?? input.start,
limit: toNumber(payload.limit ?? payload.Limit) ?? input.limit,
count: toNumber(payload.count ?? payload.Count) ?? messages.length,
total: toNumber(payload.total ?? payload.Total) ?? messages.length,
unread: toNumber(payload.unread ?? payload.Unread),
messages,
};
}
async read(input: KitEmailsReadInput): Promise<KitEmailsReadOutput> {
const mailServer = await this.ensureMailServerReady();
const message = asRecord(
await this.deps.fetchJson(
`${MAILPIT_API_BASE_URL}/message/${encodeURIComponent(input.id)}`,
),
);
return {
mail_server: mailServer,
id: toString(message.ID ?? message.id) ?? input.id,
message_id: toString(message.MessageID ?? message.messageId),
subject: toString(message.Subject ?? message.subject),
from: readAddressList(message.From ?? message.from),
to: readAddressList(message.To ?? message.to),
cc: readAddressList(message.Cc ?? message.cc),
bcc: readAddressList(message.Bcc ?? message.bcc),
created_at: toString(message.Created ?? message.created),
size: toNumber(message.Size ?? message.size),
read: toBoolean(message.Read ?? message.read) ?? false,
readAt: toString(message.ReadAt ?? message.readAt),
text: readBody(message.Text ?? message.text),
html: readBody(message.HTML ?? message.Html ?? message.html),
headers: readHeaders(message.Headers ?? message.headers),
raw: message,
};
}
async setReadStatus(
input: KitEmailsSetReadStatusInput,
): Promise<KitEmailsSetReadStatusOutput> {
await this.ensureMailServerReady();
const response = asRecord(
await this.deps.requestJson(
`${MAILPIT_API_BASE_URL}/message/${encodeURIComponent(input.id)}/read`,
{
method: 'PUT',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ read: input.read }),
},
),
);
const read = toBoolean(response.Read ?? response.read) ?? input.read;
const readAt = toString(response.ReadAt ?? response.readAt);
return {
id: toString(response.ID ?? response.id) ?? input.id,
read,
...(readAt ? { readAt } : {}),
};
}
private toSummary(message: Record<string, unknown>) {
const readAt = toString(message.ReadAt ?? message.readAt);
return {
id: toString(message.ID ?? message.id) ?? '',
message_id: toString(message.MessageID ?? message.messageId),
subject: toString(message.Subject ?? message.subject),
from: readAddressList(message.From ?? message.from),
to: readAddressList(message.To ?? message.to),
created_at: toString(message.Created ?? message.created),
size: toNumber(message.Size ?? message.size),
read: toBoolean(message.Read ?? message.read) ?? false,
...(readAt ? { readAt } : {}),
};
}
private async ensureMailServerReady(): Promise<MailServerStatus> {
const running = await this.deps.isPortOpen(MAILPIT_HTTP_PORT);
const runningViaDocker = await this.isMailpitRunningViaDocker();
if (!running) {
if (runningViaDocker) {
throw new Error(
'Mailpit appears running in docker but API is unreachable at http://127.0.0.1:8025',
);
}
throw new Error(
'Mailpit is not running. Start local services with "pnpm compose:dev:up".',
);
}
await this.deps.fetchJson(`${MAILPIT_API_BASE_URL}/info`);
return {
running,
running_via_docker: runningViaDocker,
api_base_url: MAILPIT_API_BASE_URL,
};
}
private async isMailpitRunningViaDocker() {
try {
const result = await this.deps.executeCommand('docker', [
'compose',
'-f',
'docker-compose.dev.yml',
'ps',
'--status',
'running',
'--services',
]);
const services = result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return services.includes('mailpit');
} catch {
return false;
}
}
}
function readHeaders(input: unknown) {
const headers = asRecord(input);
const normalized: Record<string, string[]> = {};
for (const [key, value] of Object.entries(headers)) {
normalized[key] = asArray(value)
.map((item) => toString(item))
.filter((item): item is string => item !== null);
}
return normalized;
}
function readBody(input: unknown): string | null {
if (typeof input === 'string') {
return input;
}
if (Array.isArray(input)) {
const chunks = input
.map((item) => toString(item))
.filter((item): item is string => item !== null);
return chunks.length > 0 ? chunks.join('\n\n') : null;
}
return null;
}
function readAddressList(input: unknown): string[] {
return asArray(input)
.map((entry) => {
const item = asRecord(entry);
const address =
toString(item.Address ?? item.address ?? item.Email ?? item.email) ??
'';
const name = toString(item.Name ?? item.name);
if (!address) {
return null;
}
return name ? `${name} <${address}>` : address;
})
.filter((value): value is string => value !== null);
}
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function toString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function toBoolean(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null;
}
function toNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

View File

@@ -0,0 +1,79 @@
import { z } from 'zod/v3';
export const KitEmailsListInputSchema = z.object({
start: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(200).default(50),
});
const MailServerStatusSchema = z.object({
running: z.boolean(),
running_via_docker: z.boolean(),
api_base_url: z.string(),
});
const EmailSummarySchema = z.object({
id: z.string(),
message_id: z.string().nullable(),
subject: z.string().nullable(),
from: z.array(z.string()),
to: z.array(z.string()),
created_at: z.string().nullable(),
size: z.number().nullable(),
read: z.boolean(),
readAt: z.string().optional(),
});
export const KitEmailsListOutputSchema = z.object({
mail_server: MailServerStatusSchema,
start: z.number(),
limit: z.number(),
count: z.number(),
total: z.number(),
unread: z.number().nullable(),
messages: z.array(EmailSummarySchema),
});
export const KitEmailsReadInputSchema = z.object({
id: z.string().min(1),
});
export const KitEmailsReadOutputSchema = z.object({
mail_server: MailServerStatusSchema,
id: z.string(),
message_id: z.string().nullable(),
subject: z.string().nullable(),
from: z.array(z.string()),
to: z.array(z.string()),
cc: z.array(z.string()),
bcc: z.array(z.string()),
created_at: z.string().nullable(),
size: z.number().nullable(),
read: z.boolean(),
readAt: z.string().optional(),
text: z.string().nullable(),
html: z.string().nullable(),
headers: z.record(z.array(z.string())),
raw: z.unknown(),
});
export const KitEmailsSetReadStatusInputSchema = z.object({
id: z.string().min(1),
read: z.boolean(),
});
export const KitEmailsSetReadStatusOutputSchema = z.object({
id: z.string(),
read: z.boolean(),
readAt: z.string().optional(),
});
export type KitEmailsListInput = z.infer<typeof KitEmailsListInputSchema>;
export type KitEmailsListOutput = z.infer<typeof KitEmailsListOutputSchema>;
export type KitEmailsReadInput = z.infer<typeof KitEmailsReadInputSchema>;
export type KitEmailsReadOutput = z.infer<typeof KitEmailsReadOutputSchema>;
export type KitEmailsSetReadStatusInput = z.infer<
typeof KitEmailsSetReadStatusInputSchema
>;
export type KitEmailsSetReadStatusOutput = z.infer<
typeof KitEmailsSetReadStatusOutputSchema
>;

View File

@@ -2,7 +2,7 @@ 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';
import { z } from 'zod/v3';
export class MigrationsTool {
static GetMigrations() {
@@ -35,9 +35,12 @@ export function registerGetMigrationsTools(server: McpServer) {
}
function createDiffMigrationTool(server: McpServer) {
return server.tool(
return server.registerTool(
'diff_migrations',
'Compare differences between the declarative schemas and the applied migrations in Supabase',
{
description:
'Compare differences between the declarative schemas and the applied migrations in Supabase',
},
async () => {
const result = MigrationsTool.Diff();
const text = result.toString('utf8');
@@ -55,13 +58,15 @@ function createDiffMigrationTool(server: McpServer) {
}
function createCreateMigrationTool(server: McpServer) {
return server.tool(
return server.registerTool(
'create_migration',
'Create a new Supabase Postgres migration file',
{
state: z.object({
name: z.string(),
}),
description: 'Create a new Supabase Postgres migration file',
inputSchema: {
state: z.object({
name: z.string(),
}),
},
},
async ({ state }) => {
const result = MigrationsTool.CreateMigration(state.name);
@@ -80,13 +85,16 @@ function createCreateMigrationTool(server: McpServer) {
}
function createGetMigrationContentTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_migration_content',
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
{
state: z.object({
path: z.string(),
}),
description:
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
inputSchema: {
state: z.object({
path: z.string(),
}),
},
},
async ({ state }) => {
const content = await MigrationsTool.getMigrationContent(state.path);
@@ -104,9 +112,12 @@ function createGetMigrationContentTool(server: McpServer) {
}
function createGetMigrationsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_migrations',
'📜 Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
{
description:
'📜 Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
},
async () => {
const migrations = await MigrationsTool.GetMigrations();

View File

@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import { z } from 'zod/v3';
// Custom phase for organizing user stories
interface CustomPhase {
@@ -34,6 +34,56 @@ interface UserStory {
completedAt?: string;
}
interface RiskItem {
id: string;
description: string;
mitigation: string;
owner: string;
severity: 'low' | 'medium' | 'high';
}
interface CrossDependency {
id: string;
name: string;
description: string;
blocking: boolean;
owner?: string;
}
interface DecisionLogEntry {
id: string;
date: string;
decision: string;
rationale: string;
owner?: string;
status: 'proposed' | 'accepted' | 'superseded';
}
interface AgentTaskPacket {
id: string;
title: string;
scope: string;
doneCriteria: string[];
testPlan: string[];
likelyFiles: string[];
linkedStoryIds: string[];
dependencies: string[];
}
interface StoryTraceabilityMap {
storyId: string;
featureId: string;
acceptanceCriteriaIds: string[];
successMetricIds: string[];
}
interface CreateStructuredPRDOptions {
nonGoals?: string[];
outOfScope?: string[];
assumptions?: string[];
openQuestions?: string[];
}
// Structured PRD following ChatPRD format
interface StructuredPRD {
introduction: {
@@ -54,8 +104,16 @@ interface StructuredPRD {
successMetrics: string[];
};
nonGoals: string[];
outOfScope: string[];
assumptions: string[];
openQuestions: string[];
risks: RiskItem[];
dependencies: CrossDependency[];
userStories: UserStory[];
customPhases?: CustomPhase[];
storyTraceability: StoryTraceabilityMap[];
technicalRequirements: {
constraints: string[];
@@ -63,6 +121,13 @@ interface StructuredPRD {
complianceRequirements: string[];
};
technicalContracts: {
apis: string[];
dataModels: string[];
permissions: string[];
integrationBoundaries: string[];
};
acceptanceCriteria: {
global: string[];
qualityStandards: string[];
@@ -75,10 +140,30 @@ interface StructuredPRD {
nonNegotiables: string[];
};
rolloutPlan: {
featureFlags: string[];
migrationPlan: string[];
rolloutPhases: string[];
rollbackConditions: string[];
};
measurementPlan: {
events: string[];
dashboards: string[];
baselineMetrics: string[];
targetMetrics: string[];
guardrailMetrics: string[];
};
decisionLog: DecisionLogEntry[];
agentTaskPackets: AgentTaskPacket[];
changeLog: string[];
metadata: {
version: string;
created: string;
lastUpdated: string;
lastValidatedAt: string;
approver: string;
};
@@ -118,6 +203,7 @@ export class PRDManager {
solutionDescription: string,
keyFeatures: string[],
successMetrics: string[],
options?: CreateStructuredPRDOptions,
): Promise<string> {
await this.ensurePRDsDirectory();
@@ -140,12 +226,25 @@ export class PRDManager {
keyFeatures,
successMetrics,
},
nonGoals: options?.nonGoals ?? [],
outOfScope: options?.outOfScope ?? [],
assumptions: options?.assumptions ?? [],
openQuestions: options?.openQuestions ?? [],
risks: [],
dependencies: [],
userStories: [],
storyTraceability: [],
technicalRequirements: {
constraints: [],
integrationNeeds: [],
complianceRequirements: [],
},
technicalContracts: {
apis: [],
dataModels: [],
permissions: [],
integrationBoundaries: [],
},
acceptanceCriteria: {
global: [],
qualityStandards: [],
@@ -155,10 +254,27 @@ export class PRDManager {
resources: [],
nonNegotiables: [],
},
rolloutPlan: {
featureFlags: [],
migrationPlan: [],
rolloutPhases: [],
rollbackConditions: [],
},
measurementPlan: {
events: [],
dashboards: [],
baselineMetrics: [],
targetMetrics: [],
guardrailMetrics: [],
},
decisionLog: [],
agentTaskPackets: [],
changeLog: ['Initial PRD created'],
metadata: {
version: '1.0',
version: '2.0',
created: now,
lastUpdated: now,
lastValidatedAt: now,
approver: '',
},
progress: {
@@ -294,6 +410,28 @@ export class PRDManager {
suggestions.push('Add global acceptance criteria for quality standards');
}
if (prd.nonGoals.length === 0 || prd.outOfScope.length === 0) {
suggestions.push(
'Define both non-goals and out-of-scope items to reduce implementation drift',
);
}
if (prd.openQuestions.length > 0) {
suggestions.push(
`${prd.openQuestions.length} open questions remain unresolved`,
);
}
if (prd.measurementPlan.targetMetrics.length === 0) {
suggestions.push(
'Define target metrics in measurementPlan to validate delivery impact',
);
}
if (prd.rolloutPlan.rolloutPhases.length === 0) {
suggestions.push('Add rollout phases and rollback conditions');
}
const vagueStories = prd.userStories.filter(
(s) => s.acceptanceCriteria.length < 2,
);
@@ -336,11 +474,24 @@ export class PRDManager {
}
}
static async deletePRD(filename: string): Promise<string> {
const filePath = join(this.PRDS_DIR, filename);
try {
await unlink(filePath);
return `PRD deleted successfully: ${filename}`;
} catch {
throw new Error(`PRD file "${filename}" not found`);
}
}
static async getProjectStatus(filename: string): Promise<{
progress: number;
summary: string;
nextSteps: string[];
blockers: UserStory[];
openQuestions: string[];
highRisks: RiskItem[];
}> {
const prd = await this.loadPRD(filename);
@@ -357,13 +508,16 @@ export class PRDManager {
...nextPending.map((s) => `Start: ${s.title}`),
];
const summary = `${prd.progress.completed}/${prd.progress.total} stories completed (${prd.progress.overall}%). Total stories: ${prd.userStories.length}`;
const highRisks = prd.risks.filter((risk) => risk.severity === 'high');
const summary = `${prd.progress.completed}/${prd.progress.total} stories completed (${prd.progress.overall}%). Total stories: ${prd.userStories.length}. Open questions: ${prd.openQuestions.length}. High risks: ${highRisks.length}.`;
return {
progress: prd.progress.overall,
summary,
nextSteps,
blockers,
openQuestions: prd.openQuestions,
highRisks,
};
}
@@ -526,7 +680,7 @@ export class PRDManager {
const filePath = join(this.PRDS_DIR, filename);
try {
const content = await readFile(filePath, 'utf8');
return JSON.parse(content);
return this.normalizePRD(JSON.parse(content));
} catch {
throw new Error(`PRD file "${filename}" not found`);
}
@@ -536,11 +690,101 @@ export class PRDManager {
filename: string,
prd: StructuredPRD,
): Promise<void> {
prd.metadata.lastUpdated = new Date().toISOString().split('T')[0];
const now = new Date().toISOString().split('T')[0];
prd.metadata.lastUpdated = now;
prd.metadata.lastValidatedAt = prd.metadata.lastValidatedAt || now;
if (prd.changeLog.length === 0) {
prd.changeLog.push(`Updated on ${now}`);
}
const filePath = join(this.PRDS_DIR, filename);
await writeFile(filePath, JSON.stringify(prd, null, 2), 'utf8');
}
private static normalizePRD(input: unknown): StructuredPRD {
const prd = input as Partial<StructuredPRD>;
const today = new Date().toISOString().split('T')[0];
return {
introduction: {
title: prd.introduction?.title ?? 'Untitled PRD',
overview: prd.introduction?.overview ?? '',
lastUpdated: prd.introduction?.lastUpdated ?? today,
},
problemStatement: {
problem: prd.problemStatement?.problem ?? '',
marketOpportunity: prd.problemStatement?.marketOpportunity ?? '',
targetUsers: prd.problemStatement?.targetUsers ?? [],
},
solutionOverview: {
description: prd.solutionOverview?.description ?? '',
keyFeatures: prd.solutionOverview?.keyFeatures ?? [],
successMetrics: prd.solutionOverview?.successMetrics ?? [],
},
nonGoals: prd.nonGoals ?? [],
outOfScope: prd.outOfScope ?? [],
assumptions: prd.assumptions ?? [],
openQuestions: prd.openQuestions ?? [],
risks: prd.risks ?? [],
dependencies: prd.dependencies ?? [],
userStories: prd.userStories ?? [],
customPhases: prd.customPhases ?? [],
storyTraceability: prd.storyTraceability ?? [],
technicalRequirements: {
constraints: prd.technicalRequirements?.constraints ?? [],
integrationNeeds: prd.technicalRequirements?.integrationNeeds ?? [],
complianceRequirements:
prd.technicalRequirements?.complianceRequirements ?? [],
},
technicalContracts: {
apis: prd.technicalContracts?.apis ?? [],
dataModels: prd.technicalContracts?.dataModels ?? [],
permissions: prd.technicalContracts?.permissions ?? [],
integrationBoundaries:
prd.technicalContracts?.integrationBoundaries ?? [],
},
acceptanceCriteria: {
global: prd.acceptanceCriteria?.global ?? [],
qualityStandards: prd.acceptanceCriteria?.qualityStandards ?? [],
},
constraints: {
timeline: prd.constraints?.timeline ?? '',
budget: prd.constraints?.budget,
resources: prd.constraints?.resources ?? [],
nonNegotiables: prd.constraints?.nonNegotiables ?? [],
},
rolloutPlan: {
featureFlags: prd.rolloutPlan?.featureFlags ?? [],
migrationPlan: prd.rolloutPlan?.migrationPlan ?? [],
rolloutPhases: prd.rolloutPlan?.rolloutPhases ?? [],
rollbackConditions: prd.rolloutPlan?.rollbackConditions ?? [],
},
measurementPlan: {
events: prd.measurementPlan?.events ?? [],
dashboards: prd.measurementPlan?.dashboards ?? [],
baselineMetrics: prd.measurementPlan?.baselineMetrics ?? [],
targetMetrics: prd.measurementPlan?.targetMetrics ?? [],
guardrailMetrics: prd.measurementPlan?.guardrailMetrics ?? [],
},
decisionLog: prd.decisionLog ?? [],
agentTaskPackets: prd.agentTaskPackets ?? [],
changeLog: prd.changeLog ?? [],
metadata: {
version: prd.metadata?.version ?? '2.0',
created: prd.metadata?.created ?? today,
lastUpdated: prd.metadata?.lastUpdated ?? today,
lastValidatedAt: prd.metadata?.lastValidatedAt ?? today,
approver: prd.metadata?.approver ?? '',
},
progress: {
overall: prd.progress?.overall ?? 0,
completed: prd.progress?.completed ?? 0,
total: prd.progress?.total ?? 0,
blocked: prd.progress?.blocked ?? 0,
},
};
}
private static extractTitleFromAction(action: string): string {
const cleaned = action.trim().toLowerCase();
const words = cleaned.split(' ').slice(0, 4);
@@ -604,6 +848,58 @@ export class PRDManager {
content += `- ${metric}\n`;
});
content += `\n## Scope Guardrails\n\n`;
content += `### Non-Goals\n`;
if (prd.nonGoals.length > 0) {
prd.nonGoals.forEach((item) => {
content += `- ${item}\n`;
});
} else {
content += `- None specified\n`;
}
content += `\n### Out of Scope\n`;
if (prd.outOfScope.length > 0) {
prd.outOfScope.forEach((item) => {
content += `- ${item}\n`;
});
} else {
content += `- None specified\n`;
}
content += `\n### Assumptions\n`;
if (prd.assumptions.length > 0) {
prd.assumptions.forEach((item) => {
content += `- ${item}\n`;
});
} else {
content += `- None specified\n`;
}
content += `\n### Open Questions\n`;
if (prd.openQuestions.length > 0) {
prd.openQuestions.forEach((item) => {
content += `- ${item}\n`;
});
} else {
content += `- None\n`;
}
if (prd.risks.length > 0) {
content += `\n## Risks\n`;
prd.risks.forEach((risk) => {
content += `- [${risk.severity}] ${risk.description} | Mitigation: ${risk.mitigation} | Owner: ${risk.owner}\n`;
});
}
if (prd.dependencies.length > 0) {
content += `\n## Dependencies\n`;
prd.dependencies.forEach((dependency) => {
const mode = dependency.blocking ? 'blocking' : 'non-blocking';
content += `- ${dependency.name} (${mode}) - ${dependency.description}\n`;
});
}
content += `\n## User Stories\n\n`;
const priorities: UserStory['priority'][] = ['P0', 'P1', 'P2', 'P3'];
@@ -637,6 +933,20 @@ export class PRDManager {
content += `**Blocked:** ${prd.progress.blocked} stories need attention\n`;
}
if (prd.rolloutPlan.rolloutPhases.length > 0) {
content += `\n## Rollout Plan\n`;
prd.rolloutPlan.rolloutPhases.forEach((phase) => {
content += `- ${phase}\n`;
});
}
if (prd.measurementPlan.targetMetrics.length > 0) {
content += `\n## Measurement Plan\n`;
prd.measurementPlan.targetMetrics.forEach((metric) => {
content += `- ${metric}\n`;
});
}
content += `\n---\n\n`;
content += `*Approver: ${prd.metadata.approver || 'TBD'}*\n`;
@@ -661,6 +971,7 @@ export function registerPRDTools(server: McpServer) {
createListPRDsTool(server);
createGetPRDTool(server);
createCreatePRDTool(server);
createDeletePRDTool(server);
createAddUserStoryTool(server);
createUpdateStoryStatusTool(server);
createExportMarkdownTool(server);
@@ -670,9 +981,11 @@ export function registerPRDTools(server: McpServer) {
}
function createListPRDsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'list_prds',
'List all Product Requirements Documents',
{
description: 'List all Product Requirements Documents',
},
async () => {
const prds = await PRDManager.listPRDs();
@@ -702,13 +1015,15 @@ function createListPRDsTool(server: McpServer) {
}
function createGetPRDTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_prd',
'Get the contents of a specific PRD file',
{
state: z.object({
filename: z.string(),
}),
description: 'Get the contents of a specific PRD file',
inputSchema: {
state: z.object({
filename: z.string(),
}),
},
},
async ({ state }) => {
const content = await PRDManager.getPRDContent(state.filename);
@@ -726,20 +1041,27 @@ function createGetPRDTool(server: McpServer) {
}
function createCreatePRDTool(server: McpServer) {
return server.tool(
return server.registerTool(
'create_prd',
'Create a new structured PRD following ChatPRD best practices',
{
state: z.object({
title: z.string(),
overview: z.string(),
problemStatement: z.string(),
marketOpportunity: z.string(),
targetUsers: z.array(z.string()),
solutionDescription: z.string(),
keyFeatures: z.array(z.string()),
successMetrics: z.array(z.string()),
}),
description:
'Create a new structured PRD following ChatPRD best practices',
inputSchema: {
state: z.object({
title: z.string(),
overview: z.string(),
problemStatement: z.string(),
marketOpportunity: z.string(),
targetUsers: z.array(z.string()),
solutionDescription: z.string(),
keyFeatures: z.array(z.string()),
successMetrics: z.array(z.string()),
nonGoals: z.array(z.string()).optional(),
outOfScope: z.array(z.string()).optional(),
assumptions: z.array(z.string()).optional(),
openQuestions: z.array(z.string()).optional(),
}),
},
},
async ({ state }) => {
const filename = await PRDManager.createStructuredPRD(
@@ -751,6 +1073,12 @@ function createCreatePRDTool(server: McpServer) {
state.solutionDescription,
state.keyFeatures,
state.successMetrics,
{
nonGoals: state.nonGoals,
outOfScope: state.outOfScope,
assumptions: state.assumptions,
openQuestions: state.openQuestions,
},
);
return {
@@ -765,19 +1093,47 @@ function createCreatePRDTool(server: McpServer) {
);
}
function createAddUserStoryTool(server: McpServer) {
return server.tool(
'add_user_story',
'Add a new user story to an existing PRD',
function createDeletePRDTool(server: McpServer) {
return server.registerTool(
'delete_prd',
{
state: z.object({
filename: z.string(),
userType: z.string(),
action: z.string(),
benefit: z.string(),
acceptanceCriteria: z.array(z.string()),
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P2'),
}),
description: 'Delete an existing PRD file',
inputSchema: {
state: z.object({
filename: z.string(),
}),
},
},
async ({ state }) => {
const result = await PRDManager.deletePRD(state.filename);
return {
content: [
{
type: 'text',
text: result,
},
],
};
},
);
}
function createAddUserStoryTool(server: McpServer) {
return server.registerTool(
'add_user_story',
{
description: 'Add a new user story to an existing PRD',
inputSchema: {
state: z.object({
filename: z.string(),
userType: z.string(),
action: z.string(),
benefit: z.string(),
acceptanceCriteria: z.array(z.string()),
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P2'),
}),
},
},
async ({ state }) => {
const result = await PRDManager.addUserStory(
@@ -802,23 +1158,25 @@ function createAddUserStoryTool(server: McpServer) {
}
function createUpdateStoryStatusTool(server: McpServer) {
return server.tool(
return server.registerTool(
'update_story_status',
'Update the status of a specific user story',
{
state: z.object({
filename: z.string(),
storyId: z.string(),
status: z.enum([
'not_started',
'research',
'in_progress',
'review',
'completed',
'blocked',
]),
notes: z.string().optional(),
}),
description: 'Update the status of a specific user story',
inputSchema: {
state: z.object({
filename: z.string(),
storyId: z.string(),
status: z.enum([
'not_started',
'research',
'in_progress',
'review',
'completed',
'blocked',
]),
notes: z.string().optional(),
}),
},
},
async ({ state }) => {
const result = await PRDManager.updateStoryStatus(
@@ -841,13 +1199,15 @@ function createUpdateStoryStatusTool(server: McpServer) {
}
function createExportMarkdownTool(server: McpServer) {
return server.tool(
return server.registerTool(
'export_prd_markdown',
'Export PRD as markdown for visualization and sharing',
{
state: z.object({
filename: z.string(),
}),
description: 'Export PRD as markdown for visualization and sharing',
inputSchema: {
state: z.object({
filename: z.string(),
}),
},
},
async ({ state }) => {
const markdownFile = await PRDManager.exportAsMarkdown(state.filename);
@@ -865,13 +1225,15 @@ function createExportMarkdownTool(server: McpServer) {
}
function createGetImplementationPromptsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_implementation_prompts',
'Generate Claude Code implementation prompts from PRD',
{
state: z.object({
filename: z.string(),
}),
description: 'Generate Claude Code implementation prompts from PRD',
inputSchema: {
state: z.object({
filename: z.string(),
}),
},
},
async ({ state }) => {
const prompts = await PRDManager.generateImplementationPrompts(
@@ -904,13 +1266,15 @@ function createGetImplementationPromptsTool(server: McpServer) {
}
function createGetImprovementSuggestionsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_improvement_suggestions',
'Get AI-powered suggestions to improve the PRD',
{
state: z.object({
filename: z.string(),
}),
description: 'Get AI-powered suggestions to improve the PRD',
inputSchema: {
state: z.object({
filename: z.string(),
}),
},
},
async ({ state }) => {
const suggestions = await PRDManager.getImprovementSuggestions(
@@ -943,13 +1307,15 @@ function createGetImprovementSuggestionsTool(server: McpServer) {
}
function createGetProjectStatusTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_project_status',
'Get comprehensive status overview of the PRD project',
{
state: z.object({
filename: z.string(),
}),
description: 'Get comprehensive status overview of the PRD project',
inputSchema: {
state: z.object({
filename: z.string(),
}),
},
},
async ({ state }) => {
const status = await PRDManager.getProjectStatus(state.filename);
@@ -970,6 +1336,22 @@ function createGetProjectStatusTool(server: McpServer) {
status.blockers.forEach((blocker) => {
result += `- ${blocker.title}: ${blocker.notes || 'No details provided'}\n`;
});
result += '\n';
}
if (status.highRisks.length > 0) {
result += `**High Risks:**\n`;
status.highRisks.forEach((risk) => {
result += `- ${risk.description} (Owner: ${risk.owner || 'Unassigned'})\n`;
});
result += '\n';
}
if (status.openQuestions.length > 0) {
result += `**Open Questions:**\n`;
status.openQuestions.slice(0, 5).forEach((question) => {
result += `- ${question}\n`;
});
}
return {

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
type KitPrerequisitesDeps,
createKitPrerequisitesService,
} from '../kit-prerequisites.service';
function createDeps(overrides: Partial<KitPrerequisitesDeps> = {}) {
const base: KitPrerequisitesDeps = {
async getVariantFamily() {
return 'supabase';
},
async executeCommand(command: string, _args: string[]) {
if (command === 'node')
return { stdout: 'v22.5.0\n', stderr: '', exitCode: 0 };
if (command === 'pnpm')
return { stdout: '10.19.0\n', stderr: '', exitCode: 0 };
if (command === 'git')
return { stdout: 'git version 2.44.0\n', stderr: '', exitCode: 0 };
if (command === 'docker')
return {
stdout: 'Docker version 26.1.0, build abc\n',
stderr: '',
exitCode: 0,
};
if (command === 'supabase')
return { stdout: '2.75.5\n', stderr: '', exitCode: 0 };
if (command === 'stripe') throw new Error('not installed');
throw new Error(`unexpected command: ${command}`);
},
};
return {
...base,
...overrides,
};
}
describe('KitPrerequisitesService', () => {
it('returns pass/warn statuses in a healthy supabase setup', async () => {
const service = createKitPrerequisitesService(createDeps());
const result = await service.check({});
expect(result.ready_to_develop).toBe(true);
expect(result.overall).toBe('warn');
const node = result.prerequisites.find((item) => item.id === 'node');
const supabase = result.prerequisites.find(
(item) => item.id === 'supabase',
);
const stripe = result.prerequisites.find((item) => item.id === 'stripe');
expect(node?.status).toBe('pass');
expect(supabase?.status).toBe('pass');
expect(stripe?.status).toBe('warn');
});
it('fails when required supabase cli is missing for supabase family', async () => {
const service = createKitPrerequisitesService(
createDeps({
async executeCommand(command: string, args: string[]) {
if (command === 'supabase') {
throw new Error('missing');
}
return createDeps().executeCommand(command, args);
},
}),
);
const result = await service.check({});
const supabase = result.prerequisites.find(
(item) => item.id === 'supabase',
);
expect(supabase?.required).toBe(true);
expect(supabase?.status).toBe('fail');
expect(result.overall).toBe('fail');
expect(result.ready_to_develop).toBe(false);
});
it('treats supabase cli as optional for orm family', async () => {
const service = createKitPrerequisitesService(
createDeps({
async getVariantFamily() {
return 'orm';
},
async executeCommand(command: string, args: string[]) {
if (command === 'supabase') {
throw new Error('missing');
}
return createDeps().executeCommand(command, args);
},
}),
);
const result = await service.check({});
const supabase = result.prerequisites.find(
(item) => item.id === 'supabase',
);
expect(supabase?.required).toBe(false);
expect(supabase?.status).toBe('warn');
expect(result.overall).toBe('warn');
expect(result.ready_to_develop).toBe(true);
});
});

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { registerKitPrerequisitesTool } from '../index';
import { KitPrerequisitesOutputSchema } from '../schema';
interface RegisteredTool {
name: string;
handler: (input: unknown) => Promise<Record<string, unknown>>;
}
describe('registerKitPrerequisitesTool', () => {
it('registers kit_prerequisites and returns typed structured output', async () => {
const tools: RegisteredTool[] = [];
const server = {
registerTool(
name: string,
_config: Record<string, unknown>,
handler: (input: unknown) => Promise<Record<string, unknown>>,
) {
tools.push({ name, handler });
return {};
},
};
registerKitPrerequisitesTool(server as never);
expect(tools).toHaveLength(1);
expect(tools[0]?.name).toBe('kit_prerequisites');
const result = await tools[0]!.handler({});
const parsed = KitPrerequisitesOutputSchema.parse(result.structuredContent);
expect(parsed.prerequisites.length).toBeGreaterThan(0);
expect(typeof parsed.ready_to_develop).toBe('boolean');
});
});

View File

@@ -0,0 +1,191 @@
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 {
type KitPrerequisitesDeps,
createKitPrerequisitesService,
} from './kit-prerequisites.service';
import {
KitPrerequisitesInputSchema,
KitPrerequisitesOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
export function registerKitPrerequisitesTool(server: McpServer) {
return server.registerTool(
'kit_prerequisites',
{
description: 'Check installed tools and versions for this kit variant',
inputSchema: KitPrerequisitesInputSchema,
outputSchema: KitPrerequisitesOutputSchema,
},
async (input) => {
const parsedInput = KitPrerequisitesInputSchema.parse(input);
try {
const service = createKitPrerequisitesService(
createKitPrerequisitesDeps(),
);
const result = await service.check(parsedInput);
return {
structuredContent: result,
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `kit_prerequisites failed: ${toErrorMessage(error)}`,
},
],
};
}
},
);
}
function createKitPrerequisitesDeps(): KitPrerequisitesDeps {
const rootPath = process.cwd();
return {
async getVariantFamily() {
const variant = await resolveVariant(rootPath);
return variant.includes('supabase') ? 'supabase' : 'orm';
},
async executeCommand(command: string, args: string[]) {
const result = await executeWithFallback(rootPath, command, args);
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
};
},
};
}
async function executeWithFallback(
rootPath: string,
command: string,
args: string[],
) {
try {
return await execFileAsync(command, args, {
cwd: rootPath,
});
} catch (error) {
// Local CLI tools are often installed in node_modules/.bin in this monorepo.
if (isLocalCliCandidate(command)) {
const localBinCandidates = [
join(rootPath, 'node_modules', '.bin', command),
join(rootPath, 'apps', 'web', 'node_modules', '.bin', command),
];
for (const localBin of localBinCandidates) {
try {
return await execFileAsync(localBin, args, {
cwd: rootPath,
});
} catch {
// Try next local binary candidate.
}
}
try {
return await execFileAsync('pnpm', ['exec', command, ...args], {
cwd: rootPath,
});
} catch {
return execFileAsync(
'pnpm',
['--filter', 'web', 'exec', command, ...args],
{
cwd: rootPath,
},
);
}
}
if (command === 'pnpm') {
return execFileAsync(command, args, {
cwd: rootPath,
});
}
throw error;
}
}
function isLocalCliCandidate(command: string) {
return command === 'supabase' || command === 'stripe';
}
async function resolveVariant(rootPath: string) {
const configPath = join(rootPath, '.makerkit', 'config.json');
try {
await access(configPath);
const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<
string,
unknown
>;
const variant =
readString(config, 'variant') ??
readString(config, 'template') ??
readString(config, 'kitVariant');
if (variant) {
return variant;
}
} catch {
// Fall through to heuristic.
}
if (await pathExists(join(rootPath, 'apps', 'web', 'supabase'))) {
return 'next-supabase';
}
return 'next-drizzle';
}
function readString(obj: Record<string, unknown>, key: string) {
const value = obj[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
async function pathExists(path: string) {
try {
await access(path);
return true;
} catch {
return false;
}
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
export {
createKitPrerequisitesService,
type KitPrerequisitesDeps,
} from './kit-prerequisites.service';
export type { KitPrerequisitesOutput } from './schema';

View File

@@ -0,0 +1,405 @@
import type {
KitPrerequisiteItem,
KitPrerequisitesInput,
KitPrerequisitesOutput,
} from './schema';
type VariantFamily = 'supabase' | 'orm';
interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface ToolVersion {
installed: boolean;
version: string | null;
}
export interface KitPrerequisitesDeps {
getVariantFamily(): Promise<VariantFamily>;
executeCommand(command: string, args: string[]): Promise<CommandResult>;
}
export function createKitPrerequisitesService(deps: KitPrerequisitesDeps) {
return new KitPrerequisitesService(deps);
}
export class KitPrerequisitesService {
constructor(private readonly deps: KitPrerequisitesDeps) {}
async check(_input: KitPrerequisitesInput): Promise<KitPrerequisitesOutput> {
const family = await this.deps.getVariantFamily();
const [node, pnpm, git, docker, supabaseCli, stripeCli] = await Promise.all(
[
this.getNodeVersion(),
this.getPnpmVersion(),
this.getGitVersion(),
this.getDockerVersion(),
this.getSupabaseVersion(),
this.getStripeVersion(),
],
);
const prerequisites: KitPrerequisiteItem[] = [];
prerequisites.push(
this.createRequiredItem({
id: 'node',
name: 'Node.js',
minimumVersion: '20.10.0',
installUrl: 'https://nodejs.org',
version: node,
}),
);
prerequisites.push(
this.createRequiredItem({
id: 'pnpm',
name: 'pnpm',
minimumVersion: '10.0.0',
installCommand: 'npm install -g pnpm',
version: pnpm,
}),
);
prerequisites.push(
this.createRequiredItem({
id: 'git',
name: 'Git',
minimumVersion: '2.0.0',
installUrl: 'https://git-scm.com/downloads',
version: git,
}),
);
prerequisites.push(
this.createRequiredItem({
id: 'docker',
name: 'Docker',
minimumVersion: '20.10.0',
installUrl: 'https://docker.com/products/docker-desktop',
requiredFor:
family === 'supabase' ? 'Local Supabase stack' : 'Local PostgreSQL',
version: docker,
}),
);
prerequisites.push(
this.createVariantConditionalItem({
id: 'supabase',
name: 'Supabase CLI',
minimumVersion: '2.0.0',
installCommand: 'npm install -g supabase',
required: family === 'supabase',
requiredFor: 'Supabase variants',
version: supabaseCli,
}),
);
prerequisites.push(
this.createVariantConditionalItem({
id: 'stripe',
name: 'Stripe CLI',
minimumVersion: '1.0.0',
installUrl: 'https://docs.stripe.com/stripe-cli',
required: false,
requiredFor: 'Payment webhook testing',
version: stripeCli,
}),
);
const overall = this.computeOverall(prerequisites);
return {
prerequisites,
overall,
ready_to_develop: overall !== 'fail',
};
}
private computeOverall(items: KitPrerequisiteItem[]) {
if (items.some((item) => item.required && item.status === 'fail')) {
return 'fail' as const;
}
if (items.some((item) => item.status === 'warn')) {
return 'warn' as const;
}
return 'pass' as const;
}
private createRequiredItem(params: {
id: string;
name: string;
minimumVersion: string;
version: ToolVersion;
installUrl?: string;
installCommand?: string;
requiredFor?: string;
}): KitPrerequisiteItem {
const status = this.getVersionStatus(params.version, params.minimumVersion);
return {
id: params.id,
name: params.name,
required: true,
required_for: params.requiredFor,
installed: params.version.installed,
version: params.version.version,
minimum_version: params.minimumVersion,
status,
install_url: params.installUrl,
install_command: params.installCommand,
message: this.getMessage(params.version, params.minimumVersion, true),
remedies: this.getRemedies(params, status, true),
};
}
private createVariantConditionalItem(params: {
id: string;
name: string;
minimumVersion: string;
version: ToolVersion;
required: boolean;
requiredFor?: string;
installUrl?: string;
installCommand?: string;
}): KitPrerequisiteItem {
if (!params.required) {
if (!params.version.installed) {
return {
id: params.id,
name: params.name,
required: false,
required_for: params.requiredFor,
installed: false,
version: null,
minimum_version: params.minimumVersion,
status: 'warn',
install_url: params.installUrl,
install_command: params.installCommand,
message: `${params.name} is optional but recommended for ${params.requiredFor ?? 'developer workflows'}.`,
remedies: params.installCommand
? [params.installCommand]
: params.installUrl
? [params.installUrl]
: [],
};
}
const status = this.getVersionStatus(
params.version,
params.minimumVersion,
);
return {
id: params.id,
name: params.name,
required: false,
required_for: params.requiredFor,
installed: true,
version: params.version.version,
minimum_version: params.minimumVersion,
status: status === 'fail' ? 'warn' : status,
install_url: params.installUrl,
install_command: params.installCommand,
message: this.getMessage(params.version, params.minimumVersion, false),
remedies: this.getRemedies(
{
...params,
},
status === 'fail' ? 'warn' : status,
false,
),
};
}
const status = this.getVersionStatus(params.version, params.minimumVersion);
return {
id: params.id,
name: params.name,
required: true,
required_for: params.requiredFor,
installed: params.version.installed,
version: params.version.version,
minimum_version: params.minimumVersion,
status,
install_url: params.installUrl,
install_command: params.installCommand,
message: this.getMessage(params.version, params.minimumVersion, true),
remedies: this.getRemedies(params, status, true),
};
}
private getVersionStatus(version: ToolVersion, minimumVersion: string) {
if (!version.installed || !version.version) {
return 'fail' as const;
}
const cmp = compareVersions(version.version, minimumVersion);
if (cmp < 0) {
return 'fail' as const;
}
return 'pass' as const;
}
private getMessage(
version: ToolVersion,
minimumVersion: string,
required: boolean,
) {
if (!version.installed) {
return required
? 'Required tool is not installed.'
: 'Optional tool is not installed.';
}
if (!version.version) {
return 'Tool is installed but version could not be detected.';
}
const cmp = compareVersions(version.version, minimumVersion);
if (cmp < 0) {
return `Installed version ${version.version} is below minimum ${minimumVersion}.`;
}
return `Installed version ${version.version} satisfies minimum ${minimumVersion}.`;
}
private getRemedies(
params: {
installUrl?: string;
installCommand?: string;
minimumVersion: string;
},
status: 'pass' | 'warn' | 'fail',
required: boolean,
) {
if (status === 'pass') {
return [];
}
const remedies: string[] = [];
if (params.installCommand) {
remedies.push(params.installCommand);
}
if (params.installUrl) {
remedies.push(params.installUrl);
}
remedies.push(`Ensure version is >= ${params.minimumVersion}`);
if (!required && status === 'warn') {
remedies.push(
'Optional for development but useful for related workflows',
);
}
return remedies;
}
private async getNodeVersion() {
try {
const result = await this.deps.executeCommand('node', ['--version']);
return {
installed: true,
version: normalizeVersion(result.stdout),
};
} catch {
return { installed: false, version: null };
}
}
private async getPnpmVersion() {
try {
const result = await this.deps.executeCommand('pnpm', ['--version']);
return {
installed: true,
version: normalizeVersion(result.stdout),
};
} catch {
return { installed: false, version: null };
}
}
private async getGitVersion() {
try {
const result = await this.deps.executeCommand('git', ['--version']);
return {
installed: true,
version: normalizeVersion(result.stdout),
};
} catch {
return { installed: false, version: null };
}
}
private async getDockerVersion() {
try {
const result = await this.deps.executeCommand('docker', ['--version']);
return {
installed: true,
version: normalizeVersion(result.stdout),
};
} catch {
return { installed: false, version: null };
}
}
private async getSupabaseVersion() {
try {
const result = await this.deps.executeCommand('supabase', ['--version']);
return {
installed: true,
version: normalizeVersion(result.stdout),
};
} catch {
return { installed: false, version: null };
}
}
private async getStripeVersion() {
try {
const result = await this.deps.executeCommand('stripe', ['--version']);
return {
installed: true,
version: normalizeVersion(result.stdout),
};
} catch {
return { installed: false, version: null };
}
}
}
function normalizeVersion(input: string) {
const match = input.match(/\d+\.\d+\.\d+/);
return match ? match[0] : null;
}
function compareVersions(a: string, b: string) {
const left = a.split('.').map((part) => Number(part));
const right = b.split('.').map((part) => Number(part));
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const l = left[i] ?? 0;
const r = right[i] ?? 0;
if (l > r) return 1;
if (l < r) return -1;
}
return 0;
}

View File

@@ -0,0 +1,30 @@
import { z } from 'zod/v3';
export const KitPrerequisitesInputSchema = z.object({});
export const KitPrerequisiteItemSchema = z.object({
id: z.string(),
name: z.string(),
required: z.boolean(),
required_for: z.string().optional(),
installed: z.boolean(),
version: z.string().nullable(),
minimum_version: z.string().nullable(),
status: z.enum(['pass', 'warn', 'fail']),
install_url: z.string().optional(),
install_command: z.string().optional(),
message: z.string().optional(),
remedies: z.array(z.string()).default([]),
});
export const KitPrerequisitesOutputSchema = z.object({
prerequisites: z.array(KitPrerequisiteItemSchema),
overall: z.enum(['pass', 'warn', 'fail']),
ready_to_develop: z.boolean(),
});
export type KitPrerequisitesInput = z.infer<typeof KitPrerequisitesInputSchema>;
export type KitPrerequisiteItem = z.infer<typeof KitPrerequisiteItemSchema>;
export type KitPrerequisitesOutput = z.infer<
typeof KitPrerequisitesOutputSchema
>;

View File

@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { z } from 'zod/v3';
interface PromptTemplate {
name: string;
@@ -280,10 +280,12 @@ export function registerPromptsSystem(server: McpServer) {
{} as Record<string, z.ZodString | z.ZodOptional<z.ZodString>>,
);
server.prompt(
server.registerPrompt(
promptTemplate.name,
promptTemplate.description,
argsSchema,
{
description: promptTemplate.description,
argsSchema,
},
async (args: Record<string, string>) => {
const renderedPrompt = PromptsManager.renderPrompt(
promptTemplate.name,

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
type RunChecksDeps,
createRunChecksService,
} from '../run-checks.service';
function createDeps(
overrides: Partial<RunChecksDeps> = {},
scripts: Record<string, string> = {
typecheck: 'tsc --noEmit',
'lint:fix': 'eslint . --fix',
'format:fix': 'prettier . --write',
test: 'vitest run',
},
): RunChecksDeps {
let nowValue = 0;
return {
rootPath: '/repo',
async readRootPackageJson() {
return { scripts };
},
async executeCommand() {
nowValue += 100;
return {
stdout: 'ok',
stderr: '',
exitCode: 0,
};
},
now() {
return nowValue;
},
...overrides,
};
}
describe('RunChecksService', () => {
it('runs default scripts and reports pass', async () => {
const service = createRunChecksService(createDeps());
const result = await service.run({});
expect(result.overall).toBe('pass');
expect(result.scripts_requested).toEqual([
'typecheck',
'lint:fix',
'format:fix',
]);
expect(result.summary.passed).toBe(3);
});
it('includes tests when includeTests is true', async () => {
const service = createRunChecksService(createDeps());
const result = await service.run({ state: { includeTests: true } });
expect(result.scripts_requested).toContain('test');
expect(result.summary.total).toBe(4);
});
it('marks missing scripts as missing and fails overall', async () => {
const service = createRunChecksService(
createDeps(
{},
{
typecheck: 'tsc --noEmit',
},
),
);
const result = await service.run({
state: { scripts: ['typecheck', 'lint:fix'] },
});
expect(result.overall).toBe('fail');
expect(result.summary.missing).toBe(1);
expect(
result.checks.find((item) => item.script === 'lint:fix')?.status,
).toBe('missing');
});
it('stops subsequent checks when failFast is enabled', async () => {
let calls = 0;
const service = createRunChecksService(
createDeps({
async executeCommand(_command, args) {
calls += 1;
if (args[1] === 'typecheck') {
return { stdout: '', stderr: 'boom', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
);
const result = await service.run({
state: {
scripts: ['typecheck', 'lint:fix', 'format:fix'],
failFast: true,
},
});
expect(calls).toBe(1);
expect(result.checks[0]?.status).toBe('fail');
expect(result.checks[1]?.status).toBe('skipped');
expect(result.checks[2]?.status).toBe('skipped');
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { registerRunChecksTool } from '../index';
import { RunChecksOutputSchema } from '../schema';
interface RegisteredTool {
name: string;
handler: (input: unknown) => Promise<Record<string, unknown>>;
}
describe('registerRunChecksTool', () => {
it('registers run_checks and returns typed structured output', async () => {
const tools: RegisteredTool[] = [];
const server = {
registerTool(
name: string,
_config: Record<string, unknown>,
handler: (input: unknown) => Promise<Record<string, unknown>>,
) {
tools.push({ name, handler });
return {};
},
};
registerRunChecksTool(server as never);
expect(tools).toHaveLength(1);
expect(tools[0]?.name).toBe('run_checks');
const result = await tools[0]!.handler({});
const parsed = RunChecksOutputSchema.parse(result.structuredContent);
expect(parsed.summary.total).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,115 @@
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 {
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());
return server.registerTool(
'run_checks',
{
description:
'Run code quality checks (typecheck, lint, format, and optional tests) with structured output',
inputSchema: RunChecksInputSchema,
outputSchema: RunChecksOutputSchema,
},
async (input) => {
try {
const parsed = RunChecksInputSchema.parse(input);
const result = await service.run(parsed);
return {
structuredContent: result,
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `run_checks failed: ${toErrorMessage(error)}`,
},
],
};
}
},
);
}
export function createRunChecksDeps(rootPath = process.cwd()): RunChecksDeps {
return {
rootPath,
async readRootPackageJson() {
const path = join(rootPath, 'package.json');
const content = await readFile(path, 'utf8');
return JSON.parse(content) as { scripts?: Record<string, string> };
},
async executeCommand(command, args) {
try {
const result = await execFileAsync(command, args, {
cwd: rootPath,
maxBuffer: 1024 * 1024 * 10,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
};
} catch (error) {
if (isExecError(error)) {
return {
stdout: error.stdout ?? '',
stderr: error.stderr ?? '',
exitCode: error.code,
};
}
throw error;
}
},
now() {
return Date.now();
},
};
}
interface ExecError extends Error {
code: number;
stdout?: string;
stderr?: string;
}
function isExecError(error: unknown): error is ExecError {
return error instanceof Error && 'code' in error;
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
export {
createRunChecksService,
type RunChecksDeps,
} from './run-checks.service';
export type { RunChecksOutput } from './schema';

View File

@@ -0,0 +1,147 @@
import type {
RunChecksInput,
RunChecksOutput,
RunChecksResult,
} from './schema';
interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
export interface RunChecksDeps {
rootPath: string;
readRootPackageJson(): Promise<{ scripts?: Record<string, string> }>;
executeCommand(command: string, args: string[]): Promise<CommandResult>;
now(): number;
}
const DEFAULT_SCRIPTS = ['typecheck', 'lint:fix', 'format:fix'] as const;
export function createRunChecksService(deps: RunChecksDeps) {
return new RunChecksService(deps);
}
export class RunChecksService {
constructor(private readonly deps: RunChecksDeps) {}
async run(input: RunChecksInput): Promise<RunChecksOutput> {
const options = input.state ?? {};
const includeTests = options.includeTests ?? false;
const failFast = options.failFast ?? false;
const maxOutputChars = options.maxOutputChars ?? 4000;
const scriptsRequested = this.resolveScripts(options.scripts, includeTests);
const checks: RunChecksResult[] = [];
const packageJson = await this.deps.readRootPackageJson();
const availableScripts = new Set(Object.keys(packageJson.scripts ?? {}));
let stopRunning = false;
for (const script of scriptsRequested) {
if (stopRunning) {
checks.push({
script,
command: `pnpm run ${script}`,
status: 'skipped',
exit_code: null,
duration_ms: 0,
stdout: '',
stderr: '',
message:
'Skipped because failFast is enabled and a previous check failed.',
});
continue;
}
if (!availableScripts.has(script)) {
checks.push({
script,
command: `pnpm run ${script}`,
status: 'missing',
exit_code: null,
duration_ms: 0,
stdout: '',
stderr: '',
message: `Script "${script}" was not found in root package.json.`,
});
if (failFast) {
stopRunning = true;
}
continue;
}
const startedAt = this.deps.now();
const result = await this.deps.executeCommand('pnpm', ['run', script]);
const duration = Math.max(0, this.deps.now() - startedAt);
const status = result.exitCode === 0 ? 'pass' : 'fail';
checks.push({
script,
command: `pnpm run ${script}`,
status,
exit_code: result.exitCode,
duration_ms: duration,
stdout: truncateOutput(result.stdout, maxOutputChars),
stderr: truncateOutput(result.stderr, maxOutputChars),
});
if (status === 'fail' && failFast) {
stopRunning = true;
}
}
const summary = {
total: checks.length,
passed: checks.filter((check) => check.status === 'pass').length,
failed: checks.filter((check) => check.status === 'fail').length,
missing: checks.filter((check) => check.status === 'missing').length,
skipped: checks.filter((check) => check.status === 'skipped').length,
};
return {
overall: summary.failed > 0 || summary.missing > 0 ? 'fail' : 'pass',
scripts_requested: scriptsRequested,
checks,
summary,
};
}
private resolveScripts(scripts: string[] | undefined, includeTests: boolean) {
const list = scripts && scripts.length > 0 ? scripts : [...DEFAULT_SCRIPTS];
if (includeTests) {
list.push('test');
}
return dedupe(list);
}
}
function dedupe(list: string[]) {
const seen = new Set<string>();
const output: string[] = [];
for (const item of list) {
if (seen.has(item)) {
continue;
}
seen.add(item);
output.push(item);
}
return output;
}
function truncateOutput(value: string, maxOutputChars: number) {
if (value.length <= maxOutputChars) {
return value;
}
return `${value.slice(0, maxOutputChars)}\n...[truncated ${value.length - maxOutputChars} chars]`;
}

View File

@@ -0,0 +1,40 @@
import { z } from 'zod/v3';
export const RunChecksInputSchema = z.object({
state: z
.object({
scripts: z.array(z.string().min(1)).optional(),
includeTests: z.boolean().optional(),
failFast: z.boolean().optional(),
maxOutputChars: z.number().int().min(200).max(20000).optional(),
})
.optional(),
});
export const RunChecksResultSchema = z.object({
script: z.string(),
command: z.string(),
status: z.enum(['pass', 'fail', 'missing', 'skipped']),
exit_code: z.number().int().nullable(),
duration_ms: z.number().int().min(0),
stdout: z.string(),
stderr: z.string(),
message: z.string().optional(),
});
export const RunChecksOutputSchema = z.object({
overall: z.enum(['pass', 'fail']),
scripts_requested: z.array(z.string()),
checks: z.array(RunChecksResultSchema),
summary: z.object({
total: z.number().int().min(0),
passed: z.number().int().min(0),
failed: z.number().int().min(0),
missing: z.number().int().min(0),
skipped: z.number().int().min(0),
}),
});
export type RunChecksInput = z.infer<typeof RunChecksInputSchema>;
export type RunChecksResult = z.infer<typeof RunChecksResultSchema>;
export type RunChecksOutput = z.infer<typeof RunChecksOutputSchema>;

View File

@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import { z } from 'zod/v3';
interface ScriptInfo {
name: string;
@@ -241,9 +241,12 @@ export function registerScriptsTools(server: McpServer) {
}
function createGetScriptsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_scripts',
'Get all available npm/pnpm scripts with descriptions and usage guidance',
{
description:
'Get all available npm/pnpm scripts with descriptions and usage guidance',
},
async () => {
const scripts = await ScriptsTool.getScripts();
@@ -267,13 +270,15 @@ function createGetScriptsTool(server: McpServer) {
}
function createGetScriptDetailsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_script_details',
'Get detailed information about a specific script',
{
state: z.object({
scriptName: z.string(),
}),
description: 'Get detailed information about a specific script',
inputSchema: {
state: z.object({
scriptName: z.string(),
}),
},
},
async ({ state }) => {
const script = await ScriptsTool.getScriptDetails(state.scriptName);
@@ -300,9 +305,12 @@ Usage: ${script.usage}${healthcheck}`,
}
function createGetHealthcheckScriptsTool(server: McpServer) {
return server.tool(
return server.registerTool(
'get_healthcheck_scripts',
'Get critical scripts that should be run after writing code (typecheck, lint, format, test)',
{
description:
'Get critical scripts that should be run after writing code (typecheck, lint, format, test)',
},
async () => {
const scripts = await ScriptsTool.getScripts();
const healthcheckScripts = scripts.filter((script) => script.healthcheck);

View File

@@ -0,0 +1,431 @@
import { describe, expect, it } from 'vitest';
import {
type KitStatusDeps,
createKitStatusService,
} from '../kit-status.service';
function createDeps(overrides: Partial<KitStatusDeps> = {}): KitStatusDeps {
const readJsonMap: Record<string, unknown> = {
'package.json': {
name: 'test-project',
packageManager: 'pnpm@10.0.0',
},
'apps/web/package.json': {
dependencies: {
next: '16.1.6',
},
},
};
return {
rootPath: '/repo',
async readJsonFile(path: string) {
if (!(path in readJsonMap)) {
throw new Error(`missing file: ${path}`);
}
return readJsonMap[path];
},
async pathExists(path: string) {
return path === 'apps/web/supabase';
},
async isDirectory(path: string) {
return path === 'node_modules';
},
async executeCommand(command: string, args: string[]) {
if (command !== 'git') {
throw new Error('unsupported command');
}
if (args[0] === 'rev-parse') {
return {
stdout: 'main\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'status') {
return {
stdout: '',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'symbolic-ref') {
return {
stdout: 'origin/main\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'merge-base') {
return {
stdout: 'abc123\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'merge-tree') {
return {
stdout: '',
stderr: '',
exitCode: 0,
};
}
throw new Error('unsupported git args');
},
async isPortOpen(port: number) {
return port === 3000 || port === 54321 || port === 54323;
},
getNodeVersion() {
return 'v22.5.0';
},
...overrides,
};
}
describe('KitStatusService', () => {
it('returns a complete status in the happy path', async () => {
const service = createKitStatusService(createDeps());
const result = await service.getStatus({});
expect(result.project_name).toBe('test-project');
expect(result.package_manager).toBe('pnpm');
expect(result.node_version).toBe('22.5.0');
expect(result.git_branch).toBe('main');
expect(result.git_clean).toBe(true);
expect(result.deps_installed).toBe(true);
expect(result.variant).toBe('next-supabase');
expect(result.services.app.running).toBe(true);
expect(result.services.app.port).toBe(3000);
expect(result.services.supabase.running).toBe(true);
expect(result.services.supabase.api_port).toBe(54321);
expect(result.services.supabase.studio_port).toBe(54323);
expect(result.git_modified_files).toHaveLength(0);
expect(result.git_untracked_files).toHaveLength(0);
expect(result.git_merge_check.target_branch).toBe('main');
expect(result.git_merge_check.has_conflicts).toBe(false);
expect(result.diagnostics).toHaveLength(5);
});
it('falls back when git commands fail', async () => {
const service = createKitStatusService(
createDeps({
async executeCommand() {
throw new Error('git not found');
},
}),
);
const result = await service.getStatus({});
expect(result.git_branch).toBe('unknown');
expect(result.git_clean).toBe(false);
expect(result.git_merge_check.detectable).toBe(false);
expect(result.diagnostics.find((item) => item.id === 'git')?.status).toBe(
'warn',
);
});
it('collects modified files from git status output', async () => {
const service = createKitStatusService(
createDeps({
async executeCommand(command: string, args: string[]) {
if (command !== 'git') {
throw new Error('unsupported command');
}
if (args[0] === 'rev-parse') {
return {
stdout: 'feature/status\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'status') {
return {
stdout: ' M apps/web/page.tsx\n?? new-file.ts\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'symbolic-ref') {
return {
stdout: 'origin/main\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'merge-base') {
return {
stdout: 'abc123\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'merge-tree') {
return {
stdout: '',
stderr: '',
exitCode: 0,
};
}
throw new Error('unsupported git args');
},
}),
);
const result = await service.getStatus({});
expect(result.git_clean).toBe(false);
expect(result.git_modified_files).toEqual(['apps/web/page.tsx']);
expect(result.git_untracked_files).toEqual(['new-file.ts']);
});
it('detects merge conflicts against default branch', async () => {
const service = createKitStatusService(
createDeps({
async executeCommand(command: string, args: string[]) {
if (command !== 'git') {
throw new Error('unsupported command');
}
if (args[0] === 'rev-parse') {
return {
stdout: 'feature/conflicts\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'status') {
return {
stdout: '',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'symbolic-ref') {
return {
stdout: 'origin/main\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'merge-base') {
return {
stdout: 'abc123\n',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'merge-tree') {
return {
stdout:
'CONFLICT (content): Merge conflict in apps/dev-tool/app/page.tsx\n',
stderr: '',
exitCode: 0,
};
}
throw new Error('unsupported git args');
},
}),
);
const result = await service.getStatus({});
expect(result.git_merge_check.target_branch).toBe('main');
expect(result.git_merge_check.detectable).toBe(true);
expect(result.git_merge_check.has_conflicts).toBe(true);
expect(result.git_merge_check.conflict_files).toEqual([
'apps/dev-tool/app/page.tsx',
]);
expect(
result.diagnostics.find((item) => item.id === 'merge_conflicts')?.status,
).toBe('warn');
});
it('uses unknown package manager when packageManager is missing', async () => {
const service = createKitStatusService(
createDeps({
async readJsonFile(path: string) {
if (path === 'package.json') {
return { name: 'test-project' };
}
if (path === 'apps/web/package.json') {
return {
dependencies: {
next: '16.1.6',
},
};
}
throw new Error(`missing file: ${path}`);
},
}),
);
const result = await service.getStatus({});
expect(result.package_manager).toBe('unknown');
});
it('provides remedies when services are not running', async () => {
const service = createKitStatusService(
createDeps({
async isPortOpen() {
return false;
},
}),
);
const result = await service.getStatus({});
expect(result.services.app.running).toBe(false);
expect(result.services.supabase.running).toBe(false);
const devServerDiagnostic = result.diagnostics.find(
(item) => item.id === 'dev_server',
);
const supabaseDiagnostic = result.diagnostics.find(
(item) => item.id === 'supabase',
);
expect(devServerDiagnostic?.status).toBe('fail');
expect(devServerDiagnostic?.remedies).toEqual(['Run pnpm dev']);
expect(supabaseDiagnostic?.status).toBe('fail');
expect(supabaseDiagnostic?.remedies).toEqual([
'Run pnpm supabase:web:start',
]);
});
it('maps variant from .makerkit/config.json when present', async () => {
const service = createKitStatusService(
createDeps({
async pathExists(path: string) {
return path === '.makerkit/config.json';
},
async readJsonFile(path: string) {
if (path === '.makerkit/config.json') {
return {
variant: 'next-prisma',
};
}
if (path === 'package.json') {
return {
name: 'test-project',
packageManager: 'pnpm@10.0.0',
};
}
throw new Error(`missing file: ${path}`);
},
}),
);
const result = await service.getStatus({});
expect(result.variant).toBe('next-prisma');
expect(result.variant_family).toBe('orm');
expect(result.database).toBe('postgresql');
expect(result.auth).toBe('better-auth');
});
it('reads variant from the template key when present', async () => {
const service = createKitStatusService(
createDeps({
async pathExists(path: string) {
return path === '.makerkit/config.json';
},
async readJsonFile(path: string) {
if (path === '.makerkit/config.json') {
return {
template: 'react-router-supabase',
};
}
if (path === 'package.json') {
return {
name: 'test-project',
packageManager: 'pnpm@10.0.0',
};
}
throw new Error(`missing file: ${path}`);
},
}),
);
const result = await service.getStatus({});
expect(result.variant).toBe('react-router-supabase');
expect(result.framework).toBe('react-router');
});
it('reads variant from kitVariant key and preserves unknown names', async () => {
const service = createKitStatusService(
createDeps({
async pathExists(path: string) {
return path === '.makerkit/config.json';
},
async readJsonFile(path: string) {
if (path === '.makerkit/config.json') {
return {
kitVariant: 'custom-enterprise-kit',
};
}
if (path === 'package.json') {
return {
name: 'test-project',
packageManager: 'pnpm@10.0.0',
};
}
throw new Error(`missing file: ${path}`);
},
}),
);
const result = await service.getStatus({});
expect(result.variant).toBe('custom-enterprise-kit');
expect(result.variant_family).toBe('supabase');
expect(result.framework).toBe('nextjs');
});
it('uses heuristic variant fallback when config is absent', async () => {
const service = createKitStatusService(
createDeps({
async pathExists(path: string) {
return path === 'apps/web/supabase';
},
}),
);
const result = await service.getStatus({});
expect(result.variant).toBe('next-supabase');
expect(result.framework).toBe('nextjs');
expect(result.database).toBe('supabase');
expect(result.auth).toBe('supabase');
});
});

View File

@@ -0,0 +1,144 @@
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 {
type KitStatusDeps,
createKitStatusService,
} from './kit-status.service';
import { KitStatusInputSchema, KitStatusOutputSchema } from './schema';
const execFileAsync = promisify(execFile);
export function registerKitStatusTool(server: McpServer) {
return server.registerTool(
'kit_status',
{
description: 'Project status with variant context',
inputSchema: KitStatusInputSchema,
outputSchema: KitStatusOutputSchema,
},
async (input) => {
const parsedInput = KitStatusInputSchema.parse(input);
try {
const service = createKitStatusService(createKitStatusDeps());
const status = await service.getStatus(parsedInput);
return {
structuredContent: status,
content: [
{
type: 'text',
text: JSON.stringify(status),
},
],
};
} catch (error) {
const message = toErrorMessage(error);
return {
isError: true,
content: [
{
type: 'text',
text: `kit_status failed: ${message}`,
},
],
};
}
},
);
}
function createKitStatusDeps(): KitStatusDeps {
const rootPath = process.cwd();
return {
rootPath,
async readJsonFile(path: string): Promise<unknown> {
const filePath = join(rootPath, path);
const content = await readFile(filePath, 'utf8');
return JSON.parse(content) as unknown;
},
async pathExists(path: string) {
const fullPath = join(rootPath, path);
try {
await access(fullPath);
return true;
} catch {
return false;
}
},
async isDirectory(path: string) {
const fullPath = join(rootPath, path);
try {
const stats = await stat(fullPath);
return stats.isDirectory();
} catch {
return false;
}
},
async executeCommand(command: string, args: string[]) {
const result = await execFileAsync(command, args, {
cwd: rootPath,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
};
},
async isPortOpen(port: number) {
return checkPort(port);
},
getNodeVersion() {
return process.version;
},
};
}
async function checkPort(port: number) {
return new Promise<boolean>((resolve) => {
const socket = new Socket();
socket.setTimeout(200);
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('timeout', () => {
socket.destroy();
resolve(false);
});
socket.once('error', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, '127.0.0.1');
});
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
export {
createKitStatusService,
type KitStatusDeps,
} from './kit-status.service';
export type { KitStatusOutput } from './schema';

View File

@@ -0,0 +1,549 @@
import { join } from 'node:path';
import type { KitStatusInput, KitStatusOutput } from './schema';
interface VariantDescriptor {
variant: string;
variant_family: string;
framework: string;
database: string;
auth: string;
}
interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface ServicesStatus {
app: {
running: boolean;
port: number | null;
};
supabase: {
running: boolean;
api_port: number | null;
studio_port: number | null;
};
}
interface MergeCheckStatus {
target_branch: string | null;
detectable: boolean;
has_conflicts: boolean | null;
conflict_files: string[];
message: string;
}
export interface KitStatusDeps {
rootPath: string;
readJsonFile(path: string): Promise<unknown>;
pathExists(path: string): Promise<boolean>;
isDirectory(path: string): Promise<boolean>;
executeCommand(command: string, args: string[]): Promise<CommandResult>;
isPortOpen(port: number): Promise<boolean>;
getNodeVersion(): string;
}
export function createKitStatusService(deps: KitStatusDeps) {
return new KitStatusService(deps);
}
export class KitStatusService {
constructor(private readonly deps: KitStatusDeps) {}
async getStatus(_input: KitStatusInput): Promise<KitStatusOutput> {
const packageJson = await this.readObject('package.json');
const projectName = this.readString(packageJson, 'name') ?? 'unknown';
const packageManager = this.getPackageManager(packageJson);
const depsInstalled = await this.deps.isDirectory('node_modules');
const { gitBranch, gitClean, modifiedFiles, untrackedFiles, mergeCheck } =
await this.getGitStatus();
const variant = await this.resolveVariant();
const services = await this.getServicesStatus();
const diagnostics = this.buildDiagnostics({
depsInstalled,
gitBranch,
gitClean,
mergeCheck,
services,
});
return {
...variant,
project_name: projectName,
node_version: this.deps.getNodeVersion().replace(/^v/, ''),
package_manager: packageManager,
deps_installed: depsInstalled,
git_clean: gitClean,
git_branch: gitBranch,
git_modified_files: modifiedFiles,
git_untracked_files: untrackedFiles,
git_merge_check: mergeCheck,
services,
diagnostics,
};
}
private async getServicesStatus(): Promise<ServicesStatus> {
const app = await this.detectAppService();
const supabase = await this.detectSupabaseService();
return {
app,
supabase,
};
}
private async detectAppService() {
const commonDevPorts = [3000, 3001, 3002, 3003];
for (const port of commonDevPorts) {
if (await this.deps.isPortOpen(port)) {
return {
running: true,
port,
};
}
}
return {
running: false,
port: null,
};
}
private async detectSupabaseService() {
const apiPort = 54321;
const studioPort = 54323;
const [apiRunning, studioRunning] = await Promise.all([
this.deps.isPortOpen(apiPort),
this.deps.isPortOpen(studioPort),
]);
return {
running: apiRunning || studioRunning,
api_port: apiRunning ? apiPort : null,
studio_port: studioRunning ? studioPort : null,
};
}
private buildDiagnostics(params: {
depsInstalled: boolean;
gitBranch: string;
gitClean: boolean;
mergeCheck: MergeCheckStatus;
services: ServicesStatus;
}) {
const diagnostics: KitStatusOutput['diagnostics'] = [];
diagnostics.push({
id: 'dependencies',
status: params.depsInstalled ? 'pass' : 'fail',
message: params.depsInstalled
? 'Dependencies are installed.'
: 'Dependencies are missing.',
remedies: params.depsInstalled ? [] : ['Run pnpm install'],
});
diagnostics.push({
id: 'dev_server',
status: params.services.app.running ? 'pass' : 'fail',
message: params.services.app.running
? `Dev server is running on port ${params.services.app.port}.`
: 'Dev server is not running.',
remedies: params.services.app.running ? [] : ['Run pnpm dev'],
});
diagnostics.push({
id: 'supabase',
status: params.services.supabase.running ? 'pass' : 'fail',
message: params.services.supabase.running
? `Supabase is running${params.services.supabase.api_port ? ` (API ${params.services.supabase.api_port})` : ''}${params.services.supabase.studio_port ? ` (Studio ${params.services.supabase.studio_port})` : ''}.`
: 'Supabase is not running.',
remedies: params.services.supabase.running
? []
: ['Run pnpm supabase:web:start'],
});
diagnostics.push({
id: 'git',
status:
params.gitBranch === 'unknown'
? 'warn'
: params.gitClean
? 'pass'
: 'warn',
message:
params.gitBranch === 'unknown'
? 'Git status unavailable.'
: `Current branch ${params.gitBranch} is ${params.gitClean ? 'clean' : 'dirty'}.`,
remedies:
params.gitBranch === 'unknown' || params.gitClean
? []
: ['Commit or stash changes when you need a clean workspace'],
});
diagnostics.push({
id: 'merge_conflicts',
status:
params.mergeCheck.has_conflicts === true
? 'warn'
: params.mergeCheck.detectable
? 'pass'
: 'warn',
message: params.mergeCheck.message,
remedies:
params.mergeCheck.has_conflicts === true
? [
`Rebase or merge ${params.mergeCheck.target_branch} and resolve conflicts`,
]
: [],
});
return diagnostics;
}
private async getGitStatus() {
try {
const branchResult = await this.deps.executeCommand('git', [
'rev-parse',
'--abbrev-ref',
'HEAD',
]);
const statusResult = await this.deps.executeCommand('git', [
'status',
'--porcelain',
]);
const parsedStatus = this.parseGitStatus(statusResult.stdout);
const mergeCheck = await this.getMergeCheck();
return {
gitBranch: branchResult.stdout.trim() || 'unknown',
gitClean:
parsedStatus.modifiedFiles.length === 0 &&
parsedStatus.untrackedFiles.length === 0,
modifiedFiles: parsedStatus.modifiedFiles,
untrackedFiles: parsedStatus.untrackedFiles,
mergeCheck,
};
} catch {
return {
gitBranch: 'unknown',
gitClean: false,
modifiedFiles: [],
untrackedFiles: [],
mergeCheck: {
target_branch: null,
detectable: false,
has_conflicts: null,
conflict_files: [],
message: 'Git metadata unavailable.',
} satisfies MergeCheckStatus,
};
}
}
private parseGitStatus(output: string) {
const modifiedFiles: string[] = [];
const untrackedFiles: string[] = [];
const lines = output.split('\n').filter((line) => line.trim().length > 0);
for (const line of lines) {
if (line.startsWith('?? ')) {
const path = line.slice(3).trim();
if (path) {
untrackedFiles.push(path);
}
continue;
}
if (line.length >= 4) {
const path = line.slice(3).trim();
if (path) {
modifiedFiles.push(path);
}
}
}
return {
modifiedFiles,
untrackedFiles,
};
}
private async getMergeCheck(): Promise<MergeCheckStatus> {
const targetBranch = await this.resolveMergeTargetBranch();
if (!targetBranch) {
return {
target_branch: null,
detectable: false,
has_conflicts: null,
conflict_files: [],
message: 'No default target branch found for merge conflict checks.',
};
}
try {
const mergeBaseResult = await this.deps.executeCommand('git', [
'merge-base',
'HEAD',
targetBranch,
]);
const mergeBase = mergeBaseResult.stdout.trim();
if (!mergeBase) {
return {
target_branch: targetBranch,
detectable: false,
has_conflicts: null,
conflict_files: [],
message: 'Unable to compute merge base.',
};
}
const mergeTreeResult = await this.deps.executeCommand('git', [
'merge-tree',
mergeBase,
'HEAD',
targetBranch,
]);
const rawOutput = `${mergeTreeResult.stdout}\n${mergeTreeResult.stderr}`;
const conflictFiles = this.extractConflictFiles(rawOutput);
const hasConflictMarkers =
/CONFLICT|changed in both|both modified|both added/i.test(rawOutput);
const hasConflicts = conflictFiles.length > 0 || hasConflictMarkers;
return {
target_branch: targetBranch,
detectable: true,
has_conflicts: hasConflicts,
conflict_files: conflictFiles,
message: hasConflicts
? `Potential merge conflicts detected against ${targetBranch}.`
: `No merge conflicts detected against ${targetBranch}.`,
};
} catch {
return {
target_branch: targetBranch,
detectable: false,
has_conflicts: null,
conflict_files: [],
message: 'Merge conflict detection is not available in this git setup.',
};
}
}
private extractConflictFiles(rawOutput: string) {
const files = new Set<string>();
const lines = rawOutput.split('\n');
for (const line of lines) {
const conflictMatch = line.match(/CONFLICT .* in (.+)$/);
if (conflictMatch?.[1]) {
files.add(conflictMatch[1].trim());
}
}
return Array.from(files).sort((a, b) => a.localeCompare(b));
}
private async resolveMergeTargetBranch() {
try {
const originHead = await this.deps.executeCommand('git', [
'symbolic-ref',
'--quiet',
'--short',
'refs/remotes/origin/HEAD',
]);
const value = originHead.stdout.trim();
if (value) {
return value.replace(/^origin\//, '');
}
} catch {
// Fallback candidates below.
}
for (const candidate of ['main', 'master']) {
try {
await this.deps.executeCommand('git', [
'rev-parse',
'--verify',
candidate,
]);
return candidate;
} catch {
// Try next.
}
}
return null;
}
private async resolveVariant(): Promise<VariantDescriptor> {
const explicitVariant = await this.resolveConfiguredVariant();
if (explicitVariant) {
return explicitVariant;
}
const heuristicVariant = await this.resolveHeuristicVariant();
if (heuristicVariant) {
return heuristicVariant;
}
return this.mapVariant('next-supabase');
}
private async resolveConfiguredVariant(): Promise<VariantDescriptor | null> {
const configPath = '.makerkit/config.json';
if (!(await this.deps.pathExists(configPath))) {
return null;
}
const config = await this.readObject(configPath);
const value =
this.readString(config, 'variant') ??
this.readString(config, 'template') ??
this.readString(config, 'kitVariant');
if (!value) {
return null;
}
return this.mapVariant(value, {
preserveVariant: true,
});
}
private async resolveHeuristicVariant(): Promise<VariantDescriptor | null> {
const hasSupabaseFolder = await this.deps.pathExists('apps/web/supabase');
if (!hasSupabaseFolder) {
return null;
}
const appPackage = await this.readObject(
join('apps', 'web', 'package.json'),
);
const hasNextDependency = this.hasDependency(appPackage, 'next');
if (hasNextDependency) {
return this.mapVariant('next-supabase');
}
return null;
}
private hasDependency(json: Record<string, unknown>, dependency: string) {
const dependencies = this.readObjectValue(json, 'dependencies');
const devDependencies = this.readObjectValue(json, 'devDependencies');
return Boolean(
this.readString(dependencies, dependency) ||
this.readString(devDependencies, dependency),
);
}
private mapVariant(
variant: string,
options: {
preserveVariant?: boolean;
} = {},
): VariantDescriptor {
if (variant === 'next-drizzle') {
return {
variant,
variant_family: 'orm',
framework: 'nextjs',
database: 'postgresql',
auth: 'better-auth',
};
}
if (variant === 'next-prisma') {
return {
variant,
variant_family: 'orm',
framework: 'nextjs',
database: 'postgresql',
auth: 'better-auth',
};
}
if (variant === 'react-router-supabase') {
return {
variant,
variant_family: 'supabase',
framework: 'react-router',
database: 'supabase',
auth: 'supabase',
};
}
return {
variant: options.preserveVariant ? variant : 'next-supabase',
variant_family: 'supabase',
framework: 'nextjs',
database: 'supabase',
auth: 'supabase',
};
}
private async readObject(path: string): Promise<Record<string, unknown>> {
try {
const value = await this.deps.readJsonFile(path);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
} catch {
return {};
}
}
private readString(obj: Record<string, unknown>, key: string) {
const value = obj[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
private readObjectValue(obj: Record<string, unknown>, key: string) {
const value = obj[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
private getPackageManager(packageJson: Record<string, unknown>) {
const packageManager = this.readString(packageJson, 'packageManager');
if (!packageManager) {
return 'unknown';
}
const [name] = packageManager.split('@');
return name || 'unknown';
}
}

View File

@@ -0,0 +1,48 @@
import { z } from 'zod/v3';
export const KitStatusInputSchema = z.object({});
export const KitStatusOutputSchema = z.object({
variant: z.string(),
variant_family: z.string(),
framework: z.string(),
database: z.string(),
auth: z.string(),
project_name: z.string(),
node_version: z.string(),
package_manager: z.string(),
deps_installed: z.boolean(),
git_clean: z.boolean(),
git_branch: z.string(),
git_modified_files: z.array(z.string()),
git_untracked_files: z.array(z.string()),
git_merge_check: z.object({
target_branch: z.string().nullable(),
detectable: z.boolean(),
has_conflicts: z.boolean().nullable(),
conflict_files: z.array(z.string()),
message: z.string(),
}),
services: z.object({
app: z.object({
running: z.boolean(),
port: z.number().nullable(),
}),
supabase: z.object({
running: z.boolean(),
api_port: z.number().nullable(),
studio_port: z.number().nullable(),
}),
}),
diagnostics: z.array(
z.object({
id: z.string(),
status: z.enum(['pass', 'warn', 'fail']),
message: z.string(),
remedies: z.array(z.string()).default([]),
}),
),
});
export type KitStatusInput = z.infer<typeof KitStatusInputSchema>;
export type KitStatusOutput = z.infer<typeof KitStatusOutputSchema>;

View File

@@ -0,0 +1,466 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
type KitTranslationsDeps,
createKitTranslationsService,
} from '../kit-translations.service';
function createDeps(
files: Record<string, string>,
directories: string[],
): KitTranslationsDeps & { _files: Record<string, string> } {
const store = { ...files };
const dirSet = new Set(directories);
return {
rootPath: '/repo',
async readFile(filePath: string) {
if (!(filePath in store)) {
const error = new Error(
`ENOENT: no such file: ${filePath}`,
) as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return store[filePath]!;
},
async writeFile(filePath: string, content: string) {
store[filePath] = content;
},
async readdir(dirPath: string) {
const entries = new Set<string>();
for (const filePath of Object.keys(store)) {
if (path.dirname(filePath) === dirPath) {
entries.add(path.basename(filePath));
}
}
for (const dir of dirSet) {
if (path.dirname(dir) === dirPath) {
entries.add(path.basename(dir));
}
}
return Array.from(entries.values());
},
async stat(targetPath: string) {
if (!dirSet.has(targetPath)) {
const error = new Error(
`ENOENT: no such directory: ${targetPath}`,
) as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
return {
isDirectory: () => true,
};
},
async fileExists(filePath: string) {
return filePath in store || dirSet.has(filePath);
},
async mkdir(dirPath: string) {
dirSet.add(dirPath);
},
async unlink(filePath: string) {
delete store[filePath];
},
async rmdir(dirPath: string) {
const prefix = dirPath.endsWith('/') ? dirPath : `${dirPath}/`;
for (const key of Object.keys(store)) {
if (key.startsWith(prefix)) {
delete store[key];
}
}
for (const dir of dirSet) {
if (dir === dirPath || dir.startsWith(prefix)) {
dirSet.delete(dir);
}
}
},
get _files() {
return store;
},
};
}
describe('KitTranslationsService.list', () => {
it('lists and flattens translations with missing namespace fallback', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({
header: { title: 'Dashboard' },
}),
[`${localesRoot}/en/auth.json`]: JSON.stringify({
login: 'Sign In',
}),
[`${localesRoot}/es/common.json`]: JSON.stringify({
header: { title: 'Panel' },
}),
},
[localesRoot, `${localesRoot}/en`, `${localesRoot}/es`],
);
const service = createKitTranslationsService(deps);
const result = await service.list();
expect(result.base_locale).toBe('en');
expect(result.locales).toEqual(['en', 'es']);
expect(result.namespaces).toEqual(['auth', 'common']);
expect(result.translations.en.common['header.title']).toBe('Dashboard');
expect(result.translations.en.auth.login).toBe('Sign In');
expect(result.translations.es.common['header.title']).toBe('Panel');
expect(result.translations.es.auth).toEqual({});
});
});
describe('KitTranslationsService.update', () => {
it('updates nested translation keys', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`],
);
const service = createKitTranslationsService(deps);
await service.update({
locale: 'en',
namespace: 'common',
key: 'header.title',
value: 'Home',
});
const content = deps._files[`${localesRoot}/en/common.json`]!;
expect(JSON.parse(content)).toEqual({ header: { title: 'Home' } });
});
it('rejects paths outside locales root', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`],
);
const service = createKitTranslationsService(deps);
await expect(
service.update({
locale: '../secrets',
namespace: 'common',
key: 'header.title',
value: 'Oops',
}),
).rejects.toThrow('locale');
});
it('rejects namespace path segments', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`],
);
const service = createKitTranslationsService(deps);
await expect(
service.update({
locale: 'en',
namespace: 'nested/common',
key: 'header.title',
value: 'Oops',
}),
).rejects.toThrow('namespace');
});
});
describe('KitTranslationsService.stats', () => {
it('computes coverage using base locale keys', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({
header: { title: 'Dashboard', subtitle: 'Welcome' },
}),
[`${localesRoot}/es/common.json`]: JSON.stringify({
header: { title: 'Panel' },
}),
},
[localesRoot, `${localesRoot}/en`, `${localesRoot}/es`],
);
const service = createKitTranslationsService(deps);
const result = await service.stats();
expect(result.base_locale).toBe('en');
expect(result.total_keys).toBe(2);
expect(result.coverage.en.translated).toBe(2);
expect(result.coverage.es.translated).toBe(1);
expect(result.coverage.es.missing).toBe(1);
});
});
describe('KitTranslationsService.addNamespace', () => {
it('creates namespace JSON in all locale directories', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
[`${localesRoot}/es/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`, `${localesRoot}/es`],
);
const service = createKitTranslationsService(deps);
const result = await service.addNamespace({ namespace: 'billing' });
expect(result.success).toBe(true);
expect(result.namespace).toBe('billing');
expect(result.files_created).toHaveLength(2);
expect(deps._files[`${localesRoot}/en/billing.json`]).toBe(
JSON.stringify({}, null, 2),
);
expect(deps._files[`${localesRoot}/es/billing.json`]).toBe(
JSON.stringify({}, null, 2),
);
});
it('throws if namespace already exists', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`],
);
const service = createKitTranslationsService(deps);
await expect(service.addNamespace({ namespace: 'common' })).rejects.toThrow(
'already exists',
);
});
it('throws if no locales exist', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps({}, [localesRoot]);
const service = createKitTranslationsService(deps);
await expect(
service.addNamespace({ namespace: 'billing' }),
).rejects.toThrow('No locales exist');
});
it('rejects path traversal in namespace', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`],
);
const service = createKitTranslationsService(deps);
await expect(
service.addNamespace({ namespace: '../secrets' }),
).rejects.toThrow('namespace');
await expect(
service.addNamespace({ namespace: 'foo/bar' }),
).rejects.toThrow('namespace');
});
});
describe('KitTranslationsService.addLocale', () => {
it('creates locale directory with namespace files', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({ hello: 'Hello' }),
[`${localesRoot}/en/auth.json`]: JSON.stringify({ login: 'Login' }),
},
[localesRoot, `${localesRoot}/en`],
);
const service = createKitTranslationsService(deps);
const result = await service.addLocale({ locale: 'fr' });
expect(result.success).toBe(true);
expect(result.locale).toBe('fr');
expect(result.files_created).toHaveLength(2);
expect(deps._files[`${localesRoot}/fr/auth.json`]).toBe(
JSON.stringify({}, null, 2),
);
expect(deps._files[`${localesRoot}/fr/common.json`]).toBe(
JSON.stringify({}, null, 2),
);
});
it('throws if locale already exists', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`],
);
const service = createKitTranslationsService(deps);
await expect(service.addLocale({ locale: 'en' })).rejects.toThrow(
'already exists',
);
});
it('works when no namespaces exist yet', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps({}, [localesRoot]);
const service = createKitTranslationsService(deps);
const result = await service.addLocale({ locale: 'en' });
expect(result.success).toBe(true);
expect(result.files_created).toHaveLength(0);
});
it('rejects path traversal in locale', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps({}, [localesRoot]);
const service = createKitTranslationsService(deps);
await expect(service.addLocale({ locale: '../hack' })).rejects.toThrow(
'locale',
);
await expect(service.addLocale({ locale: 'foo\\bar' })).rejects.toThrow(
'locale',
);
});
});
describe('KitTranslationsService.removeNamespace', () => {
it('deletes namespace files from all locales', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
[`${localesRoot}/en/auth.json`]: JSON.stringify({}),
[`${localesRoot}/es/common.json`]: JSON.stringify({}),
[`${localesRoot}/es/auth.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`, `${localesRoot}/es`],
);
const service = createKitTranslationsService(deps);
const result = await service.removeNamespace({ namespace: 'auth' });
expect(result.success).toBe(true);
expect(result.namespace).toBe('auth');
expect(result.files_removed).toHaveLength(2);
expect(deps._files[`${localesRoot}/en/auth.json`]).toBeUndefined();
expect(deps._files[`${localesRoot}/es/auth.json`]).toBeUndefined();
expect(deps._files[`${localesRoot}/en/common.json`]).toBeDefined();
});
it('throws if namespace does not exist', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`],
);
const service = createKitTranslationsService(deps);
await expect(
service.removeNamespace({ namespace: 'nonexistent' }),
).rejects.toThrow('does not exist');
});
it('rejects path traversal', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps({}, [localesRoot]);
const service = createKitTranslationsService(deps);
await expect(
service.removeNamespace({ namespace: '../etc' }),
).rejects.toThrow('namespace');
});
});
describe('KitTranslationsService.removeLocale', () => {
it('deletes entire locale directory', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
[`${localesRoot}/es/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`, `${localesRoot}/es`],
);
const service = createKitTranslationsService(deps);
const result = await service.removeLocale({ locale: 'es' });
expect(result.success).toBe(true);
expect(result.locale).toBe('es');
expect(result.path_removed).toBe(`${localesRoot}/es`);
expect(deps._files[`${localesRoot}/es/common.json`]).toBeUndefined();
expect(deps._files[`${localesRoot}/en/common.json`]).toBeDefined();
});
it('throws if locale does not exist', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps({}, [localesRoot]);
const service = createKitTranslationsService(deps);
await expect(service.removeLocale({ locale: 'fr' })).rejects.toThrow(
'does not exist',
);
});
it('throws when trying to delete base locale', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps(
{
[`${localesRoot}/en/common.json`]: JSON.stringify({}),
[`${localesRoot}/es/common.json`]: JSON.stringify({}),
},
[localesRoot, `${localesRoot}/en`, `${localesRoot}/es`],
);
const service = createKitTranslationsService(deps);
await expect(service.removeLocale({ locale: 'en' })).rejects.toThrow(
'Cannot remove base locale',
);
});
it('rejects path traversal', async () => {
const localesRoot = '/repo/apps/web/public/locales';
const deps = createDeps({}, [localesRoot]);
const service = createKitTranslationsService(deps);
await expect(service.removeLocale({ locale: '../hack' })).rejects.toThrow(
'locale',
);
});
});

View File

@@ -0,0 +1,219 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
type KitTranslationsDeps,
createKitTranslationsDeps,
createKitTranslationsService,
} from './kit-translations.service';
import {
KitTranslationsAddLocaleInputSchema,
KitTranslationsAddLocaleOutputSchema,
KitTranslationsAddNamespaceInputSchema,
KitTranslationsAddNamespaceOutputSchema,
KitTranslationsListInputSchema,
KitTranslationsListOutputSchema,
KitTranslationsRemoveLocaleInputSchema,
KitTranslationsRemoveLocaleOutputSchema,
KitTranslationsRemoveNamespaceInputSchema,
KitTranslationsRemoveNamespaceOutputSchema,
KitTranslationsStatsInputSchema,
KitTranslationsStatsOutputSchema,
KitTranslationsUpdateInputSchema,
KitTranslationsUpdateOutputSchema,
} from './schema';
type TextContent = {
type: 'text';
text: string;
};
export function registerKitTranslationsTools(server: McpServer) {
const service = createKitTranslationsService(createKitTranslationsDeps());
server.registerTool(
'kit_translations_list',
{
description: 'List translations across locales and namespaces',
inputSchema: KitTranslationsListInputSchema,
outputSchema: KitTranslationsListOutputSchema,
},
async () => {
try {
const result = await service.list();
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_translations_list', error);
}
},
);
server.registerTool(
'kit_translations_update',
{
description: 'Update a translation value in a locale namespace',
inputSchema: KitTranslationsUpdateInputSchema,
outputSchema: KitTranslationsUpdateOutputSchema,
},
async (input) => {
try {
const parsed = KitTranslationsUpdateInputSchema.parse(input);
const result = await service.update(parsed);
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_translations_update', error);
}
},
);
server.registerTool(
'kit_translations_stats',
{
description: 'Get translation coverage statistics',
inputSchema: KitTranslationsStatsInputSchema,
outputSchema: KitTranslationsStatsOutputSchema,
},
async () => {
try {
const result = await service.stats();
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_translations_stats', error);
}
},
);
server.registerTool(
'kit_translations_add_namespace',
{
description: 'Create a new translation namespace across all locales',
inputSchema: KitTranslationsAddNamespaceInputSchema,
outputSchema: KitTranslationsAddNamespaceOutputSchema,
},
async (input) => {
try {
const { namespace } =
KitTranslationsAddNamespaceInputSchema.parse(input);
const result = await service.addNamespace({ namespace });
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_translations_add_namespace', error);
}
},
);
server.registerTool(
'kit_translations_add_locale',
{
description: 'Add a new locale with empty namespace files',
inputSchema: KitTranslationsAddLocaleInputSchema,
outputSchema: KitTranslationsAddLocaleOutputSchema,
},
async (input) => {
try {
const { locale } = KitTranslationsAddLocaleInputSchema.parse(input);
const result = await service.addLocale({ locale });
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_translations_add_locale', error);
}
},
);
server.registerTool(
'kit_translations_remove_namespace',
{
description: 'Remove a translation namespace from all locales',
inputSchema: KitTranslationsRemoveNamespaceInputSchema,
outputSchema: KitTranslationsRemoveNamespaceOutputSchema,
},
async (input) => {
try {
const { namespace } =
KitTranslationsRemoveNamespaceInputSchema.parse(input);
const result = await service.removeNamespace({ namespace });
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_translations_remove_namespace', error);
}
},
);
server.registerTool(
'kit_translations_remove_locale',
{
description: 'Remove a locale and all its translation files',
inputSchema: KitTranslationsRemoveLocaleInputSchema,
outputSchema: KitTranslationsRemoveLocaleOutputSchema,
},
async (input) => {
try {
const { locale } = KitTranslationsRemoveLocaleInputSchema.parse(input);
const result = await service.removeLocale({ locale });
return {
structuredContent: result,
content: buildTextContent(JSON.stringify(result)),
};
} catch (error) {
return buildErrorResponse('kit_translations_remove_locale', error);
}
},
);
}
function buildErrorResponse(tool: string, error: unknown) {
const message = `${tool} failed: ${toErrorMessage(error)}`;
return {
isError: true,
content: buildTextContent(message),
};
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
function buildTextContent(text: string): TextContent[] {
return [{ type: 'text', text }];
}
export { createKitTranslationsService, createKitTranslationsDeps };
export type { KitTranslationsDeps };
export type {
KitTranslationsAddLocaleOutput,
KitTranslationsAddNamespaceOutput,
KitTranslationsListOutput,
KitTranslationsRemoveLocaleOutput,
KitTranslationsRemoveNamespaceOutput,
KitTranslationsStatsOutput,
KitTranslationsUpdateOutput,
} from './schema';

View File

@@ -0,0 +1,535 @@
import path from 'node:path';
import type {
KitTranslationsAddLocaleInput,
KitTranslationsAddLocaleSuccess,
KitTranslationsAddNamespaceInput,
KitTranslationsAddNamespaceSuccess,
KitTranslationsListSuccess,
KitTranslationsRemoveLocaleInput,
KitTranslationsRemoveLocaleSuccess,
KitTranslationsRemoveNamespaceInput,
KitTranslationsRemoveNamespaceSuccess,
KitTranslationsStatsSuccess,
KitTranslationsUpdateInput,
KitTranslationsUpdateSuccess,
} from './schema';
export interface KitTranslationsDeps {
rootPath: string;
readFile(filePath: string): Promise<string>;
writeFile(filePath: string, content: string): Promise<void>;
readdir(dirPath: string): Promise<string[]>;
stat(path: string): Promise<{ isDirectory(): boolean }>;
fileExists(filePath: string): Promise<boolean>;
mkdir(dirPath: string): Promise<void>;
unlink(filePath: string): Promise<void>;
rmdir(dirPath: string): Promise<void>;
}
export function createKitTranslationsService(deps: KitTranslationsDeps) {
return new KitTranslationsService(deps);
}
export class KitTranslationsService {
constructor(private readonly deps: KitTranslationsDeps) {}
async list(): Promise<Omit<KitTranslationsListSuccess, 'ok'>> {
const localesRoot = this.getLocalesRoot();
const locales = await this.getLocaleDirectories(localesRoot);
const translations: Record<
string,
Record<string, Record<string, string>>
> = {};
const namespaces = new Set<string>();
for (const locale of locales) {
const localeDir = this.resolveLocaleDir(localesRoot, locale);
const files = await this.deps.readdir(localeDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
translations[locale] = {};
for (const file of jsonFiles) {
const namespace = file.replace(/\.json$/, '');
const filePath = path.join(localeDir, file);
namespaces.add(namespace);
translations[locale][namespace] =
await this.readFlatTranslations(filePath);
}
}
const namespaceList = Array.from(namespaces).sort();
for (const locale of locales) {
for (const namespace of namespaceList) {
if (!translations[locale]?.[namespace]) {
translations[locale]![namespace] = {};
}
}
}
return {
base_locale: locales[0] ?? '',
locales,
namespaces: namespaceList,
translations,
};
}
async update(
input: KitTranslationsUpdateInput,
): Promise<Omit<KitTranslationsUpdateSuccess, 'ok'>> {
const localesRoot = this.getLocalesRoot();
assertSinglePathSegment('locale', input.locale);
assertSinglePathSegment('namespace', input.namespace);
const localeDir = this.resolveLocaleDir(localesRoot, input.locale);
const namespacePath = this.resolveNamespaceFile(localeDir, input.namespace);
const localeExists = await this.isDirectory(localeDir);
if (!localeExists) {
throw new Error(`Locale "${input.locale}" does not exist`);
}
if (!(await this.deps.fileExists(namespacePath))) {
throw new Error(
`Namespace "${input.namespace}" does not exist for locale "${input.locale}"`,
);
}
const content = await this.deps.readFile(namespacePath);
const parsed = this.parseJson(content, namespacePath);
const keys = input.key.split('.').filter(Boolean);
if (keys.length === 0) {
throw new Error('Translation key must not be empty');
}
setNestedValue(parsed, keys, input.value);
await this.deps.writeFile(namespacePath, JSON.stringify(parsed, null, 2));
return {
success: true,
file: namespacePath,
};
}
async stats(): Promise<Omit<KitTranslationsStatsSuccess, 'ok'>> {
const { base_locale, locales, namespaces, translations } =
await this.list();
const baseTranslations = translations[base_locale] ?? {};
const baseKeys = new Set<string>();
for (const namespace of namespaces) {
const entries = Object.keys(baseTranslations[namespace] ?? {});
for (const key of entries) {
baseKeys.add(`${namespace}:${key}`);
}
}
const totalKeys = baseKeys.size;
const coverage: Record<
string,
{ total: number; translated: number; missing: number; percentage: number }
> = {};
for (const locale of locales) {
let translated = 0;
for (const compositeKey of baseKeys) {
const [namespace, key] = compositeKey.split(':');
const value = translations[locale]?.[namespace]?.[key];
if (typeof value === 'string' && value.length > 0) {
translated += 1;
}
}
const missing = totalKeys - translated;
const percentage =
totalKeys === 0
? 100
: Number(((translated / totalKeys) * 100).toFixed(1));
coverage[locale] = {
total: totalKeys,
translated,
missing,
percentage,
};
}
return {
base_locale,
locale_count: locales.length,
namespace_count: namespaces.length,
total_keys: totalKeys,
coverage,
};
}
async addNamespace(
input: KitTranslationsAddNamespaceInput,
): Promise<Omit<KitTranslationsAddNamespaceSuccess, 'ok'>> {
const localesRoot = this.getLocalesRoot();
assertSinglePathSegment('namespace', input.namespace);
const locales = await this.getLocaleDirectories(localesRoot);
if (locales.length === 0) {
throw new Error('No locales exist yet');
}
const filesCreated: string[] = [];
for (const locale of locales) {
const localeDir = this.resolveLocaleDir(localesRoot, locale);
const namespacePath = this.resolveNamespaceFile(
localeDir,
input.namespace,
);
if (await this.deps.fileExists(namespacePath)) {
throw new Error(`Namespace "${input.namespace}" already exists`);
}
}
try {
for (const locale of locales) {
const localeDir = this.resolveLocaleDir(localesRoot, locale);
const namespacePath = this.resolveNamespaceFile(
localeDir,
input.namespace,
);
await this.deps.writeFile(namespacePath, JSON.stringify({}, null, 2));
filesCreated.push(namespacePath);
}
} catch (error) {
for (const createdFile of filesCreated) {
try {
await this.deps.unlink(createdFile);
} catch {
// best-effort cleanup
}
}
throw error;
}
return {
success: true,
namespace: input.namespace,
files_created: filesCreated,
};
}
async addLocale(
input: KitTranslationsAddLocaleInput,
): Promise<Omit<KitTranslationsAddLocaleSuccess, 'ok'>> {
const localesRoot = this.getLocalesRoot();
assertSinglePathSegment('locale', input.locale);
const localeDir = this.resolveLocaleDir(localesRoot, input.locale);
if (await this.isDirectory(localeDir)) {
throw new Error(`Locale "${input.locale}" already exists`);
}
const existingLocales = await this.getLocaleDirectories(localesRoot);
const namespaces = new Set<string>();
for (const locale of existingLocales) {
const dir = this.resolveLocaleDir(localesRoot, locale);
const files = await this.deps.readdir(dir);
for (const file of files) {
if (file.endsWith('.json')) {
namespaces.add(file.replace(/\.json$/, ''));
}
}
}
await this.deps.mkdir(localeDir);
const filesCreated: string[] = [];
try {
for (const namespace of Array.from(namespaces).sort()) {
const namespacePath = this.resolveNamespaceFile(localeDir, namespace);
await this.deps.writeFile(namespacePath, JSON.stringify({}, null, 2));
filesCreated.push(namespacePath);
}
} catch (error) {
for (const createdFile of filesCreated) {
try {
await this.deps.unlink(createdFile);
} catch {
// best-effort cleanup
}
}
try {
await this.deps.rmdir(localeDir);
} catch {
// best-effort cleanup
}
throw error;
}
return {
success: true,
locale: input.locale,
files_created: filesCreated,
};
}
async removeNamespace(
input: KitTranslationsRemoveNamespaceInput,
): Promise<Omit<KitTranslationsRemoveNamespaceSuccess, 'ok'>> {
const localesRoot = this.getLocalesRoot();
assertSinglePathSegment('namespace', input.namespace);
const locales = await this.getLocaleDirectories(localesRoot);
const filesRemoved: string[] = [];
for (const locale of locales) {
const localeDir = this.resolveLocaleDir(localesRoot, locale);
const namespacePath = this.resolveNamespaceFile(
localeDir,
input.namespace,
);
if (await this.deps.fileExists(namespacePath)) {
await this.deps.unlink(namespacePath);
filesRemoved.push(namespacePath);
}
}
if (filesRemoved.length === 0) {
throw new Error(`Namespace "${input.namespace}" does not exist`);
}
return {
success: true,
namespace: input.namespace,
files_removed: filesRemoved,
};
}
async removeLocale(
input: KitTranslationsRemoveLocaleInput,
): Promise<Omit<KitTranslationsRemoveLocaleSuccess, 'ok'>> {
const localesRoot = this.getLocalesRoot();
assertSinglePathSegment('locale', input.locale);
const localeDir = this.resolveLocaleDir(localesRoot, input.locale);
if (!(await this.isDirectory(localeDir))) {
throw new Error(`Locale "${input.locale}" does not exist`);
}
const locales = await this.getLocaleDirectories(localesRoot);
const baseLocale = locales[0];
if (input.locale === baseLocale) {
throw new Error(`Cannot remove base locale "${input.locale}"`);
}
await this.deps.rmdir(localeDir);
return {
success: true,
locale: input.locale,
path_removed: localeDir,
};
}
private async getLocaleDirectories(localesRoot: string) {
if (!(await this.deps.fileExists(localesRoot))) {
return [];
}
const entries = await this.deps.readdir(localesRoot);
const locales: string[] = [];
for (const entry of entries) {
const fullPath = path.join(localesRoot, entry);
if (await this.isDirectory(fullPath)) {
locales.push(entry);
}
}
return locales.sort();
}
private async isDirectory(targetPath: string) {
try {
const stats = await this.deps.stat(targetPath);
return stats.isDirectory();
} catch {
return false;
}
}
private async readFlatTranslations(filePath: string) {
try {
const content = await this.deps.readFile(filePath);
const parsed = this.parseJson(content, filePath);
return flattenTranslations(parsed);
} catch {
return {};
}
}
private parseJson(content: string, filePath: string) {
try {
return JSON.parse(content) as Record<string, unknown>;
} catch {
throw new Error(`Invalid JSON in ${filePath}`);
}
}
private resolveLocaleDir(localesRoot: string, locale: string) {
const resolved = path.resolve(localesRoot, locale);
return ensureInsideRoot(resolved, localesRoot, locale);
}
private resolveNamespaceFile(localeDir: string, namespace: string) {
const resolved = path.resolve(localeDir, `${namespace}.json`);
return ensureInsideRoot(resolved, localeDir, namespace);
}
private getLocalesRoot() {
return path.resolve(this.deps.rootPath, 'apps', 'web', 'public', 'locales');
}
}
function ensureInsideRoot(resolved: string, root: string, input: string) {
const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
if (!resolved.startsWith(rootWithSep) && resolved !== root) {
throw new Error(
`Invalid path: "${input}" resolves outside the locales root`,
);
}
return resolved;
}
function flattenTranslations(
obj: Record<string, unknown>,
prefix = '',
result: Record<string, string> = {},
) {
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
result[newKey] = value;
continue;
}
if (value && typeof value === 'object') {
flattenTranslations(value as Record<string, unknown>, newKey, result);
continue;
}
if (value !== undefined) {
result[newKey] = String(value);
}
}
return result;
}
function setNestedValue(
target: Record<string, unknown>,
keys: string[],
value: string,
) {
let current = target;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]!;
const next = current[key];
if (!next || typeof next !== 'object') {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
current[keys[keys.length - 1]!] = value;
}
function assertSinglePathSegment(name: string, value: string) {
if (value === '.' || value === '..') {
throw new Error(`${name} must be a valid path segment`);
}
if (value.includes('..')) {
throw new Error(`${name} must not contain ".."`);
}
if (value.includes('/') || value.includes('\\')) {
throw new Error(`${name} must not include path separators`);
}
if (value.includes('\0')) {
throw new Error(`${name} must not contain null bytes`);
}
}
export function createKitTranslationsDeps(
rootPath = process.cwd(),
): KitTranslationsDeps {
return {
rootPath,
async readFile(filePath: string) {
const fs = await import('node:fs/promises');
return fs.readFile(filePath, 'utf8');
},
async writeFile(filePath: string, content: string) {
const fs = await import('node:fs/promises');
await fs.writeFile(filePath, content, 'utf8');
},
async readdir(dirPath: string) {
const fs = await import('node:fs/promises');
return fs.readdir(dirPath);
},
async stat(pathname: string) {
const fs = await import('node:fs/promises');
return fs.stat(pathname);
},
async fileExists(filePath: string) {
const fs = await import('node:fs/promises');
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
},
async mkdir(dirPath: string) {
const fs = await import('node:fs/promises');
await fs.mkdir(dirPath, { recursive: true });
},
async unlink(filePath: string) {
const fs = await import('node:fs/promises');
await fs.unlink(filePath);
},
async rmdir(dirPath: string) {
const fs = await import('node:fs/promises');
await fs.rm(dirPath, { recursive: true, force: true });
},
};
}

View File

@@ -0,0 +1,183 @@
import { z } from 'zod/v3';
export const KitTranslationsListInputSchema = z.object({});
const FlatTranslationsSchema = z.record(z.string(), z.string());
const NamespaceTranslationsSchema = z.record(
z.string(),
FlatTranslationsSchema,
);
const KitTranslationsListSuccessOutputSchema = z.object({
base_locale: z.string(),
locales: z.array(z.string()),
namespaces: z.array(z.string()),
translations: z.record(z.string(), NamespaceTranslationsSchema),
});
export const KitTranslationsListOutputSchema =
KitTranslationsListSuccessOutputSchema;
export const KitTranslationsUpdateInputSchema = z.object({
locale: z.string().min(1),
namespace: z.string().min(1),
key: z.string().min(1),
value: z.string(),
});
const KitTranslationsUpdateSuccessOutputSchema = z.object({
success: z.boolean(),
file: z.string(),
});
export const KitTranslationsUpdateOutputSchema =
KitTranslationsUpdateSuccessOutputSchema;
export const KitTranslationsStatsInputSchema = z.object({});
const KitTranslationsStatsSuccessOutputSchema = z.object({
base_locale: z.string(),
locale_count: z.number(),
namespace_count: z.number(),
total_keys: z.number(),
coverage: z.record(
z.string(),
z.object({
total: z.number(),
translated: z.number(),
missing: z.number(),
percentage: z.number(),
}),
),
});
export const KitTranslationsStatsOutputSchema =
KitTranslationsStatsSuccessOutputSchema;
export type KitTranslationsListInput = z.infer<
typeof KitTranslationsListInputSchema
>;
export type KitTranslationsListSuccess = z.infer<
typeof KitTranslationsListSuccessOutputSchema
>;
export type KitTranslationsListOutput = z.infer<
typeof KitTranslationsListOutputSchema
>;
export type KitTranslationsUpdateInput = z.infer<
typeof KitTranslationsUpdateInputSchema
>;
export type KitTranslationsUpdateSuccess = z.infer<
typeof KitTranslationsUpdateSuccessOutputSchema
>;
export type KitTranslationsUpdateOutput = z.infer<
typeof KitTranslationsUpdateOutputSchema
>;
export type KitTranslationsStatsInput = z.infer<
typeof KitTranslationsStatsInputSchema
>;
export type KitTranslationsStatsSuccess = z.infer<
typeof KitTranslationsStatsSuccessOutputSchema
>;
export type KitTranslationsStatsOutput = z.infer<
typeof KitTranslationsStatsOutputSchema
>;
// --- Add Namespace ---
export const KitTranslationsAddNamespaceInputSchema = z.object({
namespace: z.string().min(1),
});
const KitTranslationsAddNamespaceSuccessOutputSchema = z.object({
success: z.boolean(),
namespace: z.string(),
files_created: z.array(z.string()),
});
export const KitTranslationsAddNamespaceOutputSchema =
KitTranslationsAddNamespaceSuccessOutputSchema;
export type KitTranslationsAddNamespaceInput = z.infer<
typeof KitTranslationsAddNamespaceInputSchema
>;
export type KitTranslationsAddNamespaceSuccess = z.infer<
typeof KitTranslationsAddNamespaceSuccessOutputSchema
>;
export type KitTranslationsAddNamespaceOutput = z.infer<
typeof KitTranslationsAddNamespaceOutputSchema
>;
// --- Add Locale ---
export const KitTranslationsAddLocaleInputSchema = z.object({
locale: z.string().min(1),
});
const KitTranslationsAddLocaleSuccessOutputSchema = z.object({
success: z.boolean(),
locale: z.string(),
files_created: z.array(z.string()),
});
export const KitTranslationsAddLocaleOutputSchema =
KitTranslationsAddLocaleSuccessOutputSchema;
export type KitTranslationsAddLocaleInput = z.infer<
typeof KitTranslationsAddLocaleInputSchema
>;
export type KitTranslationsAddLocaleSuccess = z.infer<
typeof KitTranslationsAddLocaleSuccessOutputSchema
>;
export type KitTranslationsAddLocaleOutput = z.infer<
typeof KitTranslationsAddLocaleOutputSchema
>;
// --- Remove Namespace ---
export const KitTranslationsRemoveNamespaceInputSchema = z.object({
namespace: z.string().min(1),
});
const KitTranslationsRemoveNamespaceSuccessOutputSchema = z.object({
success: z.boolean(),
namespace: z.string(),
files_removed: z.array(z.string()),
});
export const KitTranslationsRemoveNamespaceOutputSchema =
KitTranslationsRemoveNamespaceSuccessOutputSchema;
export type KitTranslationsRemoveNamespaceInput = z.infer<
typeof KitTranslationsRemoveNamespaceInputSchema
>;
export type KitTranslationsRemoveNamespaceSuccess = z.infer<
typeof KitTranslationsRemoveNamespaceSuccessOutputSchema
>;
export type KitTranslationsRemoveNamespaceOutput = z.infer<
typeof KitTranslationsRemoveNamespaceOutputSchema
>;
// --- Remove Locale ---
export const KitTranslationsRemoveLocaleInputSchema = z.object({
locale: z.string().min(1),
});
const KitTranslationsRemoveLocaleSuccessOutputSchema = z.object({
success: z.boolean(),
locale: z.string(),
path_removed: z.string(),
});
export const KitTranslationsRemoveLocaleOutputSchema =
KitTranslationsRemoveLocaleSuccessOutputSchema;
export type KitTranslationsRemoveLocaleInput = z.infer<
typeof KitTranslationsRemoveLocaleInputSchema
>;
export type KitTranslationsRemoveLocaleSuccess = z.infer<
typeof KitTranslationsRemoveLocaleSuccessOutputSchema
>;
export type KitTranslationsRemoveLocaleOutput = z.infer<
typeof KitTranslationsRemoveLocaleOutputSchema
>;

View File

@@ -1,14 +1,15 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"outDir": "./build",
"noEmit": false,
"strict": false,
"target": "ES2022",
"target": "ES2024",
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx",
"module": "nodenext",
"moduleResolution": "nodenext"
},
"include": ["src"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/**/__tests__"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig((options) => ({
entry: ['src/index.ts'],
outDir: 'build',
target: 'es2024',
dts: false,
clean: true,
format: ['cjs'],
...options,
}));

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
globals: true,
},
});

View File

@@ -7,12 +7,26 @@ const MONITORING_PROVIDERS = [
] as const;
export const MONITORING_PROVIDER = z
.enum(MONITORING_PROVIDERS)
.enum(MONITORING_PROVIDERS, {
errorMap: () => ({ message: 'Invalid monitoring provider' }),
})
.optional()
.transform((value) => value || undefined);
export type MonitoringProvider = z.infer<typeof MONITORING_PROVIDER>;
export function getMonitoringProvider() {
return MONITORING_PROVIDER.parse(process.env.NEXT_PUBLIC_MONITORING_PROVIDER);
const provider = MONITORING_PROVIDER.safeParse(
process.env.NEXT_PUBLIC_MONITORING_PROVIDER,
);
if (!provider.success) {
console.error(
`Error: Invalid monitoring provider\n\n${provider.error.message}.\n\nWill fallback to console service.\nPlease review the variable NEXT_PUBLIC_MONITORING_PROVIDER`,
);
return;
}
return provider.data;
}

View File

@@ -20,10 +20,11 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:"
},
"dependencies": {
"pino": "^10.3.0"
"pino": "catalog:"
},
"typesVersions": {
"*": {

View File

@@ -28,6 +28,7 @@
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "^8.21.3",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"class-variance-authority": "^0.7.1",
@@ -36,7 +37,7 @@
"next": "catalog:",
"next-themes": "0.4.6",
"prettier": "^3.8.1",
"react-day-picker": "^9.13.0",
"react-day-picker": "^9.13.2",
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"sonner": "^2.0.7",