Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion superset-frontend/src/dashboard/actions/nativeFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
FilterConfiguration,
Filters,
makeApi,
Divider,
ChartCustomization,
ChartCustomizationDivider,
} from '@superset-ui/core';
import { Dispatch } from 'redux';
import { RootState } from 'src/dashboard/types';
Expand All @@ -44,7 +47,9 @@ export const SET_NATIVE_FILTERS_CONFIG_COMPLETE =
'SET_NATIVE_FILTERS_CONFIG_COMPLETE';
export interface SetNativeFiltersConfigComplete {
type: typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE;
filterChanges: Filter[];
filterChanges: Array<
Filter | Divider | ChartCustomization | ChartCustomizationDivider
>;
}

export const SET_NATIVE_FILTERS_CONFIG_FAIL = 'SET_NATIVE_FILTERS_CONFIG_FAIL';
Expand Down
180 changes: 179 additions & 1 deletion superset-frontend/src/dashboard/reducers/nativeFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Filter, NativeFilterType } from '@superset-ui/core';
import {
Filter,
NativeFilterType,
ChartCustomization,
ChartCustomizationType,
} from '@superset-ui/core';
import nativeFilterReducer from './nativeFilters';
import { SET_NATIVE_FILTERS_CONFIG_COMPLETE } from '../actions/nativeFilters';
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
Expand Down Expand Up @@ -58,6 +63,40 @@ const createMockFilter = (
tabsInScope,
});

const createMockChartCustomization = (
id: string,
chartsInScope?: number[],
tabsInScope?: string[],
): ChartCustomization => ({
id,
type: ChartCustomizationType.ChartCustomization,
name: `Chart Customization ${id}`,
filterType: 'filter_select',
targets: [
{
datasetId: 0,
column: {
name: 'test column',
displayName: 'test column',
},
},
],
scope: {
rootPath: [],
excluded: [],
},
defaultDataMask: {
filterState: {
value: null,
},
},
controlValues: {
sortAscending: true,
},
chartsInScope,
tabsInScope,
});

test('SET_NATIVE_FILTERS_CONFIG_COMPLETE updates filters with complete scope properties', () => {
const initialState = {
filters: {
Expand Down Expand Up @@ -237,3 +276,142 @@ test('HYDRATE_DASHBOARD handles new filters without existing state', () => {
expect(result.filters.filter1.chartsInScope).toEqual([1, 2]);
expect(result.filters.filter1.tabsInScope).toEqual(['tab1']);
});

test('SET_NATIVE_FILTERS_CONFIG_COMPLETE removes deleted filters from state', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1']),
filter2: createMockFilter('filter2', [3, 4], ['tab2']),
filter3: createMockFilter('filter3', [5, 6], ['tab3']),
},
};

// Backend response only includes filter1 and filter3 (filter2 was deleted)
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [
createMockFilter('filter1', [1, 2], ['tab1']),
createMockFilter('filter3', [5, 6], ['tab3']),
],
};

const result = nativeFilterReducer(initialState, action);

// filter2 should be removed from state
expect(result.filters.filter1).toBeDefined();
expect(result.filters.filter2).toBeUndefined();
expect(result.filters.filter3).toBeDefined();
expect(Object.keys(result.filters)).toHaveLength(2);
});

test('SET_NATIVE_FILTERS_CONFIG_COMPLETE removes all filters when backend returns empty array', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1']),
filter2: createMockFilter('filter2', [3, 4], ['tab2']),
},
};

const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [],
};

const result = nativeFilterReducer(initialState, action);

expect(Object.keys(result.filters)).toHaveLength(0);
expect(result.filters).toEqual({});
});

test('SET_NATIVE_FILTERS_CONFIG_COMPLETE handles mixed Filter and ChartCustomization types', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1']),
customization1: createMockChartCustomization(
'customization1',
[3, 4],
['tab2'],
),
},
};

const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [
createMockFilter('filter1', [5, 6], ['tab3']),
createMockChartCustomization('customization1', [7, 8], ['tab4']),
],
};

const result = nativeFilterReducer(initialState, action);

