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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user