Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow

| CLI Option | Environment Variable | Default | Description |
| -------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowRequestOverrides` | `MDB_MCP_ALLOW_REQUEST_OVERRIDES` | `false` | When set to true, allows configuration values to be overridden via request headers and query parameters. |
| `apiClientId` | `MDB_MCP_API_CLIENT_ID` | `<not set>` | Atlas API client ID for authentication. Required for running Atlas tools. |
| `apiClientSecret` | `MDB_MCP_API_CLIENT_SECRET` | `<not set>` | Atlas API client secret for authentication. Required for running Atlas tools. |
| `atlasTemporaryDatabaseUserLifetimeMs` | `MDB_MCP_ATLAS_TEMPORARY_DATABASE_USER_LIFETIME_MS` | `14400000` | Time in milliseconds that temporary database users created when connecting to MongoDB Atlas clusters will remain active before being automatically deleted. |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
"generate:api": "./scripts/generate.sh",
"generate:arguments": "tsx scripts/generateArguments.ts",
"pretest": "pnpm run build",
"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

"test:accuracy": "sh ./scripts/accuracy/runAccuracyTests.sh",
"test:long-running-tests": "vitest --project long-running-tests --coverage",
"test:local": "SKIP_ATLAS_TESTS=true SKIP_ATLAS_LOCAL_TESTS=true pnpm run test",
Expand Down
26 changes: 26 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
"type": "stdio"
},
"environmentVariables": [
{
"name": "MDB_MCP_ALLOW_REQUEST_OVERRIDES",
"description": "When set to true, allows configuration values to be overridden via request headers and query parameters.",
"isRequired": false,
"format": "string",
"isSecret": false
},
{
"name": "MDB_MCP_API_CLIENT_ID",
"description": "Atlas API client ID for authentication. Required for running Atlas tools.",
Expand Down Expand Up @@ -186,6 +193,12 @@
}
],
"packageArguments": [
{
"type": "named",
"name": "--allowRequestOverrides",
"description": "When set to true, allows configuration values to be overridden via request headers and query parameters.",
"isRequired": false
},
{
"type": "named",
"name": "--apiClientId",
Expand Down Expand Up @@ -344,6 +357,13 @@
"type": "stdio"
},
"environmentVariables": [
{
"name": "MDB_MCP_ALLOW_REQUEST_OVERRIDES",
"description": "When set to true, allows configuration values to be overridden via request headers and query parameters.",
"isRequired": false,
"format": "string",
"isSecret": false
},
{
"name": "MDB_MCP_API_CLIENT_ID",
"description": "Atlas API client ID for authentication. Required for running Atlas tools.",
Expand Down Expand Up @@ -514,6 +534,12 @@
}
],
"packageArguments": [
{
"type": "named",
"name": "--allowRequestOverrides",
"description": "When set to true, allows configuration values to be overridden via request headers and query parameters.",
"isRequired": false
},
{
"type": "named",
"name": "--apiClientId",
Expand Down
1 change: 1 addition & 0 deletions src/common/config/argsParserOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const OPTIONS = {
"connectionString",
"httpHost",
"httpPort",
"allowRequestOverrides",
"idleTimeoutMs",
"logPath",
"notificationTimeoutMs",
Expand Down
178 changes: 178 additions & 0 deletions src/common/config/configOverrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { UserConfig } from "./userConfig.js";
import { UserConfigSchema, configRegistry } from "./userConfig.js";
import type { RequestContext } from "../../transports/base.js";
import type { ConfigFieldMeta, 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. Can be used within the createSessionConfig
* hook to manually apply the overrides. Requires `allowRequestOverrides` to be enabled.
*
* @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);

// Only apply overrides if allowRequestOverrides is enabled
if (
!baseConfig.allowRequestOverrides &&
(Object.keys(overridesFromHeaders).length > 0 || Object.keys(overridesFromQuery).length > 0)
) {
throw new Error("Request overrides are not enabled");
}

// Apply header overrides first
for (const [key, overrideValue] of Object.entries(overridesFromHeaders)) {
assertValidConfigKey(key);
const meta = getConfigMeta(key);
const behavior = meta?.overrideBehavior || "not-allowed";
const baseValue = baseConfig[key as keyof UserConfig];
const newValue = applyOverride(key, baseValue, overrideValue, behavior);
(result as Record<keyof UserConfig, unknown>)[key] = newValue;
}

// Apply query overrides (with precedence), but block secret fields
for (const [key, overrideValue] of Object.entries(overridesFromQuery)) {
assertValidConfigKey(key);
const meta = getConfigMeta(key);

// Prevent overriding secret fields via query params
if (meta?.isSecret) {
throw new Error(`Config key ${key} can only be overriden with headers.`);
}

const behavior = meta?.overrideBehavior || "not-allowed";
const baseValue = baseConfig[key as keyof UserConfig];
const newValue = applyOverride(key, baseValue, overrideValue, behavior);
(result as Record<keyof UserConfig, unknown>)[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 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): ConfigFieldMeta | undefined {
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];
if (!fieldSchema) {
throw new Error(`Invalid config key: ${key}`);
}

return fieldSchema.safeParse(value).data;
Copy link
Collaborator

Choose a reason for hiding this comment

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

@gagik the safeparse would silently insert undefined when value is incorrect here

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

that seems fine right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ah, right we could error in way which is more useful

Copy link
Collaborator

Choose a reason for hiding this comment

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

yea I think we should error out instead because otherwise we might be overriding with potentially wrong values.

}

/**
* 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: string) => 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}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
switch (behavior) {
case "override":
return overrideValue;

case "merge":
if (Array.isArray(baseValue) && Array.isArray(overrideValue)) {
return [...(baseValue as unknown[]), ...(overrideValue as unknown[])];
}
throw new Error(`Cannot merge non-array values for ${key}`);

case "not-allowed":
throw new Error(`Config key ${key} is not allowed to be overridden`);
default:
return baseValue;
}
}
83 changes: 79 additions & 4 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 @@ -91,12 +112,17 @@ export function commaSeparatedToArray<T extends string[]>(str: string | string[]
* Zod's coerce.boolean() treats any non-empty string as true, which is not what we want.
*/
export function parseBoolean(val: unknown): unknown {
if (val === undefined) {
return undefined;
}
if (typeof val === "string") {
const lower = val.toLowerCase().trim();
if (lower === "false") {
if (val === "false") {
return false;
}
return true;
if (val === "true") {
return true;
}
throw new Error(`Invalid boolean value: ${val}`);
}
if (typeof val === "boolean") {
return val;
Expand All @@ -106,3 +132,52 @@ export function parseBoolean(val: unknown): unknown {
}
return !!val;
}

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

/** Allow overriding only to a value lower than the specified value */
export function onlyLowerThanBaseValueOverride(): CustomOverrideLogic {
return (oldValue, newValue) => {
if (typeof oldValue !== "number") {
throw new Error(`Unsupported type for base value for override: ${typeof oldValue}`);
}
if (typeof newValue !== "number") {
throw new Error(`Unsupported type for new value for override: ${typeof newValue}`);
}
if (newValue >= oldValue) {
throw new Error(`Can only set to a value lower than the base value`);
}
return newValue;
};
}

/** Allow overriding only to a subset of an array but not a superset */
export function onlySubsetOfBaseValueOverride(): CustomOverrideLogic {
return (oldValue, newValue) => {
if (!Array.isArray(oldValue)) {
throw new Error(`Unsupported type for base value for override: ${typeof oldValue}`);
}
if (!Array.isArray(newValue)) {
throw new Error(`Unsupported type for new value for override: ${typeof newValue}`);
}
if (newValue.length > oldValue.length) {
throw new Error(`Can only override to a subset of the base value`);
}
if (!newValue.every((value) => oldValue.includes(value))) {
throw new Error(`Can only override to a subset of the base value`);
}
return newValue as unknown;
};
}
Loading