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:
committed by
GitHub
parent
059408a70a
commit
f3ac595d06
22
packages/email-templates/AGENTS.md
Normal file
22
packages/email-templates/AGENTS.md
Normal 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.
|
||||
1
packages/email-templates/CLAUDE.md
Normal file
1
packages/email-templates/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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": {
|
||||
"*": {
|
||||
|
||||
39
packages/email-templates/src/registry.ts
Normal file
39
packages/email-templates/src/registry.ts
Normal 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];
|
||||
@@ -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,
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function initializeI18nClient(
|
||||
.init(
|
||||
{
|
||||
...settings,
|
||||
showSupportNotice: false,
|
||||
detection: {
|
||||
order: ['cookie', 'htmlTag', 'navigator'],
|
||||
caches: ['cookie'],
|
||||
|
||||
16
packages/mcp-server/AGENTS.md
Normal file
16
packages/mcp-server/AGENTS.md
Normal 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.
|
||||
1
packages/mcp-server/CLAUDE.md
Normal file
1
packages/mcp-server/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
365
packages/mcp-server/src/tools/db/index.ts
Normal file
365
packages/mcp-server/src/tools/db/index.ts
Normal 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';
|
||||
505
packages/mcp-server/src/tools/db/kit-db.service.ts
Normal file
505
packages/mcp-server/src/tools/db/kit-db.service.ts
Normal 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, '');
|
||||
}
|
||||
53
packages/mcp-server/src/tools/db/schema.ts
Normal file
53
packages/mcp-server/src/tools/db/schema.ts
Normal 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>;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
122
packages/mcp-server/src/tools/deps-upgrade-advisor/index.ts
Normal file
122
packages/mcp-server/src/tools/deps-upgrade-advisor/index.ts
Normal 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';
|
||||
52
packages/mcp-server/src/tools/deps-upgrade-advisor/schema.ts
Normal file
52
packages/mcp-server/src/tools/deps-upgrade-advisor/schema.ts
Normal 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
|
||||
>;
|
||||
1016
packages/mcp-server/src/tools/dev/__tests__/kit-dev.service.test.ts
Normal file
1016
packages/mcp-server/src/tools/dev/__tests__/kit-dev.service.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
494
packages/mcp-server/src/tools/dev/index.ts
Normal file
494
packages/mcp-server/src/tools/dev/index.ts
Normal 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';
|
||||
723
packages/mcp-server/src/tools/dev/kit-dev.service.ts
Normal file
723
packages/mcp-server/src/tools/dev/kit-dev.service.ts
Normal 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];
|
||||
}
|
||||
69
packages/mcp-server/src/tools/dev/schema.ts
Normal file
69
packages/mcp-server/src/tools/dev/schema.ts
Normal 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
|
||||
>;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
109
packages/mcp-server/src/tools/emails/index.ts
Normal file
109
packages/mcp-server/src/tools/emails/index.ts
Normal 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';
|
||||
289
packages/mcp-server/src/tools/emails/kit-emails.service.ts
Normal file
289
packages/mcp-server/src/tools/emails/kit-emails.service.ts
Normal 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}"`);
|
||||
},
|
||||
};
|
||||
}
|
||||
46
packages/mcp-server/src/tools/emails/schema.ts
Normal file
46
packages/mcp-server/src/tools/emails/schema.ts
Normal 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>;
|
||||
845
packages/mcp-server/src/tools/env/__tests__/kit-env.service.test.ts
vendored
Normal file
845
packages/mcp-server/src/tools/env/__tests__/kit-env.service.test.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
177
packages/mcp-server/src/tools/env/index.ts
vendored
Normal file
177
packages/mcp-server/src/tools/env/index.ts
vendored
Normal 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';
|
||||
320
packages/mcp-server/src/tools/env/kit-env.service.ts
vendored
Normal file
320
packages/mcp-server/src/tools/env/kit-env.service.ts
vendored
Normal 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
1430
packages/mcp-server/src/tools/env/model.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/mcp-server/src/tools/env/public-api.ts
vendored
Normal file
9
packages/mcp-server/src/tools/env/public-api.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export { envVariables } from './model';
|
||||
export {
|
||||
findWorkspaceRoot,
|
||||
getEnvState,
|
||||
getVariable,
|
||||
processEnvDefinitions,
|
||||
scanMonorepoEnv,
|
||||
} from './scanner';
|
||||
export { createKitEnvDeps, createKitEnvService } from './kit-env.service';
|
||||
480
packages/mcp-server/src/tools/env/scanner.ts
vendored
Normal file
480
packages/mcp-server/src/tools/env/scanner.ts
vendored
Normal 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;
|
||||
}
|
||||
102
packages/mcp-server/src/tools/env/schema.ts
vendored
Normal file
102
packages/mcp-server/src/tools/env/schema.ts
vendored
Normal 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(),
|
||||
});
|
||||
54
packages/mcp-server/src/tools/env/types.ts
vendored
Normal file
54
packages/mcp-server/src/tools/env/types.ts
vendored
Normal 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;
|
||||
}>;
|
||||
};
|
||||
@@ -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".',
|
||||
);
|
||||
});
|
||||
});
|
||||
194
packages/mcp-server/src/tools/mailbox/index.ts
Normal file
194
packages/mcp-server/src/tools/mailbox/index.ts
Normal 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';
|
||||
261
packages/mcp-server/src/tools/mailbox/kit-mailbox.service.ts
Normal file
261
packages/mcp-server/src/tools/mailbox/kit-mailbox.service.ts
Normal 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;
|
||||
}
|
||||
79
packages/mcp-server/src/tools/mailbox/schema.ts
Normal file
79
packages/mcp-server/src/tools/mailbox/schema.ts
Normal 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
|
||||
>;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
191
packages/mcp-server/src/tools/prerequisites/index.ts
Normal file
191
packages/mcp-server/src/tools/prerequisites/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
30
packages/mcp-server/src/tools/prerequisites/schema.ts
Normal file
30
packages/mcp-server/src/tools/prerequisites/schema.ts
Normal 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
|
||||
>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
115
packages/mcp-server/src/tools/run-checks/index.ts
Normal file
115
packages/mcp-server/src/tools/run-checks/index.ts
Normal 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';
|
||||
147
packages/mcp-server/src/tools/run-checks/run-checks.service.ts
Normal file
147
packages/mcp-server/src/tools/run-checks/run-checks.service.ts
Normal 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]`;
|
||||
}
|
||||
40
packages/mcp-server/src/tools/run-checks/schema.ts
Normal file
40
packages/mcp-server/src/tools/run-checks/schema.ts
Normal 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>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
144
packages/mcp-server/src/tools/status/index.ts
Normal file
144
packages/mcp-server/src/tools/status/index.ts
Normal 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';
|
||||
549
packages/mcp-server/src/tools/status/kit-status.service.ts
Normal file
549
packages/mcp-server/src/tools/status/kit-status.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
48
packages/mcp-server/src/tools/status/schema.ts
Normal file
48
packages/mcp-server/src/tools/status/schema.ts
Normal 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>;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
219
packages/mcp-server/src/tools/translations/index.ts
Normal file
219
packages/mcp-server/src/tools/translations/index.ts
Normal 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';
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
183
packages/mcp-server/src/tools/translations/schema.ts
Normal file
183
packages/mcp-server/src/tools/translations/schema.ts
Normal 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
|
||||
>;
|
||||
@@ -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__"]
|
||||
}
|
||||
|
||||
11
packages/mcp-server/tsup.config.ts
Normal file
11
packages/mcp-server/tsup.config.ts
Normal 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,
|
||||
}));
|
||||
8
packages/mcp-server/vitest.config.ts
Normal file
8
packages/mcp-server/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"*": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user