expect(result.filters.filter1.chartsInScope).toEqual([5, 6]);
expect(result.filters.filter1.tabsInScope).toEqual(['tab3']);
expect(result.filters.customization1.chartsInScope).toEqual([7, 8]);
expect(result.filters.customization1.tabsInScope).toEqual(['tab4']);
});

test('SET_NATIVE_FILTERS_CONFIG_COMPLETE adds new filters while removing deleted ones', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1']),
filter2: createMockFilter('filter2', [3, 4], ['tab2']),
},
};

// Backend response: keep filter1, delete filter2, add filter3
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [
createMockFilter('filter1', [1, 2], ['tab1']),
createMockFilter('filter3', [5, 6], ['tab3']),
],
};

const result = nativeFilterReducer(initialState, action);

expect(result.filters.filter1).toBeDefined();
expect(result.filters.filter2).toBeUndefined();
expect(result.filters.filter3).toBeDefined();
expect(result.filters.filter3.chartsInScope).toEqual([5, 6]);
expect(Object.keys(result.filters)).toHaveLength(2);
});

test('SET_NATIVE_FILTERS_CONFIG_COMPLETE treats backend response as source of truth', () => {
const initialState = {
filters: {
filter1: createMockFilter('filter1', [1, 2], ['tab1']),
filter2: createMockFilter('filter2', [3, 4], ['tab2']),
filter3: createMockFilter('filter3', [5, 6], ['tab3']),
customization1: createMockChartCustomization(
'customization1',
[7, 8],
['tab4'],
),
},
};

// Backend only returns filter2 and customization1
const action = {
type: SET_NATIVE_FILTERS_CONFIG_COMPLETE as typeof SET_NATIVE_FILTERS_CONFIG_COMPLETE,
filterChanges: [
createMockFilter('filter2', [10, 11], ['tab5']),
createMockChartCustomization('customization1', [12, 13], ['tab6']),
],
};

const result = nativeFilterReducer(initialState, action);

// Only filter2 and customization1 should remain
expect(result.filters.filter1).toBeUndefined();
expect(result.filters.filter2).toBeDefined();
expect(result.filters.filter3).toBeUndefined();
expect(result.filters.customization1).toBeDefined();
expect(Object.keys(result.filters)).toHaveLength(2);

// Values should be from backend response
expect(result.filters.filter2.chartsInScope).toEqual([10, 11]);
expect(result.filters.filter2.tabsInScope).toEqual(['tab5']);
expect(result.filters.customization1.chartsInScope).toEqual([12, 13]);
expect(result.filters.customization1.tabsInScope).toEqual(['tab6']);
});
15 changes: 10 additions & 5 deletions superset-frontend/src/dashboard/reducers/nativeFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,18 @@ function handleFilterChangesComplete(
Filter | Divider | ChartCustomization | ChartCustomizationDivider
>,
) {
const modifiedFilters = { ...state.filters };
// Create new filters object from backend response (deleted filters won't be included)
const newFilters: Record<
string,
Filter | Divider | ChartCustomization | ChartCustomizationDivider
> = {};

filters.forEach(filter => {
const existingFilter = state.filters[filter.id];
if (filter.chartsInScope != null && filter.tabsInScope != null) {
modifiedFilters[filter.id] = filter;
newFilters[filter.id] = filter;
} else {
const existingFilter = modifiedFilters[filter.id];
modifiedFilters[filter.id] = {
newFilters[filter.id] = {
...filter,
chartsInScope: filter.chartsInScope ?? existingFilter?.chartsInScope,
tabsInScope: filter.tabsInScope ?? existingFilter?.tabsInScope,
Expand All @@ -93,7 +98,7 @@ function handleFilterChangesComplete(

return {
...state,
filters: modifiedFilters,
filters: newFilters,
} as ExtendedNativeFiltersState;
}

Expand Down
32 changes: 29 additions & 3 deletions superset/mcp_service/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,22 @@ def has_dataset_access(dataset: "SqlaTable") -> bool:
return False # Deny access on error


def _setup_user_context() -> User:
def _setup_user_context() -> User | None:
"""
Set up user context for MCP tool execution.

Returns:
User object with roles and groups loaded
User object with roles and groups loaded, or None if no Flask context
"""
user = get_user_from_request()
try:
user = get_user_from_request()
except RuntimeError as e:
# No Flask application context (e.g., prompts before middleware runs)
# This is expected for some FastMCP operations - return None gracefully
if "application context" in str(e):
logger.debug("No Flask app context available for user setup")
return None
raise

# Validate user has necessary relationships loaded
# (Force access to ensure they're loaded if lazy)
Expand Down Expand Up @@ -212,6 +220,15 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
with _get_app_context_manager():
user = _setup_user_context()

# No Flask context - this is a FastMCP internal operation
# (e.g., tool discovery, prompt listing) that doesn't require auth
if user is None:
logger.debug(
"MCP internal call without Flask context: tool=%s",
tool_func.__name__,
)
return await tool_func(*args, **kwargs)

try:
logger.debug(
"MCP tool call: user=%s, tool=%s",
Expand All @@ -235,6 +252,15 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
with _get_app_context_manager():
user = _setup_user_context()

# No Flask context - this is a FastMCP internal operation
# (e.g., tool discovery, prompt listing) that doesn't require auth
if user is None:
logger.debug(
"MCP internal call without Flask context: tool=%s",
tool_func.__name__,
)
return tool_func(*args, **kwargs)

try:
logger.debug(
"MCP tool call: user=%s, tool=%s",
Expand Down
4 changes: 4 additions & 0 deletions superset/mcp_service/chart/chart_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ def map_xy_config(config: XYChartConfig) -> Dict[str, Any]:
"metrics": metrics,
}

# Add time grain if specified (for temporal x-axis columns)
if config.time_grain:
form_data["time_grain_sqla"] = config.time_grain

# CRITICAL FIX: For time series charts, handle groupby carefully to avoid duplicates
# The x_axis field already tells Superset which column to use for time grouping
groupby_columns = []
Expand Down
10 changes: 10 additions & 0 deletions superset/mcp_service/chart/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
PositiveInt,
)

from superset.constants import TimeGrain
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.common.cache_schemas import (
CacheStatus,
Expand Down Expand Up @@ -678,6 +679,15 @@ class XYChartConfig(BaseModel):
kind: Literal["line", "bar", "area", "scatter"] = Field(
"line", description="Chart visualization type"
)
time_grain: TimeGrain | None = Field(
None,
description=(
"Time granularity for the x-axis when it's a temporal column. "
"Common values: PT1S (second), PT1M (minute), PT1H (hour), "
"P1D (day), P1W (week), P1M (month), P3M (quarter), P1Y (year). "
"If not specified, Superset will use its default behavior."
),
)
group_by: ColumnRef | None = Field(None, description="Column to group by")
x_axis: AxisConfig | None = Field(None, description="X-axis configuration")
y_axis: AxisConfig | None = Field(None, description="Y-axis configuration")
Expand Down
12 changes: 11 additions & 1 deletion superset/mcp_service/mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,22 @@
# =============================================================================

# MCP Store Configuration - shared Redis infrastructure for all MCP storage needs
# (caching, auth, events, etc.). Only used when a consumer explicitly requests it.
# (caching, auth, events, session state, etc.).
#
# When CACHE_REDIS_URL is set:
# - Response caching uses Redis (if MCP_CACHE_CONFIG enabled)
# - EventStore uses Redis for multi-pod session management
#
# For multi-pod/Kubernetes deployments, setting CACHE_REDIS_URL automatically
# enables Redis-backed EventStore to share session state across pods.
MCP_STORE_CONFIG: Dict[str, Any] = {
"enabled": False, # Disabled by default - caching uses in-memory store
"CACHE_REDIS_URL": None, # Redis URL, e.g., "redis://localhost:6379/0"
# Wrapper class that prefixes all keys. Each consumer provides their own prefix.
"WRAPPER_TYPE": "key_value.aio.wrappers.prefix_keys.PrefixKeysWrapper",
# EventStore settings (for multi-pod session management)
"event_store_max_events": 100, # Keep last 100 events per session
"event_store_ttl": 3600, # Events expire after 1 hour
}

# MCP Response Caching Configuration - controls caching behavior and TTLs
Expand Down
Loading
Loading