-
Notifications
You must be signed in to change notification settings - Fork 168
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
Changes from 21 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,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"; | ||
|
|
||
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. 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>> { | ||
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 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; | ||
|
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. @gagik the safeparse would silently insert
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. that seems fine right?
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. ah, right we could error in way which is more useful
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. 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; | ||
| } | ||
| } | ||
| 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; | ||
| }; | ||
|
|
||
|
|
@@ -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 { | ||
|
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 (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; | ||
|
|
@@ -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; | ||
| }; | ||
| } | ||
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