Skip to content

Commit 0f2a594

Browse files
committed
feat: add ability to override parameters using HTTP headers MCP-293
1 parent 8acd47b commit 0f2a594

File tree

10 files changed

+1125
-138
lines changed

10 files changed

+1125
-138
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"generate": "npm run generate:api && npm run generate:arguments",
6666
"generate:api": "./scripts/generate.sh",
6767
"generate:arguments": "tsx scripts/generateArguments.ts",
68-
"test": "vitest --project eslint-rules --project unit-and-integration --coverage",
68+
"test": "vitest --project eslint-rules --project unit-and-integration --coverage --run",
6969
"pretest:accuracy": "npm run build",
7070
"test:accuracy": "sh ./scripts/accuracy/runAccuracyTests.sh",
7171
"test:long-running-tests": "vitest --project long-running-tests --coverage",
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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 { 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.
12+
*
13+
* @param baseConfig - The base user configuration
14+
* @param request - The request context containing headers and query parameters
15+
* @returns The configuration with overrides applied
16+
*/
17+
export function applyConfigOverrides({
18+
baseConfig,
19+
request,
20+
}: {
21+
baseConfig: UserConfig;
22+
request?: RequestContext;
23+
}): UserConfig {
24+
if (!request) {
25+
return baseConfig;
26+
}
27+
28+
const result: UserConfig = { ...baseConfig };
29+
const overridesFromHeaders = extractConfigOverrides("header", request.headers);
30+
const overridesFromQuery = extractConfigOverrides("query", request.query);
31+
32+
// Merge overrides, with query params taking precedence
33+
const allOverrides = { ...overridesFromHeaders, ...overridesFromQuery };
34+
35+
// Apply each override according to its behavior
36+
for (const [key, overrideValue] of Object.entries(allOverrides)) {
37+
assertValidConfigKey(key);
38+
const behavior = getConfigMeta(key)?.overrideBehavior || "not-allowed";
39+
const baseValue = baseConfig[key as keyof UserConfig];
40+
const newValue = applyOverride(key, baseValue, overrideValue, behavior);
41+
(result as any)[key] = newValue;
42+
}
43+
44+
return result;
45+
}
46+
47+
/**
48+
* Extracts config overrides from HTTP headers or query parameters.
49+
*/
50+
function extractConfigOverrides(
51+
mode: "header" | "query",
52+
source: Record<string, string | string[] | undefined> | undefined
53+
): Partial<Record<keyof typeof UserConfigSchema.shape, unknown>> {
54+
if (!source) {
55+
return {};
56+
}
57+
58+
const overrides: Partial<Record<keyof typeof UserConfigSchema.shape, unknown>> = {};
59+
60+
for (const [name, value] of Object.entries(source)) {
61+
const configKey = nameToConfigKey(mode, name);
62+
if (!configKey) {
63+
continue;
64+
}
65+
assertValidConfigKey(configKey);
66+
67+
const behavior = getConfigMeta(configKey)?.overrideBehavior || "not-allowed";
68+
if (behavior === "not-allowed") {
69+
throw new Error(`Config key ${configKey} is not allowed to be overridden`);
70+
}
71+
72+
const parsedValue = parseConfigValue(configKey, value);
73+
if (parsedValue !== undefined) {
74+
overrides[configKey] = parsedValue;
75+
}
76+
}
77+
78+
return overrides;
79+
}
80+
81+
function assertValidConfigKey(key: string): asserts key is keyof typeof UserConfigSchema.shape {
82+
if (!(key in UserConfigSchema.shape)) {
83+
throw new Error(`Invalid config key: ${key}`);
84+
}
85+
}
86+
87+
/**
88+
* Gets the schema metadata for a config key.
89+
*/
90+
export function getConfigMeta(key: keyof typeof UserConfigSchema.shape) {
91+
return configRegistry.get(UserConfigSchema.shape[key]);
92+
}
93+
94+
/**
95+
* Parses a string value to the appropriate type using the Zod schema.
96+
*/
97+
function parseConfigValue(key: keyof typeof UserConfigSchema.shape, value: unknown): unknown {
98+
const fieldSchema = UserConfigSchema.shape[key as keyof typeof UserConfigSchema.shape];
99+
if (!fieldSchema) {
100+
throw new Error(`Invalid config key: ${key}`);
101+
}
102+
103+
return fieldSchema.safeParse(value).data;
104+
}
105+
106+
/**
107+
* Converts a header/query name to its config key format.
108+
* Example: "x-mongodb-mcp-read-only" -> "readOnly"
109+
* Example: "mongodbMcpReadOnly" -> "readOnly"
110+
*/
111+
export function nameToConfigKey(mode: "header" | "query", name: string): string | undefined {
112+
const lowerCaseName = name.toLowerCase();
113+
114+
if (mode === "header" && lowerCaseName.startsWith(CONFIG_HEADER_PREFIX)) {
115+
const normalized = lowerCaseName.substring(CONFIG_HEADER_PREFIX.length);
116+
// Convert kebab-case to camelCase
117+
return normalized.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
118+
}
119+
if (mode === "query" && name.startsWith(CONFIG_QUERY_PREFIX)) {
120+
const withoutPrefix = name.substring(CONFIG_QUERY_PREFIX.length);
121+
// Convert first letter to lowercase to get config key
122+
return withoutPrefix.charAt(0).toLowerCase() + withoutPrefix.slice(1);
123+
}
124+
125+
return undefined;
126+
}
127+
128+
function applyOverride(
129+
key: keyof typeof UserConfigSchema.shape,
130+
baseValue: unknown,
131+
overrideValue: unknown,
132+
behavior: OverrideBehavior
133+
): unknown {
134+
if (typeof behavior === "function") {
135+
const shouldApply = behavior(baseValue, overrideValue);
136+
if (!shouldApply) {
137+
throw new Error(
138+
`Config override validation failed for ${key}: cannot override from ${JSON.stringify(baseValue)} to ${JSON.stringify(overrideValue)}`
139+
);
140+
}
141+
return overrideValue;
142+
}
143+
switch (behavior) {
144+
case "override":
145+
return overrideValue;
146+
147+
case "merge":
148+
if (Array.isArray(baseValue) && Array.isArray(overrideValue)) {
149+
return [...baseValue, ...overrideValue];
150+
}
151+
throw new Error("Cannot merge non-array values, did you mean to use the 'override' behavior?");
152+
153+
case "not-allowed":
154+
default:
155+
return baseValue;
156+
}
157+
}

src/common/config/configUtils.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ 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 true if the override should be applied, false otherwise.
9+
export type CustomOverrideLogic = (oldValue: unknown, newValue: unknown) => boolean;
10+
11+
/**
12+
* Defines how a config field can be overridden via HTTP headers or query parameters.
13+
*/
14+
export type OverrideBehavior =
15+
/// Cannot be overridden via request
16+
| "not-allowed"
17+
/// Can be completely replaced
18+
| "override"
19+
/// Values are merged (for arrays)
20+
| "merge"
21+
| CustomOverrideLogic;
22+
723
/**
824
* Metadata for config schema fields.
925
*/
@@ -17,7 +33,11 @@ export type ConfigFieldMeta = {
1733
* Secret fields will be marked as secret in environment variable definitions.
1834
*/
1935
isSecret?: boolean;
20-
36+
/**
37+
* Defines how this config field can be overridden via HTTP headers or query parameters.
38+
* Defaults to "not-allowed" for security.
39+
*/
40+
overrideBehavior?: OverrideBehavior;
2141
[key: string]: unknown;
2242
};
2343

@@ -69,8 +89,11 @@ export function commaSeparatedToArray<T extends string[]>(str: string | string[]
6989
return undefined;
7090
}
7191

72-
if (!Array.isArray(str)) {
73-
return [str] as T;
92+
if (typeof str === "string") {
93+
return str
94+
.split(",")
95+
.map((e) => e.trim())
96+
.filter((e) => e.length > 0) as T;
7497
}
7598

7699
if (str.length === 1) {
@@ -82,3 +105,22 @@ export function commaSeparatedToArray<T extends string[]>(str: string | string[]
82105

83106
return str as T;
84107
}
108+
109+
/**
110+
* Preprocessor for boolean values that handles string "true"/"false" correctly.
111+
* Zod's coerce.boolean() treats any non-empty string as true, which is not what we want.
112+
*/
113+
export function parseBoolean(val: unknown): unknown {
114+
if (typeof val === "string") {
115+
const lower = val.toLowerCase().trim();
116+
if (lower === "false" || lower === "0") return false;
117+
if (lower === "true" || lower === "1") return true;
118+
}
119+
if (typeof val === "boolean") {
120+
return val;
121+
}
122+
if (typeof val === "number") {
123+
return val !== 0;
124+
}
125+
return false;
126+
}

0 commit comments

Comments
 (0)