MCP Server 2.0 (#452)

* MCP Server 2.0

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

View File

@@ -55,11 +55,13 @@ create policy "projects_write" on public.projects for all
Use `server-action-builder` skill for detailed patterns.
**Rule: Services are decoupled from interfaces.** The service is pure logic that receives dependencies (database client, etc.) as arguments — it never imports framework-specific modules. The server action is a thin adapter that resolves dependencies and calls the service. This means the same service can be called from a server action, an MCP tool, a CLI command, or a unit test with zero changes.
Create in route's `_lib/server/` directory:
1. **Schema** (`_lib/schemas/feature.schema.ts`)
2. **Service** (`_lib/server/feature.service.ts`)
3. **Actions** (`_lib/server/server-actions.ts`)
2. **Service** (`_lib/server/feature.service.ts`) — pure logic, dependencies injected, testable in isolation
3. **Actions** (`_lib/server/server-actions.ts`) — thin adapter, no business logic
### Phase 3: UI Components
@@ -148,7 +150,9 @@ apps/web/app/home/[account]/projects/
### Server Layer
- [ ] Zod schema in `_lib/schemas/`
- [ ] Service class in `_lib/server/`
- [ ] Service class in `_lib/server/` with dependencies injected (not imported)
- [ ] Service contains all business logic — testable with mock dependencies
- [ ] Server actions are thin adapters — resolve dependencies, call service, handle revalidation
- [ ] Server actions use `enhanceAction`
- [ ] Actions have `auth: true` and `schema` options
- [ ] Logging added for operations

View File

@@ -29,22 +29,24 @@ export type CreateFeatureInput = z.infer<typeof CreateFeatureSchema>;
### Step 2: Create Service Layer
**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool, a CLI command, or a plain unit test.
Create service in `_lib/server/`:
```typescript
// _lib/server/feature.service.ts
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateFeatureInput } from '../schemas/feature.schema';
export function createFeatureService() {
return new FeatureService();
export function createFeatureService(client: SupabaseClient) {
return new FeatureService(client);
}
class FeatureService {
async create(data: CreateFeatureInput) {
const client = getSupabaseServerClient();
constructor(private readonly client: SupabaseClient) {}
const { data: result, error } = await client
async create(data: CreateFeatureInput) {
const { data: result, error } = await this.client
.from('features')
.insert({
name: data.name,
@@ -60,7 +62,11 @@ class FeatureService {
}
```
### Step 3: Create Server Action
The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable (pass a mock client) and reusable (any interface can supply its own client).
### Step 3: Create Server Action (Thin Adapter)
The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business logic lives here.
Create action in `_lib/server/server-actions.ts`:
@@ -69,6 +75,7 @@ Create action in `_lib/server/server-actions.ts`:
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { revalidatePath } from 'next/cache';
import { CreateFeatureSchema } from '../schemas/feature.schema';
@@ -81,7 +88,8 @@ export const createFeatureAction = enhanceAction(
logger.info(ctx, 'Creating feature');
const service = createFeatureService();
const client = getSupabaseServerClient();
const service = createFeatureService(client);
const result = await service.create(data);
logger.info({ ...ctx, featureId: result.id }, 'Feature created');
@@ -99,11 +107,13 @@ export const createFeatureAction = enhanceAction(
## Key Patterns
1. **Schema in separate file** - Reusable between client and server
2. **Service layer** - Business logic isolated from action
3. **Logging** - Always log before and after operations
4. **Revalidation** - Use `revalidatePath` after mutations
5. **Trust RLS** - Don't add manual auth checks (RLS handles it)
1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server action do the same thing, they call the same service function.
2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with stubs and reusable across interfaces.
3. **Schema in separate file** - Reusable between client and server
4. **Logging** - Always log before and after operations
5. **Revalidation** - Use `revalidatePath` after mutations
6. **Trust RLS** - Don't add manual auth checks (RLS handles it)
7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no running infrastructure
## File Structure

View File

@@ -0,0 +1,308 @@
---
name: service-builder
description: Build pure, interface-agnostic services with injected dependencies. Use when creating business logic that must work across server actions, MCP tools, CLI commands, or tests. Invoke with /service-builder.
---
# Service Builder
You are an expert at building pure, testable services that are decoupled from their callers.
## North Star
**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route handler, or a test. The caller is a thin adapter that resolves dependencies and delegates.
## Workflow
When asked to create a service, follow these steps:
### Step 1: Define the Contract
Start with the input/output types. These are plain TypeScript — no framework types.
```typescript
// _lib/schemas/project.schema.ts
import { z } from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(1),
accountId: z.string().uuid(),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export interface Project {
id: string;
name: string;
account_id: string;
created_at: string;
}
```
### Step 2: Build the Service
The service receives all dependencies through its constructor. It never imports framework-specific modules (`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.).
```typescript
// _lib/server/project.service.ts
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateProjectInput, Project } from '../schemas/project.schema';
export function createProjectService(client: SupabaseClient) {
return new ProjectService(client);
}
class ProjectService {
constructor(private readonly client: SupabaseClient) {}
async create(data: CreateProjectInput): Promise<Project> {
const { data: result, error } = await this.client
.from('projects')
.insert({
name: data.name,
account_id: data.accountId,
})
.select()
.single();
if (error) throw error;
return result;
}
async list(accountId: string): Promise<Project[]> {
const { data, error } = await this.client
.from('projects')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
}
async delete(projectId: string): Promise<void> {
const { error } = await this.client
.from('projects')
.delete()
.eq('id', projectId);
if (error) throw error;
}
}
```
### Step 3: Write Thin Adapters
Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific concerns (revalidation, redirects, MCP formatting, CLI output).
**Server Action adapter:**
```typescript
// _lib/server/server-actions.ts
'use server';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { revalidatePath } from 'next/cache';
import { CreateProjectSchema } from '../schemas/project.schema';
import { createProjectService } from './project.service';
export const createProjectAction = enhanceAction(
async function (data, user) {
const logger = await getLogger();
logger.info({ name: 'create-project', userId: user.id }, 'Creating project');
const client = getSupabaseServerClient();
const service = createProjectService(client);
const result = await service.create(data);
revalidatePath('/home/[account]/projects');
return { success: true, data: result };
},
{
auth: true,
schema: CreateProjectSchema,
},
);
```
**Route Handler adapter:**
```typescript
// app/api/projects/route.ts
import { enhanceRouteHandler } from '@kit/next/routes';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { NextResponse } from 'next/server';
import { CreateProjectSchema } from '../_lib/schemas/project.schema';
import { createProjectService } from '../_lib/server/project.service';
export const POST = enhanceRouteHandler(
async function ({ body, user }) {
const client = getSupabaseServerClient();
const service = createProjectService(client);
const result = await service.create(body);
return NextResponse.json(result);
},
{
auth: true,
schema: CreateProjectSchema,
},
);
```
**MCP Tool adapter:**
```typescript
// mcp/tools/kit_project_create.ts
import { createProjectService } from '../../_lib/server/project.service';
export const kit_project_create: McpToolHandler = async (input, context) => {
const client = context.getSupabaseClient();
const service = createProjectService(client);
return service.create(input);
};
```
### Step 4: Write Tests
Because the service accepts dependencies, you can test it with stubs — no running database, no framework runtime.
```typescript
// _lib/server/__tests__/project.service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createProjectService } from '../project.service';
function createMockClient(overrides: Record<string, unknown> = {}) {
const mockChain = {
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { id: 'proj-1', name: 'Test', account_id: 'acc-1', created_at: new Date().toISOString() },
error: null,
}),
delete: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: [], error: null }),
...overrides,
};
return {
from: vi.fn(() => mockChain),
mockChain,
} as unknown as SupabaseClient;
}
describe('ProjectService', () => {
it('creates a project', async () => {
const client = createMockClient();
const service = createProjectService(client);
const result = await service.create({
name: 'Test Project',
accountId: 'acc-1',
});
expect(result.id).toBe('proj-1');
expect(client.from).toHaveBeenCalledWith('projects');
});
it('throws on database error', async () => {
const client = createMockClient({
single: vi.fn().mockResolvedValue({
data: null,
error: { message: 'unique violation' },
}),
});
const service = createProjectService(client);
await expect(
service.create({ name: 'Dup', accountId: 'acc-1' }),
).rejects.toEqual({ message: 'unique violation' });
});
});
```
## Rules
1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/`Response`, no MCP context, no `FormData`.
2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service.
3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`. An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters.
4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating logic is a violation.
5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service, refactor until you don't.
## What Goes Where
| Concern | Location | Example |
|---------|----------|---------|
| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` |
| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` |
| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper |
| Logging | Adapter | `logger.info()` before/after service call |
| Cache revalidation | Adapter | `revalidatePath()` after mutation |
| Redirect | Adapter | `redirect()` after creation |
| MCP response format | Adapter | Return service result as MCP content |
## File Structure
```
feature/
├── _lib/
│ ├── schemas/
│ │ └── feature.schema.ts # Zod schemas + TS types
│ └── server/
│ ├── feature.service.ts # Pure service (dependencies injected)
│ ├── server-actions.ts # Server action adapters
│ └── __tests__/
│ └── feature.service.test.ts # Unit tests with mock client
└── _components/
└── feature-form.tsx
```
## Anti-Patterns
```typescript
// ❌ BAD: Service imports framework-specific client
class ProjectService {
async create(data: CreateProjectInput) {
const client = getSupabaseServerClient(); // coupling!
// ...
}
}
// ❌ BAD: Business logic in the adapter
export const createProjectAction = enhanceAction(
async function (data, user) {
const client = getSupabaseServerClient();
// Business logic directly in the action — not reusable
if (data.name.length > 100) throw new Error('Name too long');
const { data: result } = await client.from('projects').insert(data);
return result;
},
{ auth: true, schema: CreateProjectSchema },
);
// ❌ BAD: Two interfaces duplicate the same logic
// server-actions.ts
const result = await client.from('projects').insert(...).select().single();
// mcp-tool.ts
const result = await client.from('projects').insert(...).select().single();
// Should be: both call projectService.create()
```
## Reference
See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose other services, and testing strategies.

