Skip to content

@W-21952572: Add support for request level overrides#329

Open
stephendeoca wants to merge 43 commits into
mainfrom
support-request-overrides
Open

@W-21952572: Add support for request level overrides#329
stephendeoca wants to merge 43 commits into
mainfrom
support-request-overrides

Conversation

@stephendeoca
Copy link
Copy Markdown
Contributor

@stephendeoca stephendeoca commented Apr 22, 2026

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_OVERRIDES variable, which specifies which variables are overridable and if the override values they provide is restricted. Introduces the ALLOW_SITES_TO_CONFIGURE_REQUEST_OVERRIDES env 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

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Other (please describe):

How Has This Been Tested?

Unit tests and local validation

Related Issues

Checklist

  • I have updated the version in the package.json file by using npm run version. For example,
    use npm run version:patch for a patch version bump.
  • I have made any necessary changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have documented any breaking changes in the PR description. For example, renaming a config
    environment variable or changing its default value.

Contributor Agreement

By submitting this pull request, I confirm that:

@stephendeoca stephendeoca self-assigned this Apr 22, 2026
Comment thread src/server/passthroughAuthMiddleware.ts
restApiArgs: { ...extra },
})
).boundedContext.projectIds
(await extra.getConfigWithOverrides()).boundedContext.projectIds
Copy link
Copy Markdown
Contributor Author

@stephendeoca stephendeoca Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now calls extra.getConfigWithOverrides from the tool registration, since it is setup to use any request overrides that appear in the tool invocation request.

Comment thread src/overridableConfig.ts
import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js';

const overridableVariables = [
export const overridableVariables = [
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@stephendeoca stephendeoca marked this pull request as ready for review April 28, 2026 20:41
restApiArgs: { ...extra },
})
).boundedContext.datasourceIds
(await extra.getConfigWithOverrides()).boundedContext.datasourceIds
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
      }
    ]
  }
}
  1. Request 1 comes in with no X-Tableau-Mcp-Config header to query-datasource with datasourceLuid=A
  2. ResourceAccessChecker caches the fact that this DS luid A is allowed
  3. Request 2 comes in with X-Tableau-Mcp-Config header as INCLUDE_DATASOURCE_IDS=B to query-datasource with datasourceLuid=A
  4. Request 2 should not be allowed because DS A is not in the request overrides, however, since the allowed result was cached from the previous request, it does get returned.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Collaborator

@anyoung-tableau anyoung-tableau Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/overridableConfig.ts
Comment on lines +58 to +76
/**
* 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.
*/
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can tell you put a lot of time and thought into defining this logic. It's really awesome.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Andy, I appreciate it.

Comment thread docs/docs/configuration/mcp-config/request-overrides.md Outdated
Comment thread docs/docs/configuration/mcp-config/request-overrides.md Outdated
Comment thread docs/docs/configuration/mcp-config/request-overrides.md
Comment thread src/server.ts Outdated
export const serverVersion = pkg.version;
export const userAgent = `${serverName}/${serverVersion}`;

const X_TABLEAU_MCP_CONFIG_HEADER = 'x-tableau-mcp-config';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to add this header to the allowedHeaders array in express.ts so it appears on the Access-Control-Allow-Headers header.

Comment thread src/server.ts Outdated
return toolsToRegister;
};

getRequestOverridesFromHeader(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

@stephendeoca stephendeoca May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved this to requestUtils.ts it even has a getToolNameFromRequestBody method which mirrors this method.

Comment thread src/overridableConfig.ts
Comment on lines +379 to +382
if (!this.allowedRequestOverrides.has(variableName)) {
throw new Error(`${variableName} is not an allowed request override`);
}
const restrictionType = this.allowedRequestOverrides.get(variableName)!;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/overridableConfig.ts
if (includeTools.length > 0 && excludeTools.length > 0) {
throw new Error('Cannot include and exclude tools simultaneously');
}
} else if (siteOverrides.EXCLUDE_TOOLS) {
Copy link
Copy Markdown
Collaborator

@anyoung-tableau anyoung-tableau Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a bug on this line and on the one for INCLUDE_TOOLS below? Should this be using Object.hasOwn() instead?

  1. process.env.EXCLUDE_TOOLS=list-datasources
  2. Site override: "EXCLUDE_TOOLS": "" (empty string)

This condition would be false and wouldn't honor the empty string override

Copy link
Copy Markdown
Contributor Author

@stephendeoca stephendeoca May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/server.ts Outdated
);
}

const requestOverridesHeader = extra.requestInfo?.headers[X_TABLEAU_MCP_CONFIG_HEADER];
Copy link
Copy Markdown
Collaborator

@anyoung-tableau anyoung-tableau Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread package.json Outdated
"name": "@tableau/mcp-server",
"description": "Helping agents see and understand data.",
"version": "1.18.9",
"version": "1.18.10",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree, this should be a minor bump not a patch bump.

Copy link
Copy Markdown
Collaborator

@anyoung-tableau anyoung-tableau left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really awesome stuff, have fun at TC. Excited to see this working with the resource access checker changes later on

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants