MCP Server 2.0 (#452)
* MCP Server 2.0 - Updated application version from 2.23.14 to 2.24.0 in package.json. - MCP Server improved with new features - Migrated functionality from Dev Tools to MCP Server - Improved getMonitoringProvider not to crash application when misconfigured
This commit is contained in:
committed by
GitHub
parent
059408a70a
commit
f3ac595d06
@@ -0,0 +1,431 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type KitStatusDeps,
|
||||
createKitStatusService,
|
||||
} from '../kit-status.service';
|
||||
|
||||
function createDeps(overrides: Partial<KitStatusDeps> = {}): KitStatusDeps {
|
||||
const readJsonMap: Record<string, unknown> = {
|
||||
'package.json': {
|
||||
name: 'test-project',
|
||||
packageManager: 'pnpm@10.0.0',
|
||||
},
|
||||
'apps/web/package.json': {
|
||||
dependencies: {
|
||||
next: '16.1.6',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
rootPath: '/repo',
|
||||
async readJsonFile(path: string) {
|
||||
if (!(path in readJsonMap)) {
|
||||
throw new Error(`missing file: ${path}`);
|
||||
}
|
||||
|
||||
return readJsonMap[path];
|
||||
},
|
||||
async pathExists(path: string) {
|
||||
return path === 'apps/web/supabase';
|
||||
},
|
||||
async isDirectory(path: string) {
|
||||
return path === 'node_modules';
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
if (command !== 'git') {
|
||||
throw new Error('unsupported command');
|
||||
}
|
||||
|
||||
if (args[0] === 'rev-parse') {
|
||||
return {
|
||||
stdout: 'main\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'status') {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return {
|
||||
stdout: 'origin/main\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'merge-base') {
|
||||
return {
|
||||
stdout: 'abc123\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'merge-tree') {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('unsupported git args');
|
||||
},
|
||||
async isPortOpen(port: number) {
|
||||
return port === 3000 || port === 54321 || port === 54323;
|
||||
},
|
||||
getNodeVersion() {
|
||||
return 'v22.5.0';
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('KitStatusService', () => {
|
||||
it('returns a complete status in the happy path', async () => {
|
||||
const service = createKitStatusService(createDeps());
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.project_name).toBe('test-project');
|
||||
expect(result.package_manager).toBe('pnpm');
|
||||
expect(result.node_version).toBe('22.5.0');
|
||||
expect(result.git_branch).toBe('main');
|
||||
expect(result.git_clean).toBe(true);
|
||||
expect(result.deps_installed).toBe(true);
|
||||
expect(result.variant).toBe('next-supabase');
|
||||
expect(result.services.app.running).toBe(true);
|
||||
expect(result.services.app.port).toBe(3000);
|
||||
expect(result.services.supabase.running).toBe(true);
|
||||
expect(result.services.supabase.api_port).toBe(54321);
|
||||
expect(result.services.supabase.studio_port).toBe(54323);
|
||||
expect(result.git_modified_files).toHaveLength(0);
|
||||
expect(result.git_untracked_files).toHaveLength(0);
|
||||
expect(result.git_merge_check.target_branch).toBe('main');
|
||||
expect(result.git_merge_check.has_conflicts).toBe(false);
|
||||
expect(result.diagnostics).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('falls back when git commands fail', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async executeCommand() {
|
||||
throw new Error('git not found');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.git_branch).toBe('unknown');
|
||||
expect(result.git_clean).toBe(false);
|
||||
expect(result.git_merge_check.detectable).toBe(false);
|
||||
expect(result.diagnostics.find((item) => item.id === 'git')?.status).toBe(
|
||||
'warn',
|
||||
);
|
||||
});
|
||||
|
||||
it('collects modified files from git status output', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
if (command !== 'git') {
|
||||
throw new Error('unsupported command');
|
||||
}
|
||||
|
||||
if (args[0] === 'rev-parse') {
|
||||
return {
|
||||
stdout: 'feature/status\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'status') {
|
||||
return {
|
||||
stdout: ' M apps/web/page.tsx\n?? new-file.ts\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return {
|
||||
stdout: 'origin/main\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'merge-base') {
|
||||
return {
|
||||
stdout: 'abc123\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'merge-tree') {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('unsupported git args');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.git_clean).toBe(false);
|
||||
expect(result.git_modified_files).toEqual(['apps/web/page.tsx']);
|
||||
expect(result.git_untracked_files).toEqual(['new-file.ts']);
|
||||
});
|
||||
|
||||
it('detects merge conflicts against default branch', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
if (command !== 'git') {
|
||||
throw new Error('unsupported command');
|
||||
}
|
||||
|
||||
if (args[0] === 'rev-parse') {
|
||||
return {
|
||||
stdout: 'feature/conflicts\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'status') {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return {
|
||||
stdout: 'origin/main\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'merge-base') {
|
||||
return {
|
||||
stdout: 'abc123\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'merge-tree') {
|
||||
return {
|
||||
stdout:
|
||||
'CONFLICT (content): Merge conflict in apps/dev-tool/app/page.tsx\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('unsupported git args');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.git_merge_check.target_branch).toBe('main');
|
||||
expect(result.git_merge_check.detectable).toBe(true);
|
||||
expect(result.git_merge_check.has_conflicts).toBe(true);
|
||||
expect(result.git_merge_check.conflict_files).toEqual([
|
||||
'apps/dev-tool/app/page.tsx',
|
||||
]);
|
||||
expect(
|
||||
result.diagnostics.find((item) => item.id === 'merge_conflicts')?.status,
|
||||
).toBe('warn');
|
||||
});
|
||||
|
||||
it('uses unknown package manager when packageManager is missing', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async readJsonFile(path: string) {
|
||||
if (path === 'package.json') {
|
||||
return { name: 'test-project' };
|
||||
}
|
||||
|
||||
if (path === 'apps/web/package.json') {
|
||||
return {
|
||||
dependencies: {
|
||||
next: '16.1.6',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`missing file: ${path}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.package_manager).toBe('unknown');
|
||||
});
|
||||
|
||||
it('provides remedies when services are not running', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async isPortOpen() {
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.services.app.running).toBe(false);
|
||||
expect(result.services.supabase.running).toBe(false);
|
||||
|
||||
const devServerDiagnostic = result.diagnostics.find(
|
||||
(item) => item.id === 'dev_server',
|
||||
);
|
||||
const supabaseDiagnostic = result.diagnostics.find(
|
||||
(item) => item.id === 'supabase',
|
||||
);
|
||||
|
||||
expect(devServerDiagnostic?.status).toBe('fail');
|
||||
expect(devServerDiagnostic?.remedies).toEqual(['Run pnpm dev']);
|
||||
expect(supabaseDiagnostic?.status).toBe('fail');
|
||||
expect(supabaseDiagnostic?.remedies).toEqual([
|
||||
'Run pnpm supabase:web:start',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps variant from .makerkit/config.json when present', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async pathExists(path: string) {
|
||||
return path === '.makerkit/config.json';
|
||||
},
|
||||
async readJsonFile(path: string) {
|
||||
if (path === '.makerkit/config.json') {
|
||||
return {
|
||||
variant: 'next-prisma',
|
||||
};
|
||||
}
|
||||
|
||||
if (path === 'package.json') {
|
||||
return {
|
||||
name: 'test-project',
|
||||
packageManager: 'pnpm@10.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`missing file: ${path}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.variant).toBe('next-prisma');
|
||||
expect(result.variant_family).toBe('orm');
|
||||
expect(result.database).toBe('postgresql');
|
||||
expect(result.auth).toBe('better-auth');
|
||||
});
|
||||
|
||||
it('reads variant from the template key when present', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async pathExists(path: string) {
|
||||
return path === '.makerkit/config.json';
|
||||
},
|
||||
async readJsonFile(path: string) {
|
||||
if (path === '.makerkit/config.json') {
|
||||
return {
|
||||
template: 'react-router-supabase',
|
||||
};
|
||||
}
|
||||
|
||||
if (path === 'package.json') {
|
||||
return {
|
||||
name: 'test-project',
|
||||
packageManager: 'pnpm@10.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`missing file: ${path}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.variant).toBe('react-router-supabase');
|
||||
expect(result.framework).toBe('react-router');
|
||||
});
|
||||
|
||||
it('reads variant from kitVariant key and preserves unknown names', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async pathExists(path: string) {
|
||||
return path === '.makerkit/config.json';
|
||||
},
|
||||
async readJsonFile(path: string) {
|
||||
if (path === '.makerkit/config.json') {
|
||||
return {
|
||||
kitVariant: 'custom-enterprise-kit',
|
||||
};
|
||||
}
|
||||
|
||||
if (path === 'package.json') {
|
||||
return {
|
||||
name: 'test-project',
|
||||
packageManager: 'pnpm@10.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`missing file: ${path}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.variant).toBe('custom-enterprise-kit');
|
||||
expect(result.variant_family).toBe('supabase');
|
||||
expect(result.framework).toBe('nextjs');
|
||||
});
|
||||
|
||||
it('uses heuristic variant fallback when config is absent', async () => {
|
||||
const service = createKitStatusService(
|
||||
createDeps({
|
||||
async pathExists(path: string) {
|
||||
return path === 'apps/web/supabase';
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.getStatus({});
|
||||
|
||||
expect(result.variant).toBe('next-supabase');
|
||||
expect(result.framework).toBe('nextjs');
|
||||
expect(result.database).toBe('supabase');
|
||||
expect(result.auth).toBe('supabase');
|
||||
});
|
||||
});
|
||||
144
packages/mcp-server/src/tools/status/index.ts
Normal file
144
packages/mcp-server/src/tools/status/index.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import { Socket } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
type KitStatusDeps,
|
||||
createKitStatusService,
|
||||
} from './kit-status.service';
|
||||
import { KitStatusInputSchema, KitStatusOutputSchema } from './schema';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function registerKitStatusTool(server: McpServer) {
|
||||
return server.registerTool(
|
||||
'kit_status',
|
||||
{
|
||||
description: 'Project status with variant context',
|
||||
inputSchema: KitStatusInputSchema,
|
||||
outputSchema: KitStatusOutputSchema,
|
||||
},
|
||||
async (input) => {
|
||||
const parsedInput = KitStatusInputSchema.parse(input);
|
||||
|
||||
try {
|
||||
const service = createKitStatusService(createKitStatusDeps());
|
||||
const status = await service.getStatus(parsedInput);
|
||||
|
||||
return {
|
||||
structuredContent: status,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(status),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `kit_status failed: ${message}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createKitStatusDeps(): KitStatusDeps {
|
||||
const rootPath = process.cwd();
|
||||
|
||||
return {
|
||||
rootPath,
|
||||
async readJsonFile(path: string): Promise<unknown> {
|
||||
const filePath = join(rootPath, path);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(content) as unknown;
|
||||
},
|
||||
async pathExists(path: string) {
|
||||
const fullPath = join(rootPath, path);
|
||||
|
||||
try {
|
||||
await access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async isDirectory(path: string) {
|
||||
const fullPath = join(rootPath, path);
|
||||
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
return stats.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async executeCommand(command: string, args: string[]) {
|
||||
const result = await execFileAsync(command, args, {
|
||||
cwd: rootPath,
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: 0,
|
||||
};
|
||||
},
|
||||
async isPortOpen(port: number) {
|
||||
return checkPort(port);
|
||||
},
|
||||
getNodeVersion() {
|
||||
return process.version;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function checkPort(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new Socket();
|
||||
|
||||
socket.setTimeout(200);
|
||||
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.once('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
export {
|
||||
createKitStatusService,
|
||||
type KitStatusDeps,
|
||||
} from './kit-status.service';
|
||||
export type { KitStatusOutput } from './schema';
|
||||
549
packages/mcp-server/src/tools/status/kit-status.service.ts
Normal file
549
packages/mcp-server/src/tools/status/kit-status.service.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { KitStatusInput, KitStatusOutput } from './schema';
|
||||
|
||||
interface VariantDescriptor {
|
||||
variant: string;
|
||||
variant_family: string;
|
||||
framework: string;
|
||||
database: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
interface ServicesStatus {
|
||||
app: {
|
||||
running: boolean;
|
||||
port: number | null;
|
||||
};
|
||||
supabase: {
|
||||
running: boolean;
|
||||
api_port: number | null;
|
||||
studio_port: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface MergeCheckStatus {
|
||||
target_branch: string | null;
|
||||
detectable: boolean;
|
||||
has_conflicts: boolean | null;
|
||||
conflict_files: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface KitStatusDeps {
|
||||
rootPath: string;
|
||||
readJsonFile(path: string): Promise<unknown>;
|
||||
pathExists(path: string): Promise<boolean>;
|
||||
isDirectory(path: string): Promise<boolean>;
|
||||
executeCommand(command: string, args: string[]): Promise<CommandResult>;
|
||||
isPortOpen(port: number): Promise<boolean>;
|
||||
getNodeVersion(): string;
|
||||
}
|
||||
|
||||
export function createKitStatusService(deps: KitStatusDeps) {
|
||||
return new KitStatusService(deps);
|
||||
}
|
||||
|
||||
export class KitStatusService {
|
||||
constructor(private readonly deps: KitStatusDeps) {}
|
||||
|
||||
async getStatus(_input: KitStatusInput): Promise<KitStatusOutput> {
|
||||
const packageJson = await this.readObject('package.json');
|
||||
|
||||
const projectName = this.readString(packageJson, 'name') ?? 'unknown';
|
||||
const packageManager = this.getPackageManager(packageJson);
|
||||
const depsInstalled = await this.deps.isDirectory('node_modules');
|
||||
|
||||
const { gitBranch, gitClean, modifiedFiles, untrackedFiles, mergeCheck } =
|
||||
await this.getGitStatus();
|
||||
const variant = await this.resolveVariant();
|
||||
const services = await this.getServicesStatus();
|
||||
const diagnostics = this.buildDiagnostics({
|
||||
depsInstalled,
|
||||
gitBranch,
|
||||
gitClean,
|
||||
mergeCheck,
|
||||
services,
|
||||
});
|
||||
|
||||
return {
|
||||
...variant,
|
||||
project_name: projectName,
|
||||
node_version: this.deps.getNodeVersion().replace(/^v/, ''),
|
||||
package_manager: packageManager,
|
||||
deps_installed: depsInstalled,
|
||||
git_clean: gitClean,
|
||||
git_branch: gitBranch,
|
||||
git_modified_files: modifiedFiles,
|
||||
git_untracked_files: untrackedFiles,
|
||||
git_merge_check: mergeCheck,
|
||||
services,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
private async getServicesStatus(): Promise<ServicesStatus> {
|
||||
const app = await this.detectAppService();
|
||||
const supabase = await this.detectSupabaseService();
|
||||
|
||||
return {
|
||||
app,
|
||||
supabase,
|
||||
};
|
||||
}
|
||||
|
||||
private async detectAppService() {
|
||||
const commonDevPorts = [3000, 3001, 3002, 3003];
|
||||
|
||||
for (const port of commonDevPorts) {
|
||||
if (await this.deps.isPortOpen(port)) {
|
||||
return {
|
||||
running: true,
|
||||
port,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
running: false,
|
||||
port: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async detectSupabaseService() {
|
||||
const apiPort = 54321;
|
||||
const studioPort = 54323;
|
||||
|
||||
const [apiRunning, studioRunning] = await Promise.all([
|
||||
this.deps.isPortOpen(apiPort),
|
||||
this.deps.isPortOpen(studioPort),
|
||||
]);
|
||||
|
||||
return {
|
||||
running: apiRunning || studioRunning,
|
||||
api_port: apiRunning ? apiPort : null,
|
||||
studio_port: studioRunning ? studioPort : null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildDiagnostics(params: {
|
||||
depsInstalled: boolean;
|
||||
gitBranch: string;
|
||||
gitClean: boolean;
|
||||
mergeCheck: MergeCheckStatus;
|
||||
services: ServicesStatus;
|
||||
}) {
|
||||
const diagnostics: KitStatusOutput['diagnostics'] = [];
|
||||
|
||||
diagnostics.push({
|
||||
id: 'dependencies',
|
||||
status: params.depsInstalled ? 'pass' : 'fail',
|
||||
message: params.depsInstalled
|
||||
? 'Dependencies are installed.'
|
||||
: 'Dependencies are missing.',
|
||||
remedies: params.depsInstalled ? [] : ['Run pnpm install'],
|
||||
});
|
||||
|
||||
diagnostics.push({
|
||||
id: 'dev_server',
|
||||
status: params.services.app.running ? 'pass' : 'fail',
|
||||
message: params.services.app.running
|
||||
? `Dev server is running on port ${params.services.app.port}.`
|
||||
: 'Dev server is not running.',
|
||||
remedies: params.services.app.running ? [] : ['Run pnpm dev'],
|
||||
});
|
||||
|
||||
diagnostics.push({
|
||||
id: 'supabase',
|
||||
status: params.services.supabase.running ? 'pass' : 'fail',
|
||||
message: params.services.supabase.running
|
||||
? `Supabase is running${params.services.supabase.api_port ? ` (API ${params.services.supabase.api_port})` : ''}${params.services.supabase.studio_port ? ` (Studio ${params.services.supabase.studio_port})` : ''}.`
|
||||
: 'Supabase is not running.',
|
||||
remedies: params.services.supabase.running
|
||||
? []
|
||||
: ['Run pnpm supabase:web:start'],
|
||||
});
|
||||
|
||||
diagnostics.push({
|
||||
id: 'git',
|
||||
status:
|
||||
params.gitBranch === 'unknown'
|
||||
? 'warn'
|
||||
: params.gitClean
|
||||
? 'pass'
|
||||
: 'warn',
|
||||
message:
|
||||
params.gitBranch === 'unknown'
|
||||
? 'Git status unavailable.'
|
||||
: `Current branch ${params.gitBranch} is ${params.gitClean ? 'clean' : 'dirty'}.`,
|
||||
remedies:
|
||||
params.gitBranch === 'unknown' || params.gitClean
|
||||
? []
|
||||
: ['Commit or stash changes when you need a clean workspace'],
|
||||
});
|
||||
|
||||
diagnostics.push({
|
||||
id: 'merge_conflicts',
|
||||
status:
|
||||
params.mergeCheck.has_conflicts === true
|
||||
? 'warn'
|
||||
: params.mergeCheck.detectable
|
||||
? 'pass'
|
||||
: 'warn',
|
||||
message: params.mergeCheck.message,
|
||||
remedies:
|
||||
params.mergeCheck.has_conflicts === true
|
||||
? [
|
||||
`Rebase or merge ${params.mergeCheck.target_branch} and resolve conflicts`,
|
||||
]
|
||||
: [],
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
private async getGitStatus() {
|
||||
try {
|
||||
const branchResult = await this.deps.executeCommand('git', [
|
||||
'rev-parse',
|
||||
'--abbrev-ref',
|
||||
'HEAD',
|
||||
]);
|
||||
|
||||
const statusResult = await this.deps.executeCommand('git', [
|
||||
'status',
|
||||
'--porcelain',
|
||||
]);
|
||||
|
||||
const parsedStatus = this.parseGitStatus(statusResult.stdout);
|
||||
const mergeCheck = await this.getMergeCheck();
|
||||
|
||||
return {
|
||||
gitBranch: branchResult.stdout.trim() || 'unknown',
|
||||
gitClean:
|
||||
parsedStatus.modifiedFiles.length === 0 &&
|
||||
parsedStatus.untrackedFiles.length === 0,
|
||||
modifiedFiles: parsedStatus.modifiedFiles,
|
||||
untrackedFiles: parsedStatus.untrackedFiles,
|
||||
mergeCheck,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
gitBranch: 'unknown',
|
||||
gitClean: false,
|
||||
modifiedFiles: [],
|
||||
untrackedFiles: [],
|
||||
mergeCheck: {
|
||||
target_branch: null,
|
||||
detectable: false,
|
||||
has_conflicts: null,
|
||||
conflict_files: [],
|
||||
message: 'Git metadata unavailable.',
|
||||
} satisfies MergeCheckStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private parseGitStatus(output: string) {
|
||||
const modifiedFiles: string[] = [];
|
||||
const untrackedFiles: string[] = [];
|
||||
|
||||
const lines = output.split('\n').filter((line) => line.trim().length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('?? ')) {
|
||||
const path = line.slice(3).trim();
|
||||
if (path) {
|
||||
untrackedFiles.push(path);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length >= 4) {
|
||||
const path = line.slice(3).trim();
|
||||
if (path) {
|
||||
modifiedFiles.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modifiedFiles,
|
||||
untrackedFiles,
|
||||
};
|
||||
}
|
||||
|
||||
private async getMergeCheck(): Promise<MergeCheckStatus> {
|
||||
const targetBranch = await this.resolveMergeTargetBranch();
|
||||
|
||||
if (!targetBranch) {
|
||||
return {
|
||||
target_branch: null,
|
||||
detectable: false,
|
||||
has_conflicts: null,
|
||||
conflict_files: [],
|
||||
message: 'No default target branch found for merge conflict checks.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const mergeBaseResult = await this.deps.executeCommand('git', [
|
||||
'merge-base',
|
||||
'HEAD',
|
||||
targetBranch,
|
||||
]);
|
||||
|
||||
const mergeBase = mergeBaseResult.stdout.trim();
|
||||
|
||||
if (!mergeBase) {
|
||||
return {
|
||||
target_branch: targetBranch,
|
||||
detectable: false,
|
||||
has_conflicts: null,
|
||||
conflict_files: [],
|
||||
message: 'Unable to compute merge base.',
|
||||
};
|
||||
}
|
||||
|
||||
const mergeTreeResult = await this.deps.executeCommand('git', [
|
||||
'merge-tree',
|
||||
mergeBase,
|
||||
'HEAD',
|
||||
targetBranch,
|
||||
]);
|
||||
|
||||
const rawOutput = `${mergeTreeResult.stdout}\n${mergeTreeResult.stderr}`;
|
||||
const conflictFiles = this.extractConflictFiles(rawOutput);
|
||||
const hasConflictMarkers =
|
||||
/CONFLICT|changed in both|both modified|both added/i.test(rawOutput);
|
||||
const hasConflicts = conflictFiles.length > 0 || hasConflictMarkers;
|
||||
|
||||
return {
|
||||
target_branch: targetBranch,
|
||||
detectable: true,
|
||||
has_conflicts: hasConflicts,
|
||||
conflict_files: conflictFiles,
|
||||
message: hasConflicts
|
||||
? `Potential merge conflicts detected against ${targetBranch}.`
|
||||
: `No merge conflicts detected against ${targetBranch}.`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
target_branch: targetBranch,
|
||||
detectable: false,
|
||||
has_conflicts: null,
|
||||
conflict_files: [],
|
||||
message: 'Merge conflict detection is not available in this git setup.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private extractConflictFiles(rawOutput: string) {
|
||||
const files = new Set<string>();
|
||||
const lines = rawOutput.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const conflictMatch = line.match(/CONFLICT .* in (.+)$/);
|
||||
if (conflictMatch?.[1]) {
|
||||
files.add(conflictMatch[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(files).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
private async resolveMergeTargetBranch() {
|
||||
try {
|
||||
const originHead = await this.deps.executeCommand('git', [
|
||||
'symbolic-ref',
|
||||
'--quiet',
|
||||
'--short',
|
||||
'refs/remotes/origin/HEAD',
|
||||
]);
|
||||
|
||||
const value = originHead.stdout.trim();
|
||||
if (value) {
|
||||
return value.replace(/^origin\//, '');
|
||||
}
|
||||
} catch {
|
||||
// Fallback candidates below.
|
||||
}
|
||||
|
||||
for (const candidate of ['main', 'master']) {
|
||||
try {
|
||||
await this.deps.executeCommand('git', [
|
||||
'rev-parse',
|
||||
'--verify',
|
||||
candidate,
|
||||
]);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try next.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolveVariant(): Promise<VariantDescriptor> {
|
||||
const explicitVariant = await this.resolveConfiguredVariant();
|
||||
|
||||
if (explicitVariant) {
|
||||
return explicitVariant;
|
||||
}
|
||||
|
||||
const heuristicVariant = await this.resolveHeuristicVariant();
|
||||
|
||||
if (heuristicVariant) {
|
||||
return heuristicVariant;
|
||||
}
|
||||
|
||||
return this.mapVariant('next-supabase');
|
||||
}
|
||||
|
||||
private async resolveConfiguredVariant(): Promise<VariantDescriptor | null> {
|
||||
const configPath = '.makerkit/config.json';
|
||||
|
||||
if (!(await this.deps.pathExists(configPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = await this.readObject(configPath);
|
||||
|
||||
const value =
|
||||
this.readString(config, 'variant') ??
|
||||
this.readString(config, 'template') ??
|
||||
this.readString(config, 'kitVariant');
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapVariant(value, {
|
||||
preserveVariant: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveHeuristicVariant(): Promise<VariantDescriptor | null> {
|
||||
const hasSupabaseFolder = await this.deps.pathExists('apps/web/supabase');
|
||||
|
||||
if (!hasSupabaseFolder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appPackage = await this.readObject(
|
||||
join('apps', 'web', 'package.json'),
|
||||
);
|
||||
|
||||
const hasNextDependency = this.hasDependency(appPackage, 'next');
|
||||
|
||||
if (hasNextDependency) {
|
||||
return this.mapVariant('next-supabase');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private hasDependency(json: Record<string, unknown>, dependency: string) {
|
||||
const dependencies = this.readObjectValue(json, 'dependencies');
|
||||
const devDependencies = this.readObjectValue(json, 'devDependencies');
|
||||
|
||||
return Boolean(
|
||||
this.readString(dependencies, dependency) ||
|
||||
this.readString(devDependencies, dependency),
|
||||
);
|
||||
}
|
||||
|
||||
private mapVariant(
|
||||
variant: string,
|
||||
options: {
|
||||
preserveVariant?: boolean;
|
||||
} = {},
|
||||
): VariantDescriptor {
|
||||
if (variant === 'next-drizzle') {
|
||||
return {
|
||||
variant,
|
||||
variant_family: 'orm',
|
||||
framework: 'nextjs',
|
||||
database: 'postgresql',
|
||||
auth: 'better-auth',
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'next-prisma') {
|
||||
return {
|
||||
variant,
|
||||
variant_family: 'orm',
|
||||
framework: 'nextjs',
|
||||
database: 'postgresql',
|
||||
auth: 'better-auth',
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === 'react-router-supabase') {
|
||||
return {
|
||||
variant,
|
||||
variant_family: 'supabase',
|
||||
framework: 'react-router',
|
||||
database: 'supabase',
|
||||
auth: 'supabase',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
variant: options.preserveVariant ? variant : 'next-supabase',
|
||||
variant_family: 'supabase',
|
||||
framework: 'nextjs',
|
||||
database: 'supabase',
|
||||
auth: 'supabase',
|
||||
};
|
||||
}
|
||||
|
||||
private async readObject(path: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const value = await this.deps.readJsonFile(path);
|
||||
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private readString(obj: Record<string, unknown>, key: string) {
|
||||
const value = obj[key];
|
||||
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
private readObjectValue(obj: Record<string, unknown>, key: string) {
|
||||
const value = obj[key];
|
||||
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private getPackageManager(packageJson: Record<string, unknown>) {
|
||||
const packageManager = this.readString(packageJson, 'packageManager');
|
||||
|
||||
if (!packageManager) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const [name] = packageManager.split('@');
|
||||
return name || 'unknown';
|
||||
}
|
||||
}
|
||||
48
packages/mcp-server/src/tools/status/schema.ts
Normal file
48
packages/mcp-server/src/tools/status/schema.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
export const KitStatusInputSchema = z.object({});
|
||||
|
||||
export const KitStatusOutputSchema = z.object({
|
||||
variant: z.string(),
|
||||
variant_family: z.string(),
|
||||
framework: z.string(),
|
||||
database: z.string(),
|
||||
auth: z.string(),
|
||||
project_name: z.string(),
|
||||
node_version: z.string(),
|
||||
package_manager: z.string(),
|
||||
deps_installed: z.boolean(),
|
||||
git_clean: z.boolean(),
|
||||
git_branch: z.string(),
|
||||
git_modified_files: z.array(z.string()),
|
||||
git_untracked_files: z.array(z.string()),
|
||||
git_merge_check: z.object({
|
||||
target_branch: z.string().nullable(),
|
||||
detectable: z.boolean(),
|
||||
has_conflicts: z.boolean().nullable(),
|
||||
conflict_files: z.array(z.string()),
|
||||
message: z.string(),
|
||||
}),
|
||||
services: z.object({
|
||||
app: z.object({
|
||||
running: z.boolean(),
|
||||
port: z.number().nullable(),
|
||||
}),
|
||||
supabase: z.object({
|
||||
running: z.boolean(),
|
||||
api_port: z.number().nullable(),
|
||||
studio_port: z.number().nullable(),
|
||||
}),
|
||||
}),
|
||||
diagnostics: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(['pass', 'warn', 'fail']),
|
||||
message: z.string(),
|
||||
remedies: z.array(z.string()).default([]),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type KitStatusInput = z.infer<typeof KitStatusInputSchema>;
|
||||
export type KitStatusOutput = z.infer<typeof KitStatusOutputSchema>;
|
||||
Reference in New Issue
Block a user