-
Notifications
You must be signed in to change notification settings - Fork 166
feat: add ability to override parameters using HTTP headers MCP-293 #748
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
0f2a594
132876e
f600d2a
1a3aab2
3bce26e
c57dea4
c21a707
82f38d0
94b7863
1b284e9
b094b83
acb1eba
83af6bc
24e8e92
386be02
e6ab659
e752751
389eabd
d2731d8
d11ec6b
bd93e3d
dbacbda
3168bd5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
|
||
gagik marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /** | ||
| * 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>> { | ||
addaleax marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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)}` | ||
gagik marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ); | ||
| } | ||
| } | ||
| 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?"); | ||
gagik marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| case "not-allowed": | ||
| default: | ||
| return baseValue; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| */ | ||
|
|
@@ -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; | ||
| }; | ||
|
|
||
|
|
@@ -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) { | ||
|
|
@@ -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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. okay apparently this preprocessing only really applies to these headers.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. changed logic to be:
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; | ||
gagik marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| 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"}`); | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
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:watchif needed