View File

@@ -0,0 +1,273 @@
# Service Builder Examples
## Service with Multiple Dependencies
When a service needs more than just a database client, inject all dependencies.
```typescript
// _lib/server/invoice.service.ts
import type { SupabaseClient } from '@supabase/supabase-js';
interface InvoiceServiceDeps {
client: SupabaseClient;
storage: SupabaseClient['storage'];
}
export function createInvoiceService(deps: InvoiceServiceDeps) {
return new InvoiceService(deps);
}
class InvoiceService {
constructor(private readonly deps: InvoiceServiceDeps) {}
async generatePdf(invoiceId: string): Promise<{ url: string }> {
const { data: invoice, error } = await this.deps.client
.from('invoices')
.select('*')
.eq('id', invoiceId)
.single();
if (error) throw error;
const pdf = this.buildPdf(invoice);
const { data: upload, error: uploadError } = await this.deps.storage
.from('invoices')
.upload(`${invoiceId}.pdf`, pdf);
if (uploadError) throw uploadError;
return { url: upload.path };
}
private buildPdf(invoice: Record<string, unknown>): Uint8Array {
// Pure logic — no I/O
// ...
return new Uint8Array();
}
}
```
**Server action adapter:**
```typescript
'use server';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { GenerateInvoiceSchema } from '../schemas/invoice.schema';
import { createInvoiceService } from './invoice.service';
export const generateInvoicePdfAction = enhanceAction(
async function (data, user) {
const client = getSupabaseServerClient();
const service = createInvoiceService({
client,
storage: client.storage,
});
return service.generatePdf(data.invoiceId);
},
{ auth: true, schema: GenerateInvoiceSchema },
);
```
## Service Composing Other Services
Services can depend on other services — compose at the adapter level.
```typescript
// _lib/server/onboarding.service.ts
import type { ProjectService } from './project.service';
import type { NotificationService } from './notification.service';
interface OnboardingServiceDeps {
projects: ProjectService;
notifications: NotificationService;
}
export function createOnboardingService(deps: OnboardingServiceDeps) {
return new OnboardingService(deps);
}
class OnboardingService {
constructor(private readonly deps: OnboardingServiceDeps) {}
async onboardAccount(params: { accountId: string; accountName: string }) {
// Create default project
const project = await this.deps.projects.create({
name: `${params.accountName}'s First Project`,
accountId: params.accountId,
});
// Send welcome notification
await this.deps.notifications.send({
accountId: params.accountId,
type: 'welcome',
data: { projectId: project.id },
});
return { project };
}
}
```
**Adapter composes the dependency tree:**
```typescript
'use server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createOnboardingService } from './onboarding.service';
import { createProjectService } from './project.service';
import { createNotificationService } from './notification.service';
export const onboardAccountAction = enhanceAction(
async function (data, user) {
const client = getSupabaseServerClient();
const service = createOnboardingService({
projects: createProjectService(client),
notifications: createNotificationService(client),
});
return service.onboardAccount(data);
},
{ auth: true, schema: OnboardAccountSchema },
);
```
## Pure Validation Service (No I/O)
Some services are entirely pure — they don't even need a database client.
```typescript
// _lib/server/pricing.service.ts
interface PricingInput {
plan: 'starter' | 'pro' | 'enterprise';
seats: number;
billingPeriod: 'monthly' | 'yearly';
}
interface PricingResult {
unitPrice: number;
total: number;
discount: number;
currency: string;
}
export function calculatePricing(input: PricingInput): PricingResult {
const basePrices = { starter: 900, pro: 2900, enterprise: 9900 };
const unitPrice = basePrices[input.plan];
const yearlyDiscount = input.billingPeriod === 'yearly' ? 0.2 : 0;
const seatDiscount = input.seats >= 10 ? 0.1 : 0;
const discount = Math.min(yearlyDiscount + seatDiscount, 0.3);
const total = Math.round(unitPrice * input.seats * (1 - discount));
return { unitPrice, total, discount, currency: 'usd' };
}
```
This is the simplest case — a plain function, no class, no dependencies. Trivially testable:
```typescript
import { calculatePricing } from '../pricing.service';
it('applies yearly discount', () => {
const result = calculatePricing({
plan: 'pro',
seats: 1,
billingPeriod: 'yearly',
});
expect(result.discount).toBe(0.2);
expect(result.total).toBe(2320); // 2900 * 0.8
});
```
## Testing with Mock Client
Full mock pattern for Supabase client:
```typescript
import { vi } from 'vitest';
import type { SupabaseClient } from '@supabase/supabase-js';
/**
* Creates a chainable mock that mimics Supabase's query builder.
* Override any method in the chain via the `overrides` param.
*/
export function createMockSupabaseClient(
resolvedValue: { data: unknown; error: unknown } = { data: null, error: null },
overrides: Record<string, unknown> = {},
) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
// Every method returns `this` (chainable) by default
const methods = [
'select', 'insert', 'update', 'upsert', 'delete',
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in',
'like', 'ilike', 'is', 'order', 'limit', 'range',
'single', 'maybeSingle',
];
for (const method of methods) {
chain[method] = vi.fn().mockReturnThis();
}
// Terminal methods resolve with data
chain.single = vi.fn().mockResolvedValue(resolvedValue);
chain.maybeSingle = vi.fn().mockResolvedValue(resolvedValue);
// Apply overrides
for (const [key, value] of Object.entries(overrides)) {
chain[key] = vi.fn().mockImplementation(
typeof value === 'function' ? value : () => value,
);
}
// Non-terminal chains that don't end with single/maybeSingle
// resolve when awaited via .then()
const proxyHandler: ProxyHandler<typeof chain> = {
get(target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(resolvedValue);
}
return target[prop as string] ?? vi.fn().mockReturnValue(target);
},
};
const chainProxy = new Proxy(chain, proxyHandler);
return {
from: vi.fn(() => chainProxy),
chain,
} as unknown as SupabaseClient & { chain: typeof chain };
}
```
Usage:
```typescript
import { createMockSupabaseClient } from '../test-utils';
import { createProjectService } from '../project.service';
it('lists projects for an account', async () => {
const projects = [
{ id: '1', name: 'Alpha', account_id: 'acc-1' },
{ id: '2', name: 'Beta', account_id: 'acc-1' },
];
const client = createMockSupabaseClient({ data: projects, error: null });
const service = createProjectService(client);
const result = await service.list('acc-1');
expect(result).toEqual(projects);
expect(client.from).toHaveBeenCalledWith('projects');
expect(client.chain.eq).toHaveBeenCalledWith('account_id', 'acc-1');
});
```

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ yarn-error.log*
# ts-node cache
node-compile-cache/
# prds
.prds/

View File

@@ -3,7 +3,7 @@
"makerkit": {
"type": "stdio",
"command": "node",
"args": ["packages/mcp-server/build/index.js"]
"args": ["packages/mcp-server/build/index.cjs"]
}
}
}

View File

@@ -4,9 +4,9 @@ import { DatabaseToolsInterface } from './_components/database-tools-interface';
import { loadDatabaseToolsData } from './_lib/server/database-tools.loader';
interface DatabasePageProps {
searchParams: {
searchParams: Promise<{
search?: string;
};
}>;
}
export const metadata: Metadata = {
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
};
async function DatabasePage({ searchParams }: DatabasePageProps) {
const searchTerm = searchParams.search || '';
const searchTerm = (await searchParams).search || '';
// Load all database data server-side
const databaseData = await loadDatabaseToolsData();

View File

@@ -1,10 +1,12 @@
import { EmailTesterForm } from '@/app/emails/[id]/components/email-tester-form';
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
import { getVariable } from '@/app/variables/lib/env-scanner';
import { EnvMode } from '@/app/variables/lib/types';
import { EnvModeSelector } from '@/components/env-mode-selector';
import { IFrame } from '@/components/iframe';
import {
createKitEmailsDeps,
createKitEmailsService,
} from '@kit/mcp-server/emails';
import { findWorkspaceRoot, getVariable } from '@kit/mcp-server/env';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import {
@@ -15,6 +17,8 @@ import {
} from '@kit/ui/dialog';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
type EnvMode = 'development' | 'production';
type EmailPageProps = React.PropsWithChildren<{
params: Promise<{
id: string;
@@ -31,25 +35,28 @@ export default async function EmailPage(props: EmailPageProps) {
const { id } = await props.params;
const mode = (await props.searchParams).mode ?? 'development';
const template = await loadEmailTemplate(id);
const emailSettings = await getEmailSettings(mode);
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
const values: Record<string, string> = {
emails: 'Emails',
'invite-email': 'Invite Email',
'account-delete-email': 'Account Delete Email',
'confirm-email': 'Confirm Email',
'change-email-address-email': 'Change Email Address Email',
'reset-password-email': 'Reset Password Email',
'magic-link-email': 'Magic Link Email',
'otp-email': 'OTP Email',
};
const [result, { templates }, emailSettings] = await Promise.all([
service.read({ id }),
service.list(),
getEmailSettings(mode),
]);
const html = result.renderedHtml ?? result.source;
const values: Record<string, string> = { emails: 'Emails' };
for (const t of templates) {
values[t.id] = t.name;
}
return (
<Page style={'custom'}>
<PageHeader
displaySidebarTrigger={false}
title={values[id]}
title={values[id] ?? id}
description={<AppBreadcrumbs values={values} />}
>
<EnvModeSelector mode={mode} />
@@ -77,7 +84,7 @@ export default async function EmailPage(props: EmailPageProps) {
<IFrame className={'flex flex-1 flex-col'}>
<div
className={'flex flex-1 flex-col'}
dangerouslySetInnerHTML={{ __html: template.html }}
dangerouslySetInnerHTML={{ __html: html }}
/>
</IFrame>
</PageBody>

View File

@@ -1,60 +0,0 @@
import {
renderAccountDeleteEmail,
renderInviteEmail,
renderOtpEmail,
} from '@kit/email-templates';
export async function loadEmailTemplate(id: string) {
switch (id) {
case 'account-delete-email':
return renderAccountDeleteEmail({
productName: 'Makerkit',
userDisplayName: 'Giancarlo',
});
case 'invite-email':
return renderInviteEmail({
teamName: 'Makerkit',
teamLogo: '',
inviter: 'Giancarlo',
invitedUserEmail: 'test@makerkit.dev',
link: 'https://makerkit.dev',
productName: 'Makerkit',
});
case 'otp-email':
return renderOtpEmail({
productName: 'Makerkit',
otp: '123456',
});
case 'magic-link-email':
return loadFromFileSystem('magic-link');
case 'reset-password-email':
return loadFromFileSystem('reset-password');
case 'change-email-address-email':
return loadFromFileSystem('change-email-address');
case 'confirm-email':
return loadFromFileSystem('confirm-email');
default:
throw new Error(`Email template not found: ${id}`);
}
}
async function loadFromFileSystem(fileName: string) {
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const filePath = join(
process.cwd(),
`../web/supabase/templates/${fileName}.html`,
);
return {
html: readFileSync(filePath, 'utf8'),
};
}

View File

@@ -1,6 +1,10 @@
'use server';
import { loadEmailTemplate } from '@/app/emails/lib/email-loader';
import {
createKitEmailsDeps,
createKitEmailsService,
} from '@kit/mcp-server/emails';
import { findWorkspaceRoot } from '@kit/mcp-server/env';
export async function sendEmailAction(params: {
template: string;
@@ -27,7 +31,10 @@ export async function sendEmailAction(params: {
},
});
const { html } = await loadEmailTemplate(params.template);
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
const result = await service.read({ id: params.template });
const html = result.renderedHtml ?? result.source;
return transporter.sendMail({
html,

View File

@@ -1,5 +1,10 @@
import Link from 'next/link';
import {
createKitEmailsDeps,
createKitEmailsService,
} from '@kit/mcp-server/emails';
import { findWorkspaceRoot } from '@kit/mcp-server/env';
import {
CardButton,
CardButtonHeader,
@@ -12,7 +17,16 @@ export const metadata = {
title: 'Emails',
};
const CATEGORY_LABELS: Record<string, string> = {
'supabase-auth': 'Supabase Auth Emails',
transactional: 'Transactional Emails',
};
export default async function EmailsPage() {
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
const { templates, categories } = await service.list();
return (
<Page style={'custom'}>
<PageHeader
@@ -22,73 +36,31 @@ export default async function EmailsPage() {
/>
<PageBody className={'gap-y-8'}>
<div className={'flex flex-col space-y-4'}>
<Heading level={5}>Supabase Auth Emails</Heading>
{categories.map((category) => {
const categoryTemplates = templates.filter(
(t) => t.category === category,
);
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
<CardButton asChild>
<Link href={'/emails/confirm-email'}>
<CardButtonHeader>
<CardButtonTitle>Confirm Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
return (
<div key={category} className={'flex flex-col space-y-4'}>
<Heading level={5}>
{CATEGORY_LABELS[category] ?? category}
</Heading>
<CardButton asChild>
<Link href={'/emails/change-email-address-email'}>
<CardButtonHeader>
<CardButtonTitle>Change Email Address Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/reset-password-email'}>
<CardButtonHeader>
<CardButtonTitle>Reset Password Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/magic-link-email'}>
<CardButtonHeader>
<CardButtonTitle>Magic Link Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
</div>
</div>
<div className={'flex flex-col space-y-4'}>
<Heading level={5}>Transactional Emails</Heading>
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
<CardButton asChild>
<Link href={'/emails/account-delete-email'}>
<CardButtonHeader>
<CardButtonTitle>Account Delete Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/invite-email'}>
<CardButtonHeader>
<CardButtonTitle>Invite Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
<CardButton asChild>
<Link href={'/emails/otp-email'}>
<CardButtonHeader>
<CardButtonTitle>OTP Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
</div>
</div>
<div className={'grid grid-cols-1 gap-4 md:grid-cols-4'}>
{categoryTemplates.map((template) => (
<CardButton key={template.id} asChild>
<Link href={`/emails/${template.id}`}>
<CardButtonHeader>
<CardButtonTitle>{template.name}</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
))}
</div>
</div>
);
})}
</PageBody>
</Page>
);

View File

@@ -1,228 +0,0 @@
import { EnvMode } from '@/app/variables/lib/types';
import { getVariable } from '../variables/lib/env-scanner';
export function createConnectivityService(mode: EnvMode) {
return new ConnectivityService(mode);
}
class ConnectivityService {
constructor(private mode: EnvMode = 'development') {}
async checkSupabaseConnectivity() {
const url = await getVariable('NEXT_PUBLIC_SUPABASE_URL', this.mode);
if (!url) {
return {
status: 'error' as const,
message: 'No Supabase URL found in environment variables',
};
}
const anonKey =
(await getVariable('NEXT_PUBLIC_SUPABASE_ANON_KEY', this.mode)) ||
(await getVariable('NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', this.mode));
if (!anonKey) {
return {
status: 'error' as const,
message: 'No Supabase Anon Key found in environment variables',
};
}
try {
const response = await fetch(`${url}/auth/v1/health`, {
headers: {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`,
},
});
if (!response.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Supabase. The Supabase Anon Key or URL is not valid.',
};
}
return {
status: 'success' as const,
message: 'Connected to Supabase',
};
} catch (error) {
return {
status: 'error' as const,
message: `Failed to connect to Supabase. ${error}`,
};
}
}
async checkSupabaseAdminConnectivity() {
const url = await getVariable('NEXT_PUBLIC_SUPABASE_URL', this.mode);
if (!url) {
return {
status: 'error' as const,
message: 'No Supabase URL found in environment variables',
};
}
const endpoint = `${url}/rest/v1/accounts`;
const apikey =
(await getVariable('NEXT_PUBLIC_SUPABASE_ANON_KEY', this.mode)) ||
(await getVariable('NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', this.mode));
if (!apikey) {
return {
status: 'error' as const,
message: 'No Supabase Anon Key found in environment variables',
};
}
const adminKey =
(await getVariable('SUPABASE_SERVICE_ROLE_KEY', this.mode)) ||
(await getVariable('SUPABASE_SECRET_KEY', this.mode));
if (!adminKey) {
return {
status: 'error' as const,
message: 'No Supabase Service Role Key found in environment variables',
};
}
try {
const response = await fetch(endpoint, {
headers: {
apikey: adminKey,
},
});
if (!response.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Supabase Admin. The Supabase Service Role Key is not valid.',
};
}
const data = await response.json();
if (data.length === 0) {
return {
status: 'error' as const,
message:
'No accounts found in Supabase Admin. The data may not be seeded. Please run `pnpm run supabase:web:reset` to reset the database.',
};
}
return {
status: 'success' as const,
message: 'Connected to Supabase Admin',
};
} catch (error) {
return {
status: 'error' as const,
message: `Failed to connect to Supabase Admin. ${error}`,
};
}
}
async checkStripeWebhookEndpoints() {
const secretKey = await getVariable('STRIPE_SECRET_KEY', this.mode);
if (!secretKey) {
return {
status: 'error' as const,
message: 'No Stripe Secret Key found in environment variables',
};
}
const webhooksSecret = await getVariable(
'STRIPE_WEBHOOK_SECRET',
this.mode,
);
if (!webhooksSecret) {
return {
status: 'error' as const,
message: 'No Webhooks secret found in environment variables',
};
}
const url = `https://api.stripe.com`;
const request = await fetch(`${url}/v1/webhook_endpoints`, {
headers: {
Authorization: `Bearer ${secretKey}`,
},
});
if (!request.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Stripe. The Stripe Webhook Secret is not valid.',
};
}
const webhooksResponse = await request.json();
const webhooks = webhooksResponse.data ?? [];
if (webhooks.length === 0) {
return {
status: 'error' as const,
message: 'No webhooks found in Stripe',
};
}
const allWebhooksShareTheSameSecret = webhooks.every(
(webhook: { secret: string }) => webhook.secret === webhooksSecret,
);
if (!allWebhooksShareTheSameSecret) {
return {
status: 'error' as const,
message: 'All webhooks do not share the same secret',
};
}
return {
status: 'success' as const,
message: 'All webhooks share the same Webhooks secret',
};
}
async checkStripeConnected() {
const secretKey = await getVariable('STRIPE_SECRET_KEY', this.mode);
if (!secretKey) {
return {
status: 'error' as const,
message: 'No Stripe Secret Key found in environment variables',
};
}
const url = `https://api.stripe.com`;
const request = await fetch(`${url}/v1/prices`, {
headers: {
Authorization: `Bearer ${secretKey}`,
},
});
if (!request.ok) {
return {
status: 'error' as const,
message:
'Failed to connect to Stripe. The Stripe Secret Key is not valid.',
};
}
return {
status: 'success' as const,
message: 'Connected to Stripe',
};
}
}

View File

@@ -0,0 +1,154 @@
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/mcp-server/prerequisites';
const execFileAsync = promisify(execFile);
export async function loadDashboardKitPrerequisites() {
const rootPath = await findWorkspaceRoot(process.cwd());
const service = createKitPrerequisitesService(
createKitPrerequisitesDeps(rootPath),
);
return service.check({});
}
function createKitPrerequisitesDeps(rootPath: string): KitPrerequisitesDeps {
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) {
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 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;
}
}
async function findWorkspaceRoot(startPath: string) {
let current = startPath;
for (let depth = 0; depth < 6; depth++) {
const workspaceManifest = join(current, 'pnpm-workspace.yaml');
try {
await access(workspaceManifest);
return current;
} catch {
// Continue to parent path.
}
const parent = join(current, '..');
if (parent === current) {
break;
}
current = parent;
}
return startPath;
}

View File

@@ -0,0 +1,116 @@
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/mcp-server/status';
const execFileAsync = promisify(execFile);
export async function loadDashboardKitStatus() {
const rootPath = await findWorkspaceRoot(process.cwd());
const service = createKitStatusService(createKitStatusDeps(rootPath));
return service.getStatus({});
}
function createKitStatusDeps(rootPath: string): KitStatusDeps {
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 findWorkspaceRoot(startPath: string) {
let current = startPath;
for (let depth = 0; depth < 6; depth++) {
const workspaceManifest = join(current, 'pnpm-workspace.yaml');
try {
await access(workspaceManifest);
return current;
} catch {
// Continue to parent path.
}
const parent = join(current, '..');
if (parent === current) {
break;
}
current = parent;
}
return startPath;
}
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');
});
}

View File

@@ -1,13 +0,0 @@
import { loadPRDs } from '../_lib/server/prd-loader';
import { McpServerTabs } from './mcp-server-tabs';
import { PRDManagerClient } from './prd-manager-client';
export async function McpServerInterface() {
const initialPrds = await loadPRDs();
return (
<McpServerTabs
prdManagerContent={<PRDManagerClient initialPrds={initialPrds} />}
/>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import { DatabaseIcon, FileTextIcon } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
interface McpServerTabsProps {
prdManagerContent: React.ReactNode;
databaseToolsContent?: React.ReactNode;
}
export function McpServerTabs({
prdManagerContent,
databaseToolsContent,
}: McpServerTabsProps) {
return (
<div className="h-full">
<Tabs defaultValue="database-tools" className="flex h-full flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger
value="database-tools"
className="flex items-center gap-2"
>
<DatabaseIcon className="h-4 w-4" />
Database Tools
</TabsTrigger>
<TabsTrigger value="prd-manager" className="flex items-center gap-2">
<FileTextIcon className="h-4 w-4" />
PRD Manager
</TabsTrigger>
</TabsList>
<TabsContent value="database-tools" className="flex-1 space-y-4">
{databaseToolsContent || (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<DatabaseIcon className="text-muted-foreground mx-auto h-12 w-12" />
<h3 className="mt-4 text-lg font-semibold">Database Tools</h3>
<p className="text-muted-foreground">
Explore database schemas, tables, functions, and enums
</p>
</div>
</div>
)}
</TabsContent>
<TabsContent value="prd-manager" className="flex-1 space-y-4">
{prdManagerContent}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,188 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { CalendarIcon, FileTextIcon, PlusIcon, SearchIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Input } from '@kit/ui/input';
import { Progress } from '@kit/ui/progress';
import type { CreatePRDData } from '../_lib/schemas/create-prd.schema';
import { createPRDAction } from '../_lib/server/prd-server-actions';
import { CreatePRDForm } from './create-prd-form';
interface PRDSummary {
filename: string;
title: string;
lastUpdated: string;
progress: number;
totalStories: number;
completedStories: number;
}
interface PRDManagerClientProps {
initialPrds: PRDSummary[];
}
export function PRDManagerClient({ initialPrds }: PRDManagerClientProps) {
const [prds, setPrds] = useState<PRDSummary[]>(initialPrds);
const [searchTerm, setSearchTerm] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const handleCreatePRD = async (data: CreatePRDData) => {
const result = await createPRDAction(data);
if (result.success && result.data) {
const newPRD: PRDSummary = {
filename: result.data.filename,
title: result.data.title,
lastUpdated: result.data.lastUpdated,
progress: result.data.progress,
totalStories: result.data.totalStories,
completedStories: result.data.completedStories,
};
setPrds((prev) => [...prev, newPRD]);
setShowCreateForm(false);
// Note: In a production app, you might want to trigger a router.refresh()
// to reload the server component and get the most up-to-date data
} else {
// Error handling will be managed by the form component via the action result
throw new Error(result.error || 'Failed to create PRD');
}
};
const filteredPrds = prds.filter(
(prd) =>
prd.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
prd.filename.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="space-y-6">
{/* Header with search and create button */}
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search PRDs by title or filename..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2"
>
<PlusIcon className="h-4 w-4" />
Create New PRD
</Button>
</div>
{/* PRD List */}
{filteredPrds.length === 0 ? (
<Card>
<CardContent className="flex h-32 items-center justify-center">
<div className="text-center">
<FileTextIcon className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2">
{searchTerm ? 'No PRDs match your search' : 'No PRDs found'}
</p>
{!searchTerm && (
<Button
variant="link"
onClick={() => setShowCreateForm(true)}
className="mt-2"
>
Create your first PRD
</Button>
)}
</div>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPrds.map((prd) => (
<Link
key={prd.filename}
href={`/mcp-server/prds/${prd.filename}`}
className="block"
>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader className="pb-3">
<CardTitle className="flex items-start gap-2 text-sm">
<FileTextIcon className="text-muted-foreground mt-0.5 h-4 w-4" />
<span className="line-clamp-2">{prd.title}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Progress */}
<div className="space-y-2">
<div className="text-muted-foreground flex justify-between text-xs">
<span>Progress</span>
<span>
{prd.completedStories}/{prd.totalStories} stories
</span>
</div>
<Progress value={prd.progress} className="h-2" />
<div className="text-right text-xs font-medium">
{prd.progress}%
</div>
</div>
{/* Metadata */}
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<CalendarIcon className="h-3 w-3" />
<span>Updated {prd.lastUpdated}</span>
</div>
{/* Filename */}
<div className="text-muted-foreground text-xs">
{prd.filename}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
{/* Create PRD Form Modal */}
{showCreateForm && (
<Dialog open={showCreateForm} onOpenChange={setShowCreateForm}>
<DialogContent className="max-w-4xl overflow-y-hidden">
<DialogHeader>
<DialogTitle>Create New PRD</DialogTitle>
</DialogHeader>
<div
className="overflow-y-auto p-0.5"
style={{
maxHeight: '800px',
}}
>
<CreatePRDForm onSubmit={handleCreatePRD} />
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@@ -1,106 +0,0 @@
import Link from 'next/link';
import { withI18n } from '@/lib/i18n/with-i18n';
import { DatabaseIcon, FileTextIcon, ServerIcon } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
export const metadata = {
title: 'MCP Server',
description:
'MCP Server development interface for database exploration and PRD management',
};
function McpServerPage() {
return (
<Page style={'custom'}>
<div className={'flex h-screen flex-col overflow-hidden'}>
<PageHeader
displaySidebarTrigger={false}
title={'MCP Server'}
description={
'Access MCP Server tools for database exploration and PRD management.'
}
/>
<PageBody className={'overflow-hidden'}>
<div className={'flex h-full flex-1 flex-col p-6'}>
<div className="space-y-6">
{/* Welcome Section */}
<div className="text-center">
<ServerIcon className="text-muted-foreground mx-auto mb-4 h-16 w-16" />
<h2 className="mb-2 text-2xl font-bold">
Welcome to MCP Server Tools
</h2>
<p className="text-muted-foreground mx-auto max-w-2xl">
Choose from the tools below to explore your database schema or
manage your Product Requirements Documents. Use the sidebar
navigation for quick access to specific tools.
</p>
</div>
{/* Tool Cards */}
<div className="mx-auto grid max-w-4xl gap-6 md:grid-cols-2">
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<DatabaseIcon className="h-6 w-6" />
Database Tools
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Explore database schemas, tables, functions, and enums
through an intuitive interface.
</p>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Browse database schemas and their structure</li>
<li> Explore tables with columns and relationships</li>
<li>
Discover database functions and their parameters
</li>
<li> View enum types and their values</li>
</ul>
<Button asChild className="w-full">
<Link href="/mcp-server/database">
Open Database Tools
</Link>
</Button>
</CardContent>
</Card>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<FileTextIcon className="h-6 w-6" />
PRD Manager
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Create and manage Product Requirements Documents with user
stories and progress tracking.
</p>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Create and edit PRDs with structured templates</li>
<li> Manage user stories with priority tracking</li>
<li> Track progress and project status</li>
<li> Export PRDs to markdown format</li>
</ul>
<Button asChild className="w-full">
<Link href="/mcp-server/prd">Open PRD Manager</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
</PageBody>
</div>
</Page>
);
}
export default withI18n(McpServerPage);

View File

@@ -1,25 +1,37 @@
import { EnvMode } from '@/app/variables/lib/types';
import { EnvModeSelector } from '@/components/env-mode-selector';
import { ServiceCard } from '@/components/status-tile';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { createConnectivityService } from './lib/connectivity-service';
import { loadDashboardKitPrerequisites } from './lib/prerequisites-dashboard.loader';
import { loadDashboardKitStatus } from './lib/status-dashboard.loader';
type DashboardPageProps = React.PropsWithChildren<{
searchParams: Promise<{ mode?: EnvMode }>;
}>;
export default async function DashboardPage() {
const [status, prerequisites] = await Promise.all([
loadDashboardKitStatus(),
loadDashboardKitPrerequisites(),
]);
export default async function DashboardPage(props: DashboardPageProps) {
const mode = (await props.searchParams).mode ?? 'development';
const connectivityService = createConnectivityService(mode);
const failedRequiredCount = prerequisites.prerequisites.filter(
(item) => item.required && item.status === 'fail',
).length;
const [supabaseStatus, supabaseAdminStatus, stripeStatus] = await Promise.all(
[
connectivityService.checkSupabaseConnectivity(),
connectivityService.checkSupabaseAdminConnectivity(),
connectivityService.checkStripeConnected(),
],
const warnCount = prerequisites.prerequisites.filter(
(item) => item.status === 'warn',
).length;
const failedRequired = prerequisites.prerequisites.filter(
(item) => item.required && item.status === 'fail',
);
const prerequisiteRemedies = Array.from(
new Set(
failedRequired.flatMap((item) => [
...(item.remedies ?? []),
...(item.install_command ? [item.install_command] : []),
...(item.install_url ? [item.install_url] : []),
]),
),
);
return (
@@ -27,17 +39,148 @@ export default async function DashboardPage(props: DashboardPageProps) {
<PageHeader
displaySidebarTrigger={false}
title={'Dev Tool'}
description={'Check the status of your Supabase and Stripe services'}
>
<EnvModeSelector mode={mode} />
</PageHeader>
description={'Kit MCP status for this workspace'}
/>
<PageBody className={'space-y-8 py-2'}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<ServiceCard name={'Supabase API'} status={supabaseStatus} />
<ServiceCard name={'Supabase Admin'} status={supabaseAdminStatus} />
<ServiceCard name={'Stripe API'} status={stripeStatus} />
<ServiceCard
name={'Variant'}
status={{
status: 'success',
message: `${status.variant} (${status.variant_family})`,
}}
/>
<ServiceCard
name={'Runtime'}
status={{
status: 'success',
message: `${status.framework} • Node ${status.node_version}${status.package_manager}`,
}}
/>
<ServiceCard
name={'Dependencies'}
status={{
status: status.deps_installed ? 'success' : 'error',
message: status.deps_installed
? 'Dependencies installed'
: 'node_modules not found',
}}
/>
<ServiceCard
name={'Git'}
status={{
status:
status.git_branch === 'unknown'
? 'info'
: status.git_clean
? 'success'
: 'warning',
message: `${status.git_branch} (${status.git_clean ? 'clean' : 'dirty'}) • ${status.git_modified_files.length} modified • ${status.git_untracked_files.length} untracked`,
}}
/>
<ServiceCard
name={'Dev Server'}
status={{
status: status.services.app.running ? 'success' : 'error',
message: status.services.app.running
? `Running on port ${status.services.app.port}`
: 'Not running',
}}
/>
<ServiceCard
name={'Supabase'}
status={{
status: status.services.supabase.running ? 'success' : 'error',
message: status.services.supabase.running
? `Running${status.services.supabase.api_port ? ` (API ${status.services.supabase.api_port})` : ''}${status.services.supabase.studio_port ? ` (Studio ${status.services.supabase.studio_port})` : ''}`
: 'Not running',
}}
/>
<ServiceCard
name={'Merge Check'}
status={{
status:
status.git_merge_check.has_conflicts === true
? 'warning'
: status.git_merge_check.detectable
? 'success'
: 'info',
message: status.git_merge_check.detectable
? status.git_merge_check.has_conflicts
? `${status.git_merge_check.conflict_files.length} potential conflicts vs ${status.git_merge_check.target_branch}`
: `No conflicts vs ${status.git_merge_check.target_branch}`
: status.git_merge_check.message,
}}
/>
<ServiceCard
name={'Prerequisites'}
status={{
status:
prerequisites.overall === 'fail'
? 'error'
: prerequisites.overall === 'warn'
? 'warning'
: 'success',
message:
prerequisites.overall === 'fail'
? `${failedRequiredCount} required tools missing/mismatched`
: prerequisites.overall === 'warn'
? `${warnCount} optional warnings`
: 'All prerequisites satisfied',
}}
/>
</div>
{failedRequired.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Prerequisites Details</CardTitle>
</CardHeader>
<CardContent className={'space-y-4'}>
<div className={'space-y-2'}>
<p className={'text-sm font-medium'}>Missing or Mismatched</p>
<ul
className={
'text-muted-foreground list-disc space-y-1 pl-5 text-sm'
}
>
{failedRequired.map((item) => (
<li key={item.id}>
{item.name}
{item.version
? ` (installed ${item.version}, requires >= ${item.minimum_version})`
: ' (not installed)'}
</li>
))}
</ul>
</div>
<div className={'space-y-2'}>
<p className={'text-sm font-medium'}>Remediation</p>
<ul
className={
'text-muted-foreground list-disc space-y-1 pl-5 text-sm'
}
>
{prerequisiteRemedies.map((remedy) => (
<li key={remedy}>
<code>{remedy}</code>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
) : null}
</PageBody>
</Page>
);

View File

@@ -8,8 +8,8 @@ import { Progress } from '@kit/ui/progress';
import { Separator } from '@kit/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
import { UserStoryDisplay } from '../../../_components/user-story-display';
import type { PRDData } from '../../../_lib/server/prd-page.loader';
import { UserStoryDisplay } from '../../_components/user-story-display';
import type { PRDData } from '../../_lib/server/prd-page.loader';
interface PRDDetailViewProps {
filename: string;

View File

@@ -2,7 +2,7 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { loadPRDPageData } from '../../_lib/server/prd-page.loader';
import { loadPRDPageData } from '../_lib/server/prd-page.loader';
import { PRDDetailView } from './_components/prd-detail-view';
interface PRDPageProps {

View File

@@ -79,7 +79,7 @@ export function PRDsListInterface({ initialPrds }: PRDsListInterfaceProps) {
{filteredPrds.map((prd) => (
<Link
key={prd.filename}
href={`/mcp-server/prds/${prd.filename}`}
href={`/prds/${prd.filename}`}
className="block"
>
<Card className="cursor-pointer transition-shadow hover:shadow-md">

View File

@@ -1,7 +1,7 @@
import { Metadata } from 'next';
import { loadPRDs } from '../_lib/server/prd-loader';
import { PRDsListInterface } from './_components/prds-list-interface';
import { loadPRDs } from './_lib/server/prd-loader';
export const metadata: Metadata = {
title: 'PRDs - MCP Server',

View File

@@ -33,27 +33,7 @@ import {
import { cn } from '@kit/ui/utils';
import { updateTranslationAction } from '../lib/server-actions';
import type { TranslationData, Translations } from '../lib/translations-loader';
function flattenTranslations(
obj: TranslationData,
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;
} else {
flattenTranslations(value, newKey, result);
}
}
return result;
}
type FlattenedTranslations = Record<string, Record<string, string>>;
import type { Translations } from '../lib/translations-loader';
export function TranslationsComparison({
translations,
@@ -74,35 +54,24 @@ export function TranslationsComparison({
[],
);
const locales = Object.keys(translations);
const baseLocale = locales[0]!;
const namespaces = Object.keys(translations[baseLocale] || {});
const { base_locale, locales, namespaces } = translations;
const [selectedLocales, setSelectedLocales] = useState<Set<string>>(
new Set(locales),
);
// Flatten translations for the selected namespace
const flattenedTranslations: FlattenedTranslations = {};
const [selectedNamespace, setSelectedNamespace] = useState(
namespaces[0] as string,
namespaces[0] ?? '',
);
for (const locale of locales) {
const namespaceData = translations[locale]?.[selectedNamespace];
if (namespaceData) {
flattenedTranslations[locale] = flattenTranslations(namespaceData);
} else {
flattenedTranslations[locale] = {};
}
}
// Get all unique keys across all translations
const allKeys = Array.from(
new Set(
Object.values(flattenedTranslations).flatMap((data) => Object.keys(data)),
locales.flatMap((locale) =>
Object.keys(
translations.translations[locale]?.[selectedNamespace] ?? {},
),
),
),
).sort();
@@ -143,7 +112,7 @@ export function TranslationsComparison({
return () => subscription.unsubscribe();
}, [subject$]);
if (locales.length === 0) {
if (locales.length === 0 || !base_locale) {
return <div>No translations found</div>;
}
@@ -228,12 +197,16 @@ export function TranslationsComparison({
</TableCell>
{visibleLocales.map((locale) => {
const translations = flattenedTranslations[locale] ?? {};
const translationsForLocale =
translations.translations[locale]?.[selectedNamespace] ??
{};
const baseTranslations =
flattenedTranslations[baseLocale] ?? {};
translations.translations[base_locale]?.[
selectedNamespace
] ?? {};
const value = translations[key];
const value = translationsForLocale[key];
const baseValue = baseTranslations[key];
const isMissing = !value;
const isDifferent = value !== baseValue;

View File

@@ -2,10 +2,14 @@
import { revalidatePath } from 'next/cache';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:url';
import { z } from 'zod';
import { findWorkspaceRoot } from '@kit/mcp-server/env';
import {
createKitTranslationsDeps,
createKitTranslationsService,
} from '@kit/mcp-server/translations';
const Schema = z.object({
locale: z.string().min(1),
namespace: z.string().min(1),
@@ -20,40 +24,18 @@ const Schema = z.object({
export async function updateTranslationAction(props: z.infer<typeof Schema>) {
// Validate the input
const { locale, namespace, key, value } = Schema.parse(props);
const rootPath = findWorkspaceRoot(process.cwd());
const root = resolve(process.cwd(), '..');
const filePath = `${root}apps/web/public/locales/${locale}/${namespace}.json`;
const service = createKitTranslationsService(
createKitTranslationsDeps(rootPath),
);
try {
// Read the current translations file
const translationsFile = readFileSync(filePath, 'utf8');
const translations = JSON.parse(translationsFile) as Record<string, any>;
// Update the nested key value
const keys = key.split('.') as string[];
let current = translations;
// Navigate through nested objects until the second-to-last key
for (let i = 0; i < keys.length - 1; i++) {
const currentKey = keys[i] as string;
if (!current[currentKey]) {
current[currentKey] = {};
}
current = current[currentKey];
}
// Set the value at the final key
const finalKey = keys[keys.length - 1] as string;
current[finalKey] = value;
// Write the updated translations back to the file
writeFileSync(filePath, JSON.stringify(translations, null, 2), 'utf8');
const result = await service.update({ locale, namespace, key, value });
revalidatePath(`/translations`);
return { success: true };
return result;
} catch (error) {
console.error('Failed to update translation:', error);
throw new Error('Failed to update translation');

View File

@@ -1,50 +1,21 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
export type TranslationData = {
[key: string]: string | TranslationData;
};
import { findWorkspaceRoot } from '@kit/mcp-server/env';
import {
createKitTranslationsDeps,
createKitTranslationsService,
} from '@kit/mcp-server/translations';
export type Translations = {
[locale: string]: {
[namespace: string]: TranslationData;
};
base_locale: string;
locales: string[];
namespaces: string[];
translations: Record<string, Record<string, Record<string, string>>>;
};
export async function loadTranslations() {
const localesPath = join(process.cwd(), '../web/public/locales');
const localesDirents = readdirSync(localesPath, { withFileTypes: true });
export async function loadTranslations(): Promise<Translations> {
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitTranslationsService(
createKitTranslationsDeps(rootPath),
);
const locales = localesDirents
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
const translations: Translations = {};
for (const locale of locales) {
translations[locale] = {};
const namespaces = readdirSync(join(localesPath, locale)).filter((file) =>
file.endsWith('.json'),
);
for (const namespace of namespaces) {
const namespaceName = namespace.replace('.json', '');
try {
const filePath = join(localesPath, locale, namespace);
const content = readFileSync(filePath, 'utf8');
translations[locale][namespaceName] = JSON.parse(content);
} catch (error) {
console.warn(
`Warning: Translation file not found for locale "${locale}" and namespace "${namespaceName}"`,
);
translations[locale][namespaceName] = {};
}
}
}
return translations;
return service.list();
}

View File

@@ -1,6 +1,5 @@
import { Metadata } from 'next';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { TranslationsComparison } from './components/translations-comparison';

View File

@@ -1,471 +1,6 @@
import 'server-only';
import fs from 'fs/promises';
import path from 'path';
import { envVariables } from './env-variables-model';
import {
AppEnvState,
EnvFileInfo,
EnvMode,
EnvVariableState,
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 = path.resolve(process.cwd(), '../..'),
apps = ['web'],
mode,
} = options;
const envTypes = ENV_FILE_PRECEDENCE[mode];
const appsDir = path.join(rootDir, 'apps');
const results: EnvFileInfo[] = [];
try {
const appDirs = await fs.readdir(appsDir);
for (const appName of appDirs) {
if (apps.length > 0 && !apps.includes(appName)) {
continue;
}
const appDir = path.join(appsDir, appName);
const stat = await fs.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 fs.readFile(envPath, 'utf-8');
const vars = parseEnvFile(content, envType);
appInfo.variables.push(...vars);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`Error reading ${envPath}:`, error);
}
}
}
if (appInfo.variables.length > 0) {
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}`;
}
}
}
// 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) {
const allVariables = Object.values(variableMap).reduce(
(acc, variable) => {
return {
...acc,
[variable.key]: variable.effectiveValue,
};
},
{} as Record<string, string>,
);
// First check if it's required but missing
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.required || model.contextualValidation) {
if (model.contextualValidation) {
const allVariables = Object.values(variableMap).reduce(
(acc, variable) => {
return {
...acc,
[variable.key]: variable.effectiveValue,
};
},
{} as Record<string, string>,
);
const errors =
model?.contextualValidation?.dependencies
.map((dep) => {
const dependencyValue = allVariables[dep.variable] ?? '';
const shouldValidate = dep.condition(
dependencyValue,
allVariables,
);
if (!shouldValidate) {
return [];
}
const effectiveValue = allVariables[dep.variable] ?? '';
const validation = model.contextualValidation!.validate({
value: effectiveValue,
variables: allVariables,
mode,
});
if (validation) {
return [dep.message];
}
return [];
})
.flat() ?? ([] as string[]);
if (errors.length === 0) {
continue;
} else {
variableMap[model.name] = {
key: model.name,
effectiveValue: '',
effectiveSource: 'MISSING',
isVisible: true,
category: model.category,
isOverridden: false,
definitions: [],
validation: {
success: false,
error: {
issues: errors.map((error) => error),
},
},
};
}
}
// If it doesn't exist but is required or has contextual validation, create an empty state
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 {
getEnvState,
getVariable,
processEnvDefinitions,
scanMonorepoEnv,
} from '@kit/mcp-server/env';

File diff suppressed because it is too large Load Diff

View File

@@ -2,83 +2,35 @@
import { revalidatePath } from 'next/cache';
import { envVariables } from '@/app/variables/lib/env-variables-model';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:url';
import { z } from 'zod';
import {
createKitEnvDeps,
createKitEnvService,
findWorkspaceRoot,
} from '@kit/mcp-server/env';
const Schema = z.object({
name: z.string().min(1),
value: z.string(),
mode: z.enum(['development', 'production']),
});
/**
* Update the environment variable in the specified file.
* @param props
*/
export async function updateEnvironmentVariableAction(
props: z.infer<typeof Schema>,
) {
// Validate the input
const { name, mode, value } = Schema.parse(props);
const root = resolve(process.cwd(), '..');
const model = envVariables.find((item) => item.name === name);
// Determine the source file based on the mode
const source = (() => {
const isSecret = model?.secret ?? true;
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEnvService(createKitEnvDeps(rootPath));
switch (mode) {
case 'development':
if (isSecret) {
return '.env.local';
} else {
return '.env.development';
}
const result = await service.update({
key: name,
value,
mode,
});
case 'production':
if (isSecret) {
return '.env.production.local';
} else {
return '.env.production';
}
revalidatePath('/variables');
default:
throw new Error(`Invalid mode: ${mode}`);
}
})();
// check file exists, if not, create it
const filePath = `${root}/apps/web/${source}`;
if (!existsSync(filePath)) {
writeFileSync(filePath, '', 'utf8');
}
const sourceEnvFile = readFileSync(`${root}apps/web/${source}`, 'utf8');
let updatedEnvFile = '';
const isInSourceFile = sourceEnvFile.includes(name);
const isCommentedOut = sourceEnvFile.includes(`#${name}=`);
if (isInSourceFile && !isCommentedOut) {
updatedEnvFile = sourceEnvFile.replace(
new RegExp(`^${name}=.*`, 'm'),
`${name}=${value}`,
);
} else {
// if the key does not exist, append it to the end of the file
updatedEnvFile = `${sourceEnvFile}\n${name}=${value}`;
}
// write the updated content back to the file
writeFileSync(`${root}/apps/web/${source}`, updatedEnvFile, 'utf8');
revalidatePath(`/variables`);
return {
success: true,
message: `Updated ${name} in "${source}"`,
};
return result;
}

View File

@@ -1,12 +1,12 @@
import { use } from 'react';
import {
processEnvDefinitions,
scanMonorepoEnv,
} from '@/app/variables/lib/env-scanner';
import { EnvMode } from '@/app/variables/lib/types';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import {
createKitEnvDeps,
createKitEnvService,
findWorkspaceRoot,
} from '@kit/mcp-server/env';
import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { AppEnvironmentVariablesManager } from './components/app-environment-variables-manager';
@@ -21,7 +21,7 @@ export const metadata = {
export default function VariablesPage({ searchParams }: VariablesPageProps) {
const { mode = 'development' } = use(searchParams);
const apps = use(scanMonorepoEnv({ mode }));
const apps = use(loadEnvStates(mode));
return (
<Page style={'custom'}>
@@ -36,19 +36,18 @@ export default function VariablesPage({ searchParams }: VariablesPageProps) {
<PageBody className={'overflow-hidden'}>
<div className={'flex h-full flex-1 flex-col space-y-4'}>
{apps.map((app) => {
const appEnvState = processEnvDefinitions(app, mode);
return (
<AppEnvironmentVariablesManager
key={app.appName}
state={appEnvState}
/>
);
})}
{apps.map((app) => (
<AppEnvironmentVariablesManager key={app.appName} state={app} />
))}
</div>
</PageBody>
</div>
</Page>
);
}
async function loadEnvStates(mode: EnvMode) {
const rootPath = findWorkspaceRoot(process.cwd());
const service = createKitEnvService(createKitEnvDeps(rootPath));
return service.getAppState(mode);
}

View File

@@ -11,7 +11,6 @@ import {
LanguagesIcon,
LayoutDashboardIcon,
MailIcon,
ServerIcon,
} from 'lucide-react';
import {
@@ -55,20 +54,14 @@ const routes = [
Icon: LanguagesIcon,
},
{
label: 'MCP Server',
Icon: ServerIcon,
children: [
{
label: 'Database',
path: '/mcp-server/database',
Icon: DatabaseIcon,
},
{
label: 'PRD Manager',
path: '/mcp-server/prds',
Icon: FileTextIcon,
},
],
label: 'Database',
path: '/database',
Icon: DatabaseIcon,
},
{
label: 'PRD Manager',
path: '/prds',
Icon: FileTextIcon,
},
];

View File

@@ -2,12 +2,13 @@
import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Card, CardContent } from '@kit/ui/card';
export const ServiceStatus = {
CHECKING: 'checking',
SUCCESS: 'success',
WARNING: 'warning',
INFO: 'info',
ERROR: 'error',
} as const;
@@ -16,6 +17,8 @@ type ServiceStatusType = (typeof ServiceStatus)[keyof typeof ServiceStatus];
const StatusIcons = {
[ServiceStatus.CHECKING]: <AlertCircle className="h-6 w-6 text-yellow-500" />,
[ServiceStatus.SUCCESS]: <CheckCircle2 className="h-6 w-6 text-green-500" />,
[ServiceStatus.WARNING]: <AlertCircle className="h-6 w-6 text-amber-500" />,
[ServiceStatus.INFO]: <AlertCircle className="h-6 w-6 text-blue-500" />,
[ServiceStatus.ERROR]: <XCircle className="h-6 w-6 text-red-500" />,
};
@@ -30,7 +33,7 @@ interface ServiceCardProps {
export const ServiceCard = ({ name, status }: ServiceCardProps) => {
return (
<Card className="w-full max-w-2xl">
<CardContent className="pt-6">
<CardContent className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">

View File

@@ -11,7 +11,7 @@
},
"author": "Makerkit",
"devDependencies": {
"@playwright/test": "^1.58.1",
"@playwright/test": "^1.58.2",
"@supabase/supabase-js": "catalog:",
"@types/node": "catalog:",
"dotenv": "17.2.4",

View File

@@ -22,6 +22,7 @@ app/
For specialized implementation:
- `/feature-builder` - End-to-end feature implementation
- `/service-builder` - Server side services
- `/server-action-builder` - Server actions
- `/forms-builder` - Forms with validation
- `/navigation-config` - Adding routes and menu items

View File

@@ -72,10 +72,10 @@ export async function generateMetadata({
url: data.entry.url,
images: image
? [
{
url: image,
},
]
{
url: image,
},
]
: [],
},
twitter: {

View File

@@ -6,6 +6,7 @@
- **ALWAYS** validate admin status before operations
- **NEVER** bypass authentication or authorization
- **ALWAYS** audit admin operations with logging
- **ALWAYS** use `adminAction` to wrap admin actions @packages/features/admin/src/lib/server/utils/admin-action.ts
## Page Structure

View File

@@ -69,7 +69,7 @@ export function ErrorPageContent({
) : (
<Button asChild>
<Link href={backLink}>
<ArrowLeft className={'h-4 w-4 mr-1'} />
<ArrowLeft className={'mr-1 h-4 w-4'} />
<Trans i18nKey={backLabel} />
</Link>
</Button>
@@ -77,7 +77,7 @@ export function ErrorPageContent({
<Button asChild variant={'ghost'}>
<Link href={'/contact'}>
<MessageCircleQuestion className={'h-4 w-4 mr-1'} />
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
<Trans i18nKey={contactLabel} />
</Link>
</Button>

View File

@@ -34,7 +34,7 @@ const config = {
fullUrl: true,
},
},
serverExternalPackages: ['pino', 'thread-stream'],
serverExternalPackages: [],
// needed for supporting dynamic imports for local content
outputFileTracingIncludes: {
'/*': ['./content/**/*'],

View File

@@ -1,6 +1,6 @@
{
"name": "next-supabase-saas-kit-turbo",
"version": "2.23.14",
"version": "2.24.0",
"private": true,
"sideEffects": false,
"engines": {
@@ -48,7 +48,7 @@
"@turbo/gen": "^2.7.6",
"cross-env": "^10.0.0",
"prettier": "^3.8.1",
"turbo": "2.7.6",
"turbo": "2.8.5",
"typescript": "^5.9.3"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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