@W-21952572: Add support for request level overrides#329
Conversation
| restApiArgs: { ...extra }, | ||
| }) | ||
| ).boundedContext.projectIds | ||
| (await extra.getConfigWithOverrides()).boundedContext.projectIds |
There was a problem hiding this comment.
now calls extra.getConfigWithOverrides from the tool registration, since it is setup to use any request overrides that appear in the tool invocation request.
| import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; | ||
|
|
||
| const overridableVariables = [ | ||
| export const overridableVariables = [ |
There was a problem hiding this comment.
there's a lot of changes in this file, I might just recommend reviewing the entire file by itself instead of the diff view. my goal is to have as many comments to provide context on what's going on here, so if something isn't totally clear leave a comment so I can better document that.
most code is the same logic as before, but cleaned up and with more logic added onto it that covers request overrides.
One change to preexisting logic / behavior was with MAX_RESULT_LIMIT and MAX_RESULT_LIMITS. Previously if a max result limit was enforced, you couldn't apply an exception that allows a specific tool to be unbounded. If there was null tool specific limit, then we'd fallback to the max result limit value.
Now we can specify MAX_RESULT_LIMITS with no bounds for a specific tool. In code maxResultLimits is a map. If there is no mapping entry for a specific tool, then we fallback to the max result limit value. If there is, then the value of the tool specific limit can be a number or null (if unbounded).
| restApiArgs: { ...extra }, | ||
| }) | ||
| ).boundedContext.datasourceIds | ||
| (await extra.getConfigWithOverrides()).boundedContext.datasourceIds |
There was a problem hiding this comment.
I think there is an obscure caching bug lingering now.
Site settings:
{
"mcpSiteSettings": {
"settings": [
{
"key": "ALLOWED_REQUEST_OVERRIDES",
"value": "INCLUDE_DATASOURCE_IDS"
},
{
"key": "INCLUDE_DATASOURCE_IDS",
"value": "A,B"
}
]
}
}- Request 1 comes in with no
X-Tableau-Mcp-Configheader toquery-datasourcewithdatasourceLuid=A - ResourceAccessChecker caches the fact that this DS luid
Ais allowed - Request 2 comes in with
X-Tableau-Mcp-Configheader asINCLUDE_DATASOURCE_IDS=Btoquery-datasourcewithdatasourceLuid=A - Request 2 should not be allowed because DS
Ais not in the request overrides, however, since the allowed result was cached from the previous request, it does get returned.
There was a problem hiding this comment.
Fixing this could potentially get kinda messy. I think similar bugs could present themselves if the admin is making repeated changes to the MCP site settings as we never invalidate/expire the cached results (which is an issue in it of itself as they will grow indefinitely).
There was a problem hiding this comment.
One option would be to stop using a singleton instance of ResourceAccessChecker, and create a new instance for each request. That way cached results could never bleed from one request to another AND we fix the indefinite cache growth problem at the same time.
| /** | ||
| * General pattern for overriding variables: | ||
| * 1. Initialize the value of a given variable using the ENVIRONMENT (process.env). Throw if any issues with ENVIORNMENT values / unallowed behavior. | ||
| * 2. Using the Object.hasOwn() method, check if the given variable exists as a property in the siteOverrides object. | ||
| * Only when the variable is a property in the siteOverrides object, apply the following logic: | ||
| * a. If the site override value is an empty string or undefined, we generally revert the variable to its default value / behavior | ||
| * (each variable is different, so consider what makes the most sense for this case). | ||
| * b. If the site override value is invalid, do not throw. Either fallback to the value of the ENVIRONMENT or | ||
| * revert the value of the variable to its default value / behavior (similar to the empty string / undefined case). | ||
| * c. If the site override value is valid, replace the value of the given variable with its value from the site override. | ||
| * 3. Using the Object.hasOwn() method, check if the given variable exists as a property in the requestOverrides object. | ||
| * Only when the variable is a property in the requestOverrides object, apply the following logic: | ||
| * a. If the request override variable is not listed as an allowed request override, throw an error. | ||
| * b. If the request override value is an empty string, we generally revert the variable to its default value / behavior; | ||
| * however, determine if the override behavior conforms to any restrictions imposed on it and throw if it does not. | ||
| * c. If the request override value is invalid, throw an error. | ||
| * d. If the request override value is valid, determine if the override conforms to any restrictions imposed on it and throw if it does not. | ||
| * Replace the value of the given variable with its value from the request override if it does not violate any restrictions. | ||
| */ |
There was a problem hiding this comment.
I can tell you put a lot of time and thought into defining this logic. It's really awesome.
There was a problem hiding this comment.
Thanks Andy, I appreciate it.
| export const serverVersion = pkg.version; | ||
| export const userAgent = `${serverName}/${serverVersion}`; | ||
|
|
||
| const X_TABLEAU_MCP_CONFIG_HEADER = 'x-tableau-mcp-config'; |
There was a problem hiding this comment.
We need to add this header to the allowedHeaders array in express.ts so it appears on the Access-Control-Allow-Headers header.
| return toolsToRegister; | ||
| }; | ||
|
|
||
| getRequestOverridesFromHeader( |
There was a problem hiding this comment.
There's nothing about this function that requires it to be a method on the Server class, right? Let's move it to its own file.
There was a problem hiding this comment.
moved this to requestUtils.ts it even has a getToolNameFromRequestBody method which mirrors this method.
| if (!this.allowedRequestOverrides.has(variableName)) { | ||
| throw new Error(`${variableName} is not an allowed request override`); | ||
| } | ||
| const restrictionType = this.allowedRequestOverrides.get(variableName)!; |
There was a problem hiding this comment.
If you want to avoid the pattern where you call has and then get with a required ! to convince the TypeScript compiler it really is going to find the entry in the map, you can do:
const restrictionType = this.allowedRequestOverrides.get(variableName);
if (!restrictionType) {
throw new Error(`${variableName} is not an allowed request override`);
}
// TypeScript understands that restrictionType can't be undefined now.There was a problem hiding this comment.
I intentionally followed this pattern for readability of override logic. It's clear that the allowedOverrides not having a mapping means the override is not allowed, but its not as clear why not having a restriction type means the same thing.
| if (includeTools.length > 0 && excludeTools.length > 0) { | ||
| throw new Error('Cannot include and exclude tools simultaneously'); | ||
| } | ||
| } else if (siteOverrides.EXCLUDE_TOOLS) { |
There was a problem hiding this comment.
Is there a bug on this line and on the one for INCLUDE_TOOLS below? Should this be using Object.hasOwn() instead?
process.env.EXCLUDE_TOOLS=list-datasources- Site override:
"EXCLUDE_TOOLS": ""(empty string)
This condition would be false and wouldn't honor the empty string override
There was a problem hiding this comment.
We've initialized include tools and exclude tools to empty array in the first line of this method, then we hit none of the if statements and return empty arrays which is the proper override behavior for the example you provided.
All original tests we had for include / exclude tools passed, and then new ones were added for site overrides which all passed. overridableConfig.tests.ts lines 238 - 242 cover this scenario.
Logic is a lil special here compared to most overrides since include and exclude tools conflict with each other, so we don't initialize process.env then apply overrides, we first see when we have to fallback to process.env and for the other cases we only set based on the site override value since we know its safe.
| ); | ||
| } | ||
|
|
||
| const requestOverridesHeader = extra.requestInfo?.headers[X_TABLEAU_MCP_CONFIG_HEADER]; |
There was a problem hiding this comment.
Consider changing this to:
extra.requestInfo?.headers[X_TABLEAU_MCP_CONFIG_HEADER]?.toString() ?? ''and changing getRequestOverridesFromHeader to only accept a string. This will allow you to get rid of its Array.isArray() check that's going to happen with every request.
| "name": "@tableau/mcp-server", | ||
| "description": "Helping agents see and understand data.", | ||
| "version": "1.18.9", | ||
| "version": "1.18.10", |
There was a problem hiding this comment.
I've been thinking about our [mis]use of semver lately. Doing semver conventionally would mean for a new feature like this one, we'd do a full minor bump. Patch bumps are only bug fixes, and major bumps are for any breaking change. What do you think? Should we start following semver more conventionally and do a full minor bump here?
There was a problem hiding this comment.
Yeah I agree, this should be a minor bump not a patch bump.
anyoung-tableau
left a comment
There was a problem hiding this comment.
Really awesome stuff, have fun at TC. Excited to see this working with the resource access checker changes later on
IMPORTANT: Please do not create a Pull Request without creating an issue first.
Any change needs to be discussed before proceeding. Failure to do so may result in the rejection of
the pull request.
Pull Request Template
Description
Introduces support for configuring certain variables on a per request basis via HTTP header. Request override behavior is determined by
ALLOWED_REQUEST_OVERRIDESvariable, which specifies which variables are overridable and if the override values they provide is restricted. Introduces theALLOW_SITES_TO_CONFIGURE_REQUEST_OVERRIDESenv variable for determining if sites can specify their own request overrides.MAX_RESULT_LIMITS is also being changed to allow for tool specific limits to be unbounded
Motivation and Context
With request overrides, MCP clients can adjust server behavior for individual requests without changing the server's environment variables or site settings.
Type of Change
How Has This Been Tested?
Unit tests and local validation
Related Issues
Checklist
npm run version. For example,use
npm run version:patchfor a patch version bump.environment variable or changing its default value.
Contributor Agreement
By submitting this pull request, I confirm that:
its Contribution Checklist.