Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"generate": "npm run generate:api && npm run generate:arguments",
"generate:api": "./scripts/generate.sh",
"generate:arguments": "tsx scripts/generateArguments.ts",
"test": "vitest --project eslint-rules --project unit-and-integration --coverage",
"test": "vitest --project eslint-rules --project unit-and-integration --coverage --run",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by: makes it easier to for LLMs to run this... we can have test:watch if needed

"pretest:accuracy": "npm run build",
"test:accuracy": "sh ./scripts/accuracy/runAccuracyTests.sh",
"test:long-running-tests": "vitest --project long-running-tests --coverage",
Expand Down
159 changes: 159 additions & 0 deletions src/common/config/configOverrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { UserConfig } from "./userConfig.js";
import { UserConfigSchema, configRegistry } from "./userConfig.js";
import type { RequestContext } from "../../transports/base.js";
import type { OverrideBehavior } from "./configUtils.js";

export const CONFIG_HEADER_PREFIX = "x-mongodb-mcp-";
export const CONFIG_QUERY_PREFIX = "mongodbMcp";

/**
* Applies config overrides from request context (headers and query parameters).
* Query parameters take precedence over headers.
*
* @param baseConfig - The base user configuration
* @param request - The request context containing headers and query parameters
* @returns The configuration with overrides applied
*/
export function applyConfigOverrides({
baseConfig,
request,
}: {
baseConfig: UserConfig;
request?: RequestContext;
}): UserConfig {
if (!request) {
return baseConfig;
}

const result: UserConfig = { ...baseConfig };
const overridesFromHeaders = extractConfigOverrides("header", request.headers);
const overridesFromQuery = extractConfigOverrides("query", request.query);

// Merge overrides, with query params taking precedence
const allOverrides = { ...overridesFromHeaders, ...overridesFromQuery };

// Apply each override according to its behavior
for (const [key, overrideValue] of Object.entries(allOverrides)) {
assertValidConfigKey(key);
const behavior = getConfigMeta(key)?.overrideBehavior || "not-allowed";
const baseValue = baseConfig[key as keyof UserConfig];
const newValue = applyOverride(key, baseValue, overrideValue, behavior);
(result as any)[key] = newValue;
}

return result;
}

/**
* Extracts config overrides from HTTP headers or query parameters.
*/
function extractConfigOverrides(
mode: "header" | "query",
source: Record<string, string | string[] | undefined> | undefined
): Partial<Record<keyof typeof UserConfigSchema.shape, unknown>> {
if (!source) {
return {};
}

const overrides: Partial<Record<keyof typeof UserConfigSchema.shape, unknown>> = {};

for (const [name, value] of Object.entries(source)) {
const configKey = nameToConfigKey(mode, name);
if (!configKey) {
continue;
}
assertValidConfigKey(configKey);

const behavior = getConfigMeta(configKey)?.overrideBehavior || "not-allowed";
if (behavior === "not-allowed") {
throw new Error(`Config key ${configKey} is not allowed to be overridden`);
}

const parsedValue = parseConfigValue(configKey, value);
if (parsedValue !== undefined) {
overrides[configKey] = parsedValue;
}
}

return overrides;
}

function assertValidConfigKey(key: string): asserts key is keyof typeof UserConfigSchema.shape {
if (!(key in UserConfigSchema.shape)) {
throw new Error(`Invalid config key: ${key}`);
}
}

/**
* Gets the schema metadata for a config key.
*/
export function getConfigMeta(key: keyof typeof UserConfigSchema.shape) {
return configRegistry.get(UserConfigSchema.shape[key]);
}

/**
* Parses a string value to the appropriate type using the Zod schema.
*/
function parseConfigValue(key: keyof typeof UserConfigSchema.shape, value: unknown): unknown {
const fieldSchema = UserConfigSchema.shape[key as keyof typeof UserConfigSchema.shape];
if (!fieldSchema) {
throw new Error(`Invalid config key: ${key}`);
}

return fieldSchema.safeParse(value).data;
}

/**
* Converts a header/query name to its config key format.
* Example: "x-mongodb-mcp-read-only" -> "readOnly"
* Example: "mongodbMcpReadOnly" -> "readOnly"
*/
export function nameToConfigKey(mode: "header" | "query", name: string): string | undefined {
const lowerCaseName = name.toLowerCase();

if (mode === "header" && lowerCaseName.startsWith(CONFIG_HEADER_PREFIX)) {
const normalized = lowerCaseName.substring(CONFIG_HEADER_PREFIX.length);
// Convert kebab-case to camelCase
return normalized.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}
if (mode === "query" && name.startsWith(CONFIG_QUERY_PREFIX)) {
const withoutPrefix = name.substring(CONFIG_QUERY_PREFIX.length);
// Convert first letter to lowercase to get config key
return withoutPrefix.charAt(0).toLowerCase() + withoutPrefix.slice(1);
}

return undefined;
}

function applyOverride(
key: keyof typeof UserConfigSchema.shape,
baseValue: unknown,
overrideValue: unknown,
behavior: OverrideBehavior
): unknown {
if (typeof behavior === "function") {
// Custom logic function returns the value to use (potentially transformed)
// or throws an error if the override cannot be applied
try {
return behavior(baseValue, overrideValue);
} catch (error) {
throw new Error(
`Cannot apply override for ${key} from ${JSON.stringify(baseValue)} to ${JSON.stringify(overrideValue)}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
switch (behavior) {
case "override":
return overrideValue;

case "merge":
if (Array.isArray(baseValue) && Array.isArray(overrideValue)) {
return [...baseValue, ...overrideValue];
}
throw new Error("Cannot merge non-array values, did you mean to use the 'override' behavior?");

case "not-allowed":
default:
return baseValue;
}
}
60 changes: 57 additions & 3 deletions src/common/config/configUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ import { ALL_CONFIG_KEYS } from "./argsParserOptions.js";
import * as levenshteinModule from "ts-levenshtein";
const levenshtein = levenshteinModule.default;

/// Custom logic function to apply the override value.
/// Returns the value to use (which may be transformed from newValue).
/// Should throw an error if the override cannot be applied.
export type CustomOverrideLogic = (oldValue: unknown, newValue: unknown) => unknown;

/**
* Defines how a config field can be overridden via HTTP headers or query parameters.
*/
export type OverrideBehavior =
/// Cannot be overridden via request
| "not-allowed"
/// Can be completely replaced
| "override"
/// Values are merged (for arrays)
| "merge"
| CustomOverrideLogic;

/**
* Metadata for config schema fields.
*/
Expand All @@ -17,7 +34,11 @@ export type ConfigFieldMeta = {
* Secret fields will be marked as secret in environment variable definitions.
*/
isSecret?: boolean;

/**
* Defines how this config field can be overridden via HTTP headers or query parameters.
* Defaults to "not-allowed" for security.
*/
overrideBehavior?: OverrideBehavior;
[key: string]: unknown;
};

Expand Down Expand Up @@ -69,8 +90,11 @@ export function commaSeparatedToArray<T extends string[]>(str: string | string[]
return undefined;
}

if (!Array.isArray(str)) {
return [str] as T;
if (typeof str === "string") {
return str
.split(",")
.map((e) => e.trim())
.filter((e) => e.length > 0) as T;
}

if (str.length === 1) {
Expand All @@ -82,3 +106,33 @@ export function commaSeparatedToArray<T extends string[]>(str: string | string[]

return str as T;
}

/**
* Preprocessor for boolean values that handles string "true"/"false" correctly.
* Zod's coerce.boolean() treats any non-empty string as true, which is not what we want.
*/
export function parseBoolean(val: unknown): unknown {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function appears to treat any non-empty string as true too (except false and 0) - is this intentional?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, this was a good balance between making sure old behaviors will work similar way (so if someone has --readonly=yes before, it'd still be true and not suddenly become false. while also making sure intentional setting of the value works too.
This helps us have the same logic for CLI and request parsing/validation. Though open to adjusting if there's concerns here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should just error out if the string value is different from true/false/1/0? Because based on the same logic, if we have --readonly=no, that would now make the server readonly.

Copy link
Collaborator Author

@gagik gagik Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to this but it does mean this is a breaking change.
in latest v1.2.0 --readOnly no would make the server readOnly
--readOnly false would make the server write-able

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the question is if this is intentional - I would say this is more of a bugfix than a breaking change. Additionally, if we throw, it won't be a silent breaking change - we'll give users feedback about what to fix, which I think is a reasonable compromise.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay apparently this preprocessing only really applies to these headers.
everything else is configured by yargs-parser... so it's about changing settings there

Copy link
Collaborator Author

@gagik gagik Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed logic to be:

  • if "true" or "false" -> parse into boolean
  • any other value error with invalid boolean

note: this doesn't affect our CLI passing, this only affects query params. CLI stuff is handled by yargs-parser

if (typeof val === "string") {
const lower = val.toLowerCase().trim();
if (lower === "false" || lower === "0") return false;
if (lower === "true" || lower === "1") return true;
}
if (typeof val === "boolean") {
return val;
}
if (typeof val === "number") {
return val !== 0;
}
return false;
}

/** Allow overriding only to the allowed value */
export function oneWayOverride<T>(allowedValue: T): CustomOverrideLogic {
return (oldValue, newValue) => {
// Only allow override if setting to true from false
if (newValue === allowedValue) {
return newValue;
}
throw new Error(`Can only set to ${allowedValue ? "true" : "false"}`);
};
}
Loading
Loading