Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 26 additions & 25 deletions README.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions eslint-rules/enforce-zod-v4.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use strict";
import path from "path";

// The file that is allowed to import from zod/v4
const configFilePath = path.resolve(import.meta.dirname, "../src/common/config.ts");
const schemasFilePath = path.resolve(import.meta.dirname, "../src/common/schemas.ts");

// Ref: https://eslint.org/docs/latest/extend/custom-rules
export default {
meta: {
type: "problem",
docs: {
description:
"Only allow importing 'zod/v4' in config.ts, all other imports are allowed elsewhere. We should only adopt zod v4 for tools and resources once https://github.com/modelcontextprotocol/typescript-sdk/issues/555 is resolved.",
recommended: true,
},
fixable: null,
messages: {
enforceZodV4:
"Only 'zod/v4' imports are allowed in config.ts. Found import from '{{importPath}}'. Use 'zod/v4' instead.",
},
},
create(context) {
const currentFilePath = path.resolve(context.getFilename());

if (currentFilePath === configFilePath || currentFilePath === schemasFilePath) {
return {};
}

return {
ImportDeclaration(node) {
const importPath = node.source.value;

// Check if this is a zod import
if (typeof importPath !== "string") {
return;
}

const isZodV4Import = importPath === "zod/v4";

if (isZodV4Import) {
context.report({
node,
messageId: "enforceZodV4",
data: {
importPath,
},
});
}
},
};
},
};
7 changes: 7 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import tseslint from "typescript-eslint";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import vitestPlugin from "@vitest/eslint-plugin";
import noConfigImports from "./eslint-rules/no-config-imports.js";
import enforceZodV4 from "./eslint-rules/enforce-zod-v4.js";

const testFiles = ["tests/**/*.test.ts", "tests/**/*.ts"];

