-
Notifications
You must be signed in to change notification settings - Fork 167
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 all 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; | ||
| } | ||
| } | ||
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