Skip to content

Commit 3a21cd8

Browse files
authored
feat: add ability to override parameters using HTTP headers MCP-293 (#748)
1 parent b7bba29 commit 3a21cd8

File tree

16 files changed

+1574
-141
lines changed

16 files changed

+1574
-141
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow
346346

347347
| CLI Option | Environment Variable | Default | Description |
348348
| -------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
349+
| `allowRequestOverrides` | `MDB_MCP_ALLOW_REQUEST_OVERRIDES` | `false` | When set to true, allows configuration values to be overridden via request headers and query parameters. |
349350
| `apiClientId` | `MDB_MCP_API_CLIENT_ID` | `<not set>` | Atlas API client ID for authentication. Required for running Atlas tools. |
350351
| `apiClientSecret` | `MDB_MCP_API_CLIENT_SECRET` | `<not set>` | Atlas API client secret for authentication. Required for running Atlas tools. |
351352
| `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. |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
"generate:api": "./scripts/generate.sh",
6868
"generate:arguments": "tsx scripts/generateArguments.ts",
6969
"pretest": "pnpm run build",
70-
"test": "vitest --project eslint-rules --project unit-and-integration --coverage",
70+
"test": "vitest --project eslint-rules --project unit-and-integration --coverage --run",
7171
"test:accuracy": "sh ./scripts/accuracy/runAccuracyTests.sh",
7272
"test:long-running-tests": "vitest --project long-running-tests --coverage",
7373
"test:local": "SKIP_ATLAS_TESTS=true SKIP_ATLAS_LOCAL_TESTS=true pnpm run test",

server.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
"type": "stdio"
1717
},
1818
"environmentVariables": [
19+
{
20+
"name": "MDB_MCP_ALLOW_REQUEST_OVERRIDES",
21+
"description": "When set to true, allows configuration values to be overridden via request headers and query parameters.",
22+
"isRequired": false,
23+
"format": "string",
24+
"isSecret": false
25+
},
1926
{
2027
"name": "MDB_MCP_API_CLIENT_ID",
2128
"description": "Atlas API client ID for authentication. Required for running Atlas tools.",
@@ -186,6 +193,12 @@
186193
}
187194
],
188195
"packageArguments": [
196+
{
197+
"type": "named",
198+
"name": "--allowRequestOverrides",
199+
"description": "When set to true, allows configuration values to be overridden via request headers and query parameters.",
200+
"isRequired": false
201+
},
189202
{
190203
"type": "named",
191204
"name": "--apiClientId",
@@ -344,6 +357,13 @@
344357
"type": "stdio"
345358
},
346359
"environmentVariables": [
360+
{
361+
"name": "MDB_MCP_ALLOW_REQUEST_OVERRIDES",
362+
"description": "When set to true, allows configuration values to be overridden via request headers and query parameters.",
363+
"isRequired": false,
364+
"format": "string",
365+
"isSecret": false
366+
},
347367
{
348368
"name": "MDB_MCP_API_CLIENT_ID",
349369
"description": "Atlas API client ID for authentication. Required for running Atlas tools.",
@@ -514,6 +534,12 @@
514534
}
515535
],
516536
"packageArguments": [
537+
{
538+
"type": "named",
539+
"name": "--allowRequestOverrides",
540+
"description": "When set to true, allows configuration values to be overridden via request headers and query parameters.",
541+
"isRequired": false
542+
},
517543
{
518544
"type": "named",
519545
"name": "--apiClientId",

src/common/config/argsParserOptions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const OPTIONS = {
1818
"connectionString",
1919
"httpHost",
2020
"httpPort",
21+
"allowRequestOverrides",
2122
"idleTimeoutMs",
2223
"logPath",
2324
"notificationTimeoutMs",
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type { UserConfig } from "./userConfig.js";
2+
import { UserConfigSchema, configRegistry } from "./userConfig.js";
3+
import type { RequestContext } from "../../transports/base.js";
4+
import type { ConfigFieldMeta, OverrideBehavior } from "./configUtils.js";
5+
6+
export const CONFIG_HEADER_PREFIX = "x-mongodb-mcp-";
7+
export const CONFIG_QUERY_PREFIX = "mongodbMcp";
8+
9+
/**
10+
* Applies config overrides from request context (headers and query parameters).
11+
* Query parameters take precedence over headers. Can be used within the createSessionConfig
12+
* hook to manually apply the overrides. Requires `allowRequestOverrides` to be enabled.
13+
*
14+
* @param baseConfig - The base user configuration
15+
* @param request - The request context containing headers and query parameters
16+
* @returns The configuration with overrides applied
17+
*/
18+
export function applyConfigOverrides({
19+
baseConfig,
20+
request,
21+
}: {
22+
baseConfig: UserConfig;
23+
request?: RequestContext;
24+
}): UserConfig {
25+
if (!request) {
26+
return baseConfig;
27+
}
28+
29+
const result: UserConfig = { ...baseConfig };
30+
const overridesFromHeaders = extractConfigOverrides("header", request.headers);
31+
const overridesFromQuery = extractConfigOverrides("query", request.query);
32+
33+
// Only apply overrides if allowRequestOverrides is enabled
34+
if (
35+
!baseConfig.allowRequestOverrides &&
36+
(Object.keys(overridesFromHeaders).length > 0 || Object.keys(overridesFromQuery).length > 0)
37+
) {
38+
throw new Error("Request overrides are not enabled");
39+
}
40+
41+
// Apply header overrides first
42+
for (const [key, overrideValue] of Object.entries(overridesFromHeaders)) {
43+
assertValidConfigKey(key);
44+
const meta = getConfigMeta(key);
45+
const behavior = meta?.overrideBehavior || "not-allowed";
46+
const baseValue = baseConfig[key as keyof UserConfig];
47+
const newValue = applyOverride(key, baseValue, overrideValue, behavior);
48+
(result as Record<keyof UserConfig, unknown>)[key] = newValue;
49+
}
50+
51+
// Apply query overrides (with precedence), but block secret fields
52+
for (const [key, overrideValue] of Object.entries(overridesFromQuery)) {
53+
assertValidConfigKey(key);
54+
const meta = getConfigMeta(key);
55+
56+
// Prevent overriding secret fields via query params
57+
if (meta?.isSecret) {
58+
throw new Error(`Config key ${key} can only be overriden with headers.`);
59+
}
60+
61+
const behavior = meta?.overrideBehavior || "not-allowed";
62+
const baseValue = baseConfig[key as keyof UserConfig];
63+
const newValue = applyOverride(key, baseValue, overrideValue, behavior);
64+
(result as Record<keyof UserConfig, unknown>)[key] = newValue;
65+
}
66+
67+
return result;
68+
}
69+
70+
/**
71+
* Extracts config overrides from HTTP headers or query parameters.
72+
*/
73+
function extractConfigOverrides(
74+
mode: "header" | "query",
75+
source: Record<string, string | string[] | undefined> | undefined
76+
): Partial<Record<keyof typeof UserConfigSchema.shape, unknown>> {
77+
if (!source) {
78+
return {};
79+
}
80+
81+
const overrides: Partial<Record<keyof typeof UserConfigSchema.shape, unknown>> = {};
82+
83+
for (const [name, value] of Object.entries(source)) {
84+
const configKey = nameToConfigKey(mode, name);
85+
if (!configKey) {
86+
continue;
87+
}
88+
assertValidConfigKey(configKey);
89+
90+
const parsedValue = parseConfigValue(configKey, value);
91+
if (parsedValue !== undefined) {
92+
overrides[configKey] = parsedValue;
93+
}
94+
}
95+
96+
return overrides;
97+
}
98+
99+
function assertValidConfigKey(key: string): asserts key is keyof typeof UserConfigSchema.shape {
100+
if (!(key in UserConfigSchema.shape)) {
101+
throw new Error(`Invalid config key: ${key}`);
102+
}
103+
}
104+
105+
/**
106+
* Gets the schema metadata for a config key.
107+
*/
108+
export function getConfigMeta(key: keyof typeof UserConfigSchema.shape): ConfigFieldMeta | undefined {
109+
return configRegistry.get(UserConfigSchema.shape[key]);
110+
}
111+
112+
/**
113+
* Parses a string value to the appropriate type using the Zod schema.
114+
*/
115+
function parseConfigValue(key: keyof typeof UserConfigSchema.shape, value: unknown): unknown {
116+
const fieldSchema = UserConfigSchema.shape[key];
117+
if (!fieldSchema) {
118+
throw new Error(`Invalid config key: ${key}`);
119+
}
120+
121+
return fieldSchema.safeParse(value).data;
122+
}
123+
124+
/**
125+
* Converts a header/query name to its config key format.
126+
* Example: "x-mongodb-mcp-read-only" -> "readOnly"
127+
* Example: "mongodbMcpReadOnly" -> "readOnly"
128+
*/
129+
export function nameToConfigKey(mode: "header" | "query", name: string): string | undefined {
130+
const lowerCaseName = name.toLowerCase();
131+
132+
if (mode === "header" && lowerCaseName.startsWith(CONFIG_HEADER_PREFIX)) {
133+
const normalized = lowerCaseName.substring(CONFIG_HEADER_PREFIX.length);
134+
// Convert kebab-case to camelCase
135+
return normalized.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
136+
}
137+
if (mode === "query" && name.startsWith(CONFIG_QUERY_PREFIX)) {
138+
const withoutPrefix = name.substring(CONFIG_QUERY_PREFIX.length);
139+
// Convert first letter to lowercase to get config key
140+
return withoutPrefix.charAt(0).toLowerCase() + withoutPrefix.slice(1);
141+
}
142+
143+
return undefined;
144+
}
145+
146+
function applyOverride(
147+
key: keyof typeof UserConfigSchema.shape,
148+
baseValue: unknown,
149+
overrideValue: unknown,
150+
behavior: OverrideBehavior
151+
): unknown {
152+
if (typeof behavior === "function") {
153+
// Custom logic function returns the value to use (potentially transformed)
154+
// or throws an error if the override cannot be applied
155+
try {
156+
return behavior(baseValue, overrideValue);
157+
} catch (error) {
158+
throw new Error(
159+
`Cannot apply override for ${key}: ${error instanceof Error ? error.message : String(error)}`
160+
);
161+
}
162+
}
163+
switch (behavior) {
164+
case "override":
165+
return overrideValue;
166+
167+
case "merge":
168+
if (Array.isArray(baseValue) && Array.isArray(overrideValue)) {
169+
return [...(baseValue as unknown[]), ...(overrideValue as unknown[])];
170+
}
171+
throw new Error(`Cannot merge non-array values for ${key}`);
172+
173+
case "not-allowed":
174+
throw new Error(`Config key ${key} is not allowed to be overridden`);
175+
default:
176+
return baseValue;
177+
}
178+
}

src/common/config/configUtils.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ import { ALL_CONFIG_KEYS } from "./argsParserOptions.js";
44
import * as levenshteinModule from "ts-levenshtein";
55
const levenshtein = levenshteinModule.default;
66

7+
/// Custom logic function to apply the override value.
8+
/// Returns the value to use (which may be transformed from newValue).
9+
/// Should throw an error if the override cannot be applied.
10+
export type CustomOverrideLogic = (oldValue: unknown, newValue: unknown) => unknown;
11+
12+
/**
13+
* Defines how a config field can be overridden via HTTP headers or query parameters.
14+
*/
15+
export type OverrideBehavior =
16+
/// Cannot be overridden via request
17+
| "not-allowed"
18+
/// Can be completely replaced
19+
| "override"
20+
/// Values are merged (for arrays)
21+
| "merge"
22+
| CustomOverrideLogic;
23+
724
/**
825
* Metadata for config schema fields.
926
*/
@@ -17,7 +34,11 @@ export type ConfigFieldMeta = {
1734
* Secret fields will be marked as secret in environment variable definitions.
1835
*/
1936
isSecret?: boolean;
20-
37+
/**
38+
* Defines how this config field can be overridden via HTTP headers or query parameters.
39+
* Defaults to "not-allowed" for security.
40+
*/
41+
overrideBehavior?: OverrideBehavior;
2142
[key: string]: unknown;
2243
};
2344

@@ -91,12 +112,17 @@ export function commaSeparatedToArray<T extends string[]>(str: string | string[]
91112
* Zod's coerce.boolean() treats any non-empty string as true, which is not what we want.
92113
*/
93114
export function parseBoolean(val: unknown): unknown {
115+
if (val === undefined) {
116+
return undefined;
117+
}
94118
if (typeof val === "string") {
95-
const lower = val.toLowerCase().trim();
96-
if (lower === "false") {
119+
if (val === "false") {
97120
return false;
98121
}
99-
return true;
122+
if (val === "true") {
123+
return true;
124+
}
125+
throw new Error(`Invalid boolean value: ${val}`);
100126
}
101127
if (typeof val === "boolean") {
102128
return val;
@@ -106,3 +132,52 @@ export function parseBoolean(val: unknown): unknown {
106132
}
107133
return !!val;
108134
}
135+
136+
/** Allow overriding only to the allowed value */
137+
export function oneWayOverride<T>(allowedValue: T): CustomOverrideLogic {
138+
return (oldValue, newValue) => {
139+
// Only allow override if setting to allowed value or current value
140+
if (newValue === oldValue) {
141+
return newValue;
142+
}
143+
if (newValue === allowedValue) {
144+
return newValue;
145+
}
146+
throw new Error(`Can only set to ${String(allowedValue)}`);
147+
};
148+
}
149+
150+
/** Allow overriding only to a value lower than the specified value */
151+
export function onlyLowerThanBaseValueOverride(): CustomOverrideLogic {
152+
return (oldValue, newValue) => {
153+
if (typeof oldValue !== "number") {
154+
throw new Error(`Unsupported type for base value for override: ${typeof oldValue}`);
155+
}
156+
if (typeof newValue !== "number") {
157+
throw new Error(`Unsupported type for new value for override: ${typeof newValue}`);
158+
}
159+
if (newValue >= oldValue) {
160+
throw new Error(`Can only set to a value lower than the base value`);
161+
}
162+
return newValue;
163+
};
164+
}
165+
166+
/** Allow overriding only to a subset of an array but not a superset */
167+
export function onlySubsetOfBaseValueOverride(): CustomOverrideLogic {
168+
return (oldValue, newValue) => {
169+
if (!Array.isArray(oldValue)) {
170+
throw new Error(`Unsupported type for base value for override: ${typeof oldValue}`);
171+
}
172+
if (!Array.isArray(newValue)) {
173+
throw new Error(`Unsupported type for new value for override: ${typeof newValue}`);
174+
}
175+
if (newValue.length > oldValue.length) {
176+
throw new Error(`Can only override to a subset of the base value`);
177+
}
178+
if (!newValue.every((value) => oldValue.includes(value))) {
179+
throw new Error(`Can only override to a subset of the base value`);
180+
}
181+
return newValue as unknown;
182+
};
183+
}

0 commit comments

Comments
 (0)