Expand Down Expand Up @@ -72,9 +73,15 @@ export default defineConfig([
"no-config-imports": noConfigImports,
},
},
"enforce-zod-v4": {
rules: {
"enforce-zod-v4": enforceZodV4,
},
},
},
rules: {
"no-config-imports/no-config-imports": "error",
"enforce-zod-v4/enforce-zod-v4": "error",
},
},
globalIgnores([
Expand Down
142 changes: 114 additions & 28 deletions scripts/generateArguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
/**
* This script generates argument definitions and updates:
* - server.json arrays
* - TODO: README.md configuration table
* - README.md configuration table
*
* It uses the Zod schema and OPTIONS defined in src/common/config.ts
*/

import { readFileSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { OPTIONS, UserConfigSchema } from "../src/common/config.js";
import type { ZodObject, ZodRawShape } from "zod";
import { UserConfigSchema, configRegistry } from "../src/common/config.js";
import assert from "assert";
import { execSync } from "child_process";
import { OPTIONS } from "../src/common/argsParserOptions.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand All @@ -21,14 +23,12 @@ function camelCaseToSnakeCase(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
}

// List of configuration keys that contain sensitive/secret information
// List of mongosh OPTIONS that contain sensitive/secret information
// These should be redacted in logs and marked as secret in environment variable definitions
const SECRET_CONFIG_KEYS = new Set([
const SECRET_OPTIONS_KEYS = new Set([
"connectionString",
"username",
"password",
"apiClientId",
"apiClientSecret",
"tlsCAFile",
"tlsCertificateKeyFile",
"tlsCertificateKeyFilePassword",
Expand All @@ -37,56 +37,75 @@ const SECRET_CONFIG_KEYS = new Set([
"sslPEMKeyFile",
"sslPEMKeyPassword",
"sslCRLFile",
"voyageApiKey",
]);

interface EnvironmentVariable {
interface ArgumentInfo {
name: string;
description: string;
isRequired: boolean;
format: string;
isSecret: boolean;
configKey: string;
defaultValue?: unknown;
defaultValueDescription?: string;
}

interface ConfigMetadata {
description: string;
defaultValue?: unknown;
defaultValueDescription?: string;
isSecret?: boolean;
}

function extractZodDescriptions(): Record<string, ConfigMetadata> {
const result: Record<string, ConfigMetadata> = {};

// Get the shape of the Zod schema
const shape = (UserConfigSchema as ZodObject<ZodRawShape>).shape;
const shape = UserConfigSchema.shape;

for (const [key, fieldSchema] of Object.entries(shape)) {
const schema = fieldSchema;
// Extract description from Zod schema
const description = schema.description || `Configuration option: ${key}`;
let description = schema.description || `Configuration option: ${key}`;

if ("innerType" in schema.def) {
// "pipe" is used for our comma-separated arrays
if (schema.def.innerType.def.type === "pipe") {
assert(
description.startsWith("An array of"),
`Field description for field "${key}" with array type does not start with 'An array of'`
);
description = description.replace("An array of", "Comma separated values of");
}
}

// Extract default value if present
let defaultValue: unknown = undefined;
if (schema._def && "defaultValue" in schema._def) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
defaultValue = schema._def.defaultValue() as unknown;
let defaultValueDescription: string | undefined = undefined;
let isSecret: boolean | undefined = undefined;
if (schema.def && "defaultValue" in schema.def) {
defaultValue = schema.def.defaultValue;
}
// Get metadata from custom registry
const registryMeta = configRegistry.get(schema);
if (registryMeta) {
defaultValueDescription = registryMeta.defaultValueDescription;
isSecret = registryMeta.isSecret;
}

result[key] = {
description,
defaultValue,
defaultValueDescription,
isSecret,
};
}

return result;
}

function generateEnvironmentVariables(
options: typeof OPTIONS,
zodMetadata: Record<string, ConfigMetadata>
): EnvironmentVariable[] {
const envVars: EnvironmentVariable[] = [];
function getArgumentInfo(options: typeof OPTIONS, zodMetadata: Record<string, ConfigMetadata>): ArgumentInfo[] {
const argumentInfos: ArgumentInfo[] = [];
const processedKeys = new Set<string>();

// Helper to add env var
Expand All @@ -107,14 +126,15 @@ function generateEnvironmentVariables(
format = "string"; // Arrays are passed as comma-separated strings
}

envVars.push({
argumentInfos.push({
name: envVarName,
description: metadata.description,
isRequired: false,
format: format,
isSecret: SECRET_CONFIG_KEYS.has(key),
isSecret: metadata.isSecret ?? SECRET_OPTIONS_KEYS.has(key),
configKey: key,
defaultValue: metadata.defaultValue,
defaultValueDescription: metadata.defaultValueDescription,
});
};

Expand All @@ -139,10 +159,10 @@ function generateEnvironmentVariables(
}

// Sort by name for consistent output
return envVars.sort((a, b) => a.name.localeCompare(b.name));
return argumentInfos.sort((a, b) => a.name.localeCompare(b.name));
}

function generatePackageArguments(envVars: EnvironmentVariable[]): unknown[] {
function generatePackageArguments(envVars: ArgumentInfo[]): unknown[] {
const packageArguments: unknown[] = [];

// Generate positional arguments from the same config options (only documented ones)
Expand All @@ -168,7 +188,7 @@ function generatePackageArguments(envVars: EnvironmentVariable[]): unknown[] {
return packageArguments;
}

function updateServerJsonEnvVars(envVars: EnvironmentVariable[]): void {
function updateServerJsonEnvVars(envVars: ArgumentInfo[]): void {
const serverJsonPath = join(__dirname, "..", "server.json");
const packageJsonPath = join(__dirname, "..", "package.json");

Expand All @@ -179,7 +199,7 @@ function updateServerJsonEnvVars(envVars: EnvironmentVariable[]): void {
packages: {
registryType?: string;
identifier: string;
environmentVariables: EnvironmentVariable[];
environmentVariables: ArgumentInfo[];
packageArguments?: unknown[];
version?: string;
}[];
Expand Down Expand Up @@ -207,7 +227,7 @@ function updateServerJsonEnvVars(envVars: EnvironmentVariable[]): void {
// Update environmentVariables, packageArguments, and version for all packages
if (serverJson.packages && Array.isArray(serverJson.packages)) {
for (const pkg of serverJson.packages) {
pkg.environmentVariables = envVarsArray as EnvironmentVariable[];
pkg.environmentVariables = envVarsArray as ArgumentInfo[];
pkg.packageArguments = packageArguments;

// For OCI packages, update the version tag in the identifier and not a version field
Expand All @@ -224,11 +244,77 @@ function updateServerJsonEnvVars(envVars: EnvironmentVariable[]): void {
console.log(`✓ Updated server.json (version ${version})`);
}

function generateReadmeConfigTable(argumentInfos: ArgumentInfo[]): string {
const rows = [
"| CLI Option | Environment Variable | Default | Description |",
"| -------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |",
];

// Filter to only include options that are in the Zod schema (documented options)
const documentedVars = argumentInfos.filter((v) => !v.description.startsWith("Configuration option:"));

for (const argumentInfo of documentedVars) {
const cliOption = `\`${argumentInfo.configKey}\``;
const envVarName = `\`${argumentInfo.name}\``;

const defaultValue = argumentInfo.defaultValue;

let defaultValueString = argumentInfo.defaultValueDescription ?? "`<not set>`";
if (!argumentInfo.defaultValueDescription && defaultValue !== undefined && defaultValue !== null) {
if (Array.isArray(defaultValue)) {
defaultValueString = `\`"${defaultValue.join(",")}"\``;
} else {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (typeof defaultValue) {
case "number":
defaultValueString = `\`${defaultValue}\``;
break;
case "boolean":
defaultValueString = `\`${defaultValue}\``;
break;
case "string":
defaultValueString = `\`"${defaultValue}"\``;
break;
default:
throw new Error(`Unsupported default value type: ${typeof defaultValue}`);
}
}
}

const desc = argumentInfo.description.replace(/\|/g, "\\|"); // Escape pipes in description
rows.push(
`| ${cliOption.padEnd(38)} | ${envVarName.padEnd(51)} | ${defaultValueString.padEnd(75)} | ${desc.padEnd(199)} |`
);
}

return rows.join("\n");
}

function updateReadmeConfigTable(envVars: ArgumentInfo[]): void {
const readmePath = join(__dirname, "..", "README.md");
let content = readFileSync(readmePath, "utf-8");

const newTable = generateReadmeConfigTable(envVars);

// Find and replace the configuration options table
const tableRegex = /### Configuration Options\n\n\| CLI Option[\s\S]*?\n\n####/;
const replacement = `### Configuration Options\n\n${newTable}\n\n####`;

content = content.replace(tableRegex, replacement);

writeFileSync(readmePath, content, "utf-8");
console.log("✓ Updated README.md configuration table");

// Run prettier on the README.md file
execSync("npx prettier --write README.md", { cwd: join(__dirname, "..") });
}

function main(): void {
const zodMetadata = extractZodDescriptions();

const envVars = generateEnvironmentVariables(OPTIONS, zodMetadata);
updateServerJsonEnvVars(envVars);
const argumentInfo = getArgumentInfo(OPTIONS, zodMetadata);
updateServerJsonEnvVars(argumentInfo);
updateReadmeConfigTable(argumentInfo);
}

main();
Loading
Loading