Compare commits

..

72 Commits

Author SHA1 Message Date
Maxime Beauchemin
6d7467bafd feat: add devcontainer profiles for MCP development
- Split devcontainer config into base + profile structure
  - default: Standard Superset development (port 9001)
  - with-mcp: Superset + MCP service (ports 9001, 5008)
- Add MCP service to docker-compose-light.yml as optional profile
- Update start-superset.sh to conditionally start MCP when ENABLE_MCP=true
- Add MCP case to docker-bootstrap.sh for starting MCP service
- Preserve original devcontainer.json as .old for migration reference

This allows developers to choose their development environment:
- Use default profile for standard Superset development
- Use with-mcp profile for MCP/LLM agent development
- MCP service runs on port 5008 when enabled via profile

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 18:59:27 -07:00
Amin Ghadersohi
71f294b7d3 docs: update MCP Chart Test Plan with accurate implementation details
- Add authentication setup and dataset discovery prerequisites
- Remove references to unsupported preview formats (base64, interactive, vega_lite)
- Fix schema examples: operator -> op, xlsx -> excel
- Add performance limits for preview formats
- Update error expectations for empty query results
- Enhance cache testing with cache_timeout parameter
- Add Important Schema Notes section for common gotchas
- Update response patterns to match actual implementation
2025-07-30 20:15:41 -04:00
Amin Ghadersohi
e839d0989a refactor: remove redundant docs folder from mcp_service 2025-07-30 19:41:05 -04:00
Amin Ghadersohi
714e21b3ec feat: enhance MCP documentation with Superset best practices
- Add Docusaurus admonitions (:::note, :::tip, :::warning) following Superset patterns
- Improve code block formatting and language tags
- Add important notes about authentication and installation
- Ensure consistency with Superset documentation style
- Maintain accuracy with FastMCP specifications
2025-07-30 19:26:08 -04:00
Amin Ghadersohi
bf78eb69ed fix: resolve Mermaid diagram syntax errors in development guide
Replace @ symbols in node names that cause Mermaid parsing errors
2025-07-30 19:21:45 -04:00
Amin Ghadersohi
a13c1ba8c2 fix: update broken README.md links in MCP documentation
Replace ./README.md references with proper Docusaurus links to intro page
2025-07-30 19:20:38 -04:00
Amin Ghadersohi
422c34a6ee feat: integrate MCP service documentation into Superset Docusaurus site
- Add comprehensive MCP service documentation to docs/docs/mcp-service/
- Create 7 new documentation files covering all aspects of MCP service
- Add MCP Service section to Docusaurus sidebar configuration
- Update README files to reference official documentation
- Remove outdated standalone architecture documentation
- Install missing docs dependencies to fix eslint-docs pre-commit hook

Documentation includes:
- Introduction and overview with getting started guide
- Complete API reference with all 16 tools and examples
- Authentication setup with JWT and identity provider integration
- Development guide with internal architecture and patterns
- System architecture overview with deployment considerations
- Preset integration guide for enterprise features
2025-07-30 19:17:18 -04:00
Amin Ghadersohi
cd213fc57d update: enhance MCP service README with comprehensive setup instructions
Improve README with official Superset installation instructions, detailed Claude Desktop
connection steps using FastMCP proxy, and troubleshooting guide. Adds verification steps
and clear instructions for HTTP-based MCP service deployment.
2025-07-30 18:24:54 -04:00
Amin Ghadersohi
0f20a88598 feat(mcp): major enhancements to chart generation with validation, pooled screenshots, and simplified tests
This commit introduces significant improvements to the MCP chart generation system:

## Core Features
- Enhanced chart generation with comprehensive validation and error handling
- Implemented WebDriver pooling for 10x faster screenshot generation
- Added detailed validation for chart configurations with helpful error messages
- Support for both table and XY (line/bar/area/scatter) chart types

## Validation & Error Handling
- Column existence validation with similarity-based suggestions
- Data type compatibility checks for aggregations (SUM/AVG for numbers only)
- Filter operator validation
- Detailed error responses with actionable suggestions and available columns/metrics

## Performance Improvements
- WebDriver connection pooling eliminates browser startup overhead
- Reuses browser instances across multiple screenshot requests
- Automatic health checking and recovery of WebDriver instances
- Configurable pool size and timeout settings

## Screenshot Enhancements
- New PooledChartScreenshot and PooledExploreScreenshot classes
- FastAPI endpoints for serving chart and explore screenshots as PNG images
- Proper caching with TTL support
- Clean chart-only screenshots for explore views (hides UI elements)

## Test Improvements
- Simplified test structure focusing on schema validation over complex mocking
- Removed brittle integration-style tests in favor of unit tests
- Fixed all test failures with cleaner, more maintainable approach
- Added comprehensive test plan for manual testing with Claude Desktop

## API Enhancements
- Centralized URL generation for screenshots and explore links
- Consistent error response format across all endpoints
- Support for UUID-based chart lookups alongside numeric IDs

## Code Quality
- Fixed all pre-commit issues (mypy, ruff, long lines, complexity)
- Extracted helper functions to reduce cyclomatic complexity
- Proper type annotations throughout
- Better separation of concerns

## Documentation
- Added comprehensive test plan for chart tools
- Detailed guides for table chart configuration
- WebDriver pooling architecture documentation
- Demo scripts for testing functionality

The changes maintain backward compatibility while significantly improving
performance, reliability, and developer experience for chart generation in the
MCP service.
2025-07-30 18:09:23 -04:00
Amin Ghadersohi
33d16eaca1 refactor: rename pydantic_schemas to schemas for MCP service
Simplified the directory structure by renaming `pydantic_schemas` to `schemas` throughout the MCP service codebase. This follows standard Python conventions and makes imports more concise.

Changes:
- Renamed directory: superset/mcp_service/pydantic_schemas/ → superset/mcp_service/schemas/
- Updated all import statements across 42 files
- Updated documentation references in README files
- All tests pass, pre-commit hooks clean
2025-07-30 14:20:42 -04:00
Amin Ghadersohi
5b56bc622b feat: major MCP service enhancements with cache control, testing infrastructure, and bug fixes
This comprehensive update adds significant functionality and improvements to the MCP service:

  New Features:
  - Cache control utilities for metadata operations with configurable TTL
  - Entity testing plan documentation for systematic API validation
  - Chart data retrieval tool with caching support
  - Comprehensive request schema validation framework
  - Sortable columns testing infrastructure

  Documentation Updates:
  - Expanded README with architecture details and usage examples
  - Phase 1 status tracking and implementation notes
  - Enhanced schema documentation with cache control patterns
  - LLMS.md reference added to CLAUDE.md

  Search Functionality Fixes:
  - Dashboard search: removed invalid columns (owners, roles, tags, published)
  - Dataset search: removed non-existent 'tags' and problematic 'catalog' columns
  - Chart search: maintained correct text-only columns (slice_name, description)
  - Updated corresponding unit tests to match implementations

  Infrastructure Improvements:
  - Added comprehensive cache control test coverage
  - Request schema validation testing
  - Sortable columns validation across all entity types
  - Enhanced error handling and logging

  Technical Debt:
  - Standardized import patterns across MCP tools
  - Improved type safety in Pydantic schemas
  - Better separation of concerns for cache operations
2025-07-30 14:20:42 -04:00
Amin Ghadersohi
8cbfe027b5 update: add chart update tools and centralized URL configuration
- Add update_chart and update_chart_preview tools for modifying saved and cached charts
  - Implement centralized URL configuration using SUPERSET_WEBSERVER_ADDRESS
  - Add comprehensive MCP audit logging with impersonation tracking and payload  sanitization
  - Refactor duplicate chart analysis functions to chart_utils.py
  - Update all README documentation with new tools and features
  - Add 194+ unit tests covering URL utils, audit logging, and chart updates
  - Fix all pre-commit issues and ensure test coverage
2025-07-30 14:20:42 -04:00
Amin Ghadersohi
c3b3edc6ba refactor: rename create_chart to generate_chart and standardize MCP test naming
- Rename create_chart function to generate_chart throughout MCP service
  - Update CreateChartRequest to GenerateChartRequest schema
  - Rename test files to follow test_*.py convention consistently
  - Fix test class and method names (TestCreateChart → TestGenerateChart)
  - Update all documentation and README files with new naming
  - Standardize auth scope mappings and tool references
  - Maintain backward compatibility for Superset CreateChartCommand references
2025-07-30 14:20:42 -04:00
Amin Ghadersohi
aa06bb9fda revert unnecessary config change 2025-07-30 14:20:41 -04:00
Amin Ghadersohi
0f222b9034 update: revert comment change 2025-07-30 14:20:41 -04:00
Amin Ghadersohi
7044153ca4 revert: switch webdriver back to Firefox for chart
screenshots

  Changed WEBDRIVER_TYPE from "chrome" to "firefox" and removed
   Chrome-specific
  binary path from WEBDRIVER_CONFIGURATION. Firefox provides
  more reliable
  screenshot generation for the MCP service chart preview
  functionality.

  - Set WEBDRIVER_TYPE = "firefox"
  - Cleared binary_location in WEBDRIVER_CONFIGURATION to use
  default Firefox
  - Chart preview tools now use geckodriver instead of Chrome
  headless mode

  This resolves webdriver timeout issues in chart screenshot
  generation.
2025-07-30 14:20:41 -04:00
Amin Ghadersohi
ce82c35bb6 docs: clarify MCP service capabilities and available operations
Updated all MCP service READMEs to accurately reflect available operations:

- Removed overuse of 'comprehensive' and 'complete' where it overstated capabilities
- Added clear 'Available Operations' section showing:
  - Read operations available for all entities
  - Create operations only for charts and dashboards
  - No update or delete operations available
- Updated tool descriptions to be more specific about capabilities
- Changed 'complete workflow automation' to 'dashboard creation and chart addition'
- Clarified that we have 5 chart types, not 'comprehensive' support
- Updated test count references to be consistent (185+ tests)

This ensures documentation accurately represents Phase 1 capabilities without
overstating what operations are available.
2025-07-30 14:20:41 -04:00
Amin Ghadersohi
9b25bd973f docs: update MCP service documentation to reflect Phase 1 completion
Updated all MCP service README files to reflect current status:

- README_ARCHITECTURE.md: Updated to 95% completion status, documented new
  dashboard generation and SQL Lab tools, corrected file references
- README_PHASE1_STATUS.md: Marked all core epics as complete, updated tool
  count to 16, reflected recent technical completions
- README_SCHEMAS.md: Updated test coverage numbers, documented new schema
  enhancements, marked Phase 1 features as complete

Changes reflect the successful completion of core MCP service functionality
including BaseDAO enhancements, comprehensive testing coverage, and all
planned Phase 1 tools and features.
2025-07-30 14:20:41 -04:00
Amin Ghadersohi
5f7502b85c feat: comprehensive MCP service enhancements with type safety and new tools
Core Improvements:
- Enhanced BaseDAO with type-safe UUID handling and flexible column support
- Added comprehensive test coverage (185+ passing unit tests)
- Eliminated hardcoded string checks with SQLAlchemy type inspection
- Removed unnecessary async patterns for better performance consistency

New MCP Tools & Features:
- Dashboard generation: create_dashboard and add_chart_to_existing_dashboard
- Chart data/preview: get_chart_data and get_chart_preview with multiple formats
- SQL Lab integration: open_sql_lab_with_context with proper parameter handling
- Enhanced chart creation with comprehensive type support

Technical Enhancements:
- Fixed SQL Lab parameter naming (dbid) for frontend compatibility
- Improved error handling with graceful UUID conversion fallbacks
- Enhanced multi-identifier support (ID, UUID, slug) across all tools
- Comprehensive request schema pattern eliminating LLM validation issues

Testing & Quality:
- Added BaseDAO unit tests with edge cases and error scenarios
- Enhanced MCP tool testing with proper mocking patterns
- Fixed trailing whitespace and pre-commit compliance
- Professional error handling and type validation

Documentation:
- Updated all MCP service READMEs with current status (95% complete)
- Documented all 16 MCP tools with examples and usage patterns
- Enhanced architecture documentation with recent improvements
- Updated Phase 1 status reflecting completion of core features

This commit consolidates Phase 1 MCP service development with production-ready
authentication, comprehensive testing, and complete tool functionality.
2025-07-30 14:20:41 -04:00
Amin Ghadersohi
1d5372210f update: read files 2025-07-30 14:20:41 -04:00
Amin Ghadersohi
64af53f6f6 update: enhance MCP service auth with factory pattern and code cleanup
- Implement configurable auth factory pattern as suggested by @dpgaspar
  - Add unit test coverage
  - Refactor auth.py for better pythonic patterns and error handling
  - Add proper type annotations and logging throughout
  - Split complex functions into focused helper functions
  - Add detailed architecture documentation with Mermaid diagrams
  - Improve JWT token extraction with robust fallback logic
2025-07-30 14:20:41 -04:00
Amin Ghadersohi
56b308340e update: use real Superset user for MCP authentication instead of MCPUser
Replace MCPUser wrapper with actual Flask-AppBuilder User from database
  to enable proper RBAC permission filtering. Fixes MCP service returning
  0 counts due to empty permission queries.
2025-07-30 14:20:41 -04:00
Amin Ghadersohi
3b2b7609a8 update: add optional JWT Bearer authentication to MCP service
Implement comprehensive JWT authentication system for production deployments:

  • Add BearerAuthProvider integration with FastMCP for RS256 token validation
  • Support both static public keys and JWKS endpoints for enterprise key rotation
  • Implement scope-based authorization (dashboard:read, chart:write, etc.)
  • Extract user identity from JWT sub claim for proper audit trails
  • Maintain backward compatibility with admin fallback when auth disabled
  • Add comprehensive test coverage for authentication flows
  • Update documentation with human-friendly security setup guide

  Authentication is disabled by default for development convenience.
  Configure via environment variables for production use.
2025-07-30 14:20:40 -04:00
Amin Ghadersohi
61a91f80fe update deps 2025-07-30 14:20:40 -04:00
Amin Ghadersohi
ed3c5ecbc2 enhance MCP service with multi-identifier support and comprehensive UUID/slug functionality
- Add UUID field to ChartInfo and DatasetInfo Pydantic schemas for complete serialization
  - Include UUID in chart and dataset serialization functions (serialize_chart_object,
  serialize_dataset_object)
  - UUID and slug are now included in default response columns for better discoverability:
    * Dashboards: UUID and slug in DEFAULT_DASHBOARD_COLUMNS and returned by default
    * Charts: UUID in DEFAULT_CHART_COLUMNS and returned by default
    * Datasets: UUID in DEFAULT_DATASET_COLUMNS and returned by default
  - Search functionality enhanced to include UUID/slug fields across all relevant tools
  - Add comprehensive test coverage for UUID/slug functionality:
    * Default column verification tests ensuring UUID/slug are in default responses
    * Response data verification tests confirming UUID/slug values are returned
    * Custom column selection tests for explicit UUID/slug requests
    * Metadata accuracy tests verifying columns_requested/columns_loaded tracking
  - Update documentation to reflect enhanced multi-identifier capabilities
  - All 132 tests pass with comprehensive verification of UUID/slug support
2025-07-30 14:20:40 -04:00
Amin Ghadersohi
c8fbd4233c add multi-identifier support and fix columns metadata accuracy
**Multi-Identifier Support:**
  - Enhance ModelGetInfoTool to support ID, UUID, and slug lookup
  - Add intelligent identifier detection for get_*_info tools
  - Dashboards: support ID, UUID, and slug via id_or_slug_filter
  - Datasets/Charts: support ID and UUID via direct database queries
  - Add GetDashboardInfoRequest, GetDatasetInfoRequest, GetChartInfoRequest schemas

  **Enhanced Default Columns & Metadata:**
  - Add uuid to default columns for all list tools (dashboards, datasets, charts)
  - Include uuid/slug in search columns for better discoverability
  - Fix columns_requested to accurately reflect user input vs defaults
  - Fix columns_loaded to show actual DAO columns requested vs serialized fields

  **Testing & Code Quality:**
  - Add multi-identifier tests for all get_*_info tools (ID/UUID/slug scenarios)
  - Remove unused serialized_columns variable in ModelListTool
  - Fix linting issues (line length, docstrings)

  This provides flexible identifier support while ensuring accurate metadata
  tracking for better LLM compatibility and debugging.
2025-07-30 14:20:40 -04:00
Amin Ghadersohi
fc85f68585 update: implement FastMCP complex inputs pattern for MCP tools
- Replace individual parameters with structured request schemas for list_datasets, list_charts, and
  list_dashboards
  - Fix validation issues where LLMs passed arrays/objects as strings
  - Add ListDatasetsRequest, ListChartsRequest, ListDashboardsRequest schemas
  - Fix object serialization (charts, dashboards, datasets)
  - Add validation to prevent search+filters conflicts
  - Update all tests and fix linting issues

  Resolves string/array validation ambiguity that caused tool failures when LLMs sent complex
  parameters incorrectly.
2025-07-30 14:20:40 -04:00
Amin Ghadersohi
e825bbe1f4 update: generate_explore_link and create_chart working well 2025-07-30 14:20:40 -04:00
Amin Ghadersohi
a80b637a2a update: add a save_chart boolean parameter to create chart so that generate link to explore can use its code and just pass false 2025-07-30 14:20:40 -04:00
Amin Ghadersohi
541b3bd727 update: add docs to proxy 2025-07-30 14:20:40 -04:00
Amin Ghadersohi
a909799e5c update: simplify create chart a lot: support area, line, bar, scatter and table charts.
example prompt:
- can you use superset dataset 2 to plot popular baby names in 2024

- plot airline delay by day of week and group by airline use dataset 6
2025-07-30 14:20:39 -04:00
Amin Ghadersohi
01329f1c62 update: tests and deps 2025-07-30 14:20:39 -04:00
Amin Ghadersohi
67f621d360 update: remove create_chart_simple tool 2025-07-30 14:20:39 -04:00
Amin Ghadersohi
b552dbf4a1 update: fix all lint issues 2025-07-30 14:20:39 -04:00
Amin Ghadersohi
364af98c04 update: create_chart: Remove x_axis from groupby if present, update docs and tests
- Updates create_chart logic to automatically remove x_axis from groupby for ECharts timeseries charts, preventing duplicate dimension usage.
- Updates and expands unit test to verify x_axis is excluded from groupby, using improved test mocks for accurate backend simulation.
- Updates documentation (README.md, README_ARCHITECTURE.md, README_PHASE1_STATUS.md, README_SCHEMAS.md) to clarify create_chart tool behavior and schema, including new groupby/x_axis handling.
- No breaking changes to tool signatures; behavior is now more robust and LLM-friendly.
2025-07-30 14:20:39 -04:00
Amin Ghadersohi
afdb8b38a6 update: Add columns and metrics to DatasetInfo/list_datasets, improve test coverage
- Updated DatasetInfo schema to include columns and metrics fields, with new TableColumnInfo and SqlMetricInfo models.
- Updated serialize_dataset_object to serialize columns and metrics for each dataset.
- Modified list_datasets tool to use serialize_dataset_object and include columns/metrics by default.
- Improved and fixed all related unit tests to use proper MagicMock objects for columns/metrics and to parse JSON responses.
- Ensured LLM/OpenAPI compatibility for dataset listing and info tools.
2025-07-30 14:20:39 -04:00
Amin Ghadersohi
fc7ea804bc update: chart create working with union of types 2025-07-30 14:20:39 -04:00
Amin Ghadersohi
7c256ae9aa update: docs 2025-07-30 14:20:39 -04:00
Amin Ghadersohi
1b190abc3b update: add new ModelGetAvailableFiltersTool 2025-07-30 14:20:39 -04:00
Amin Ghadersohi
c6c71bf835 update: update tool registration to use decorators, and update all tests to use mcp client directly in the tests as recommended 2025-07-30 14:20:38 -04:00
Amin Ghadersohi
cd5ead7f11 update: update tool registration 2025-07-30 14:20:38 -04:00
Amin Ghadersohi
e5eebe28f9 update: refactor auth and model tools 2025-07-30 14:20:38 -04:00
Amin Ghadersohi
9eac6ef433 update: add tests for mcp list and info tools 2025-07-30 14:20:38 -04:00
Amin Ghadersohi
d523d523e5 update: refactor tools directories 2025-07-30 14:20:38 -04:00
Amin Ghadersohi
91a3214ed4 update: wip 2025-07-30 14:20:38 -04:00
Amin Ghadersohi
95b787f024 update: wip 2025-07-30 14:20:38 -04:00
Amin Ghadersohi
39121791e8 update: wip 2025-07-30 14:20:38 -04:00
Amin Ghadersohi
9d40fe913f update: wip 2025-07-30 14:20:37 -04:00
Amin Ghadersohi
748ae49c8c update: wip 2025-07-30 14:20:37 -04:00
Amin Ghadersohi
a9d543b6f4 update: unify FastMCP server, modularize tools, and document new DAO-based architecture
- Replace dual Flask/FastAPI setup with a single, unified FastMCP server (`server.py`)
- Introduce `MCPDAOWrapper` for secure, context-aware DAO access (`dao_wrapper.py`)
- Refactor all MCP tools to be modular and domain-organized (`tools/dashboard/`, `tools/chart/`, `tools/dataset/`, `tools/system/`)
- Strongly type all tool contracts with Pydantic v2 models, including full field documentation for LLM/OpenAPI compatibility
- Refactor and extend `BaseDAO` for robust, generic CRUD/list operations
- Add and update documentation:
  - Architecture and flow diagrams (`README_ARCHITECTURE.md`)
  - Tool schema reference and usage instructions (`README.md`, `README_SCHEMAS.md`)
  - Phase 1 status and roadmap (`README_PHASE1_STATUS.md`)
- Implement and test all core list/info tools for dashboards, datasets, and charts, with full search and filter support
- Add chart creation tool (`create_chart_simple`)
- Provide extension points for Preset-specific auth, RBAC, and logging (stubbed in Phase 1)
- Prepare for LLM/agent workflows and future command-based mutations (create/update/delete)
- Expand and update unit/integration test coverage for all tools
2025-07-30 14:20:37 -04:00
Amin Ghadersohi
55d6130fc4 update: docs and deps 2025-07-30 14:20:37 -04:00
Amin Ghadersohi
b98e3eb309 Superset MCP Service - Adding get_instance_info tool 2025-07-30 14:20:37 -04:00
Amin Ghadersohi
b469077e0e update: add list function to BaseDAO and use it to get and filter the dashboards 2025-07-30 14:20:37 -04:00
Amin Ghadersohi
397b4e450b Superset MCP Service - Initial Commit DRAFT 2025-07-30 14:20:37 -04:00
Amin Ghadersohi
0f97002520 update: Refactor BaseDAO: enhance column operator logic and expand test coverage
- Improved the BaseDAO class to robustly handle column operator logic, ensuring all supported operators (eq, ne, sw, ew, in, nin, gt, gte, lt, lte, like, ilike, is_null, is_not_null) are consistently applied via ColumnOperatorEnum.
- Refactored the apply_column_operators and list methods for clarity and reliability, including better handling of columns, relationships, and search.
- Removed 1 index base page handing from list
2025-07-30 14:20:37 -04:00
Amin Ghadersohi
2312250127 update: add idea of ColumnOperator 2025-07-30 14:20:36 -04:00
Amin Ghadersohi
cd52193869 update: add helper for applying base filter 2025-07-30 14:20:36 -04:00
Amin Ghadersohi
9ffe680aaa feat(dao): add robust generic list and count methods to BaseDAO with full test coverage
- Introduced generic `list` and `count` methods to BaseDAO for consistent querying and counting across all DAOs.
- Both methods support filtering (including IN queries), ordering, pagination, search across columns, custom FAB-style filters, and always-on base filters.
- Added comprehensive unit tests for `list` and `count` in `tests/unit_tests/dao/base_test.py`, covering:
  - Filtering (including boolean, None, and IN queries)
  - Ordering (asc/desc, multiple columns)
  - Pagination (including out-of-range)
  - Search across columns
  - Custom filter logic
  - Always-on base filter logic
  - Edge cases and skip_base_filter
- Moved common test fixtures to `conftest.py` for reuse.
2025-07-30 14:20:36 -04:00
dependabot[bot]
5c2eb0a68c build(deps): bump reselect from 4.1.7 to 5.1.1 in /superset-frontend (#30119)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2025-07-30 08:54:58 -07:00
dependabot[bot]
0cbf4d5d4d chore(deps): bump d3-scale from 3.3.0 to 4.0.2 in /superset-frontend/packages/superset-ui-core (#31534)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2025-07-30 08:52:30 -07:00
Hari Kiran
6006a21378 docs(development): fix comment in the dockerfile (#34391) 2025-07-29 21:53:46 -07:00
Maxime Beauchemin
bf967d6ba4 fix(charts): Fix unquoted 'Others' literal in series limit GROUP BY clause (#34390)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-29 17:36:10 -07:00
Hari Kiran
131ae5aa9d docs(development): fix typo in the dockerfile (#34387) 2025-07-29 14:24:18 -07:00
Cesc Bausà
eca28582b6 feat(i18n): update Spanish translations (messages.po) (#34206) 2025-07-29 13:49:40 -07:00
Maxime Beauchemin
14e90a0f52 feat: Add GitHub Codespaces support with docker-compose-light (#34376)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-29 13:10:17 -07:00
Maxime Beauchemin
a1c39d4906 feat(charts): Enable async buildQuery support for complex chart logic (#34383)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-29 13:08:55 -07:00
Maxime Beauchemin
0964a8bb7a fix(big number with trendline): running 2 identical queries for no good reason (#34296) 2025-07-29 13:07:28 -07:00
Beto Dealmeida
8de8f95a3c feat: allow creating dataset without exploring (#34380) 2025-07-29 15:43:47 -04:00
Maxime Beauchemin
16db999067 fix: rate limiting issues with example data hosted on github.com (#34381) 2025-07-29 11:19:29 -07:00
Beto Dealmeida
972be15dda feat: focus on text input when modal opens (#34379) 2025-07-29 14:01:10 -04:00
Maxime Beauchemin
c9e06714f8 fix: prevent theme initialization errors during fresh installs (#34339)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-29 09:32:53 -07:00
Beto Dealmeida
32626ab707 fix: use catalog name on generated queries (#34360) 2025-07-29 12:30:46 -04:00
181 changed files with 31831 additions and 6582 deletions

5
.devcontainer/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Superset Development with GitHub Codespaces
For complete documentation on using GitHub Codespaces with Apache Superset, please see:
**[Setting up a Development Environment - GitHub Codespaces](https://superset.apache.org/docs/contributing/development#github-codespaces-cloud-development)**

View File

@@ -0,0 +1,19 @@
{
// Extend the base configuration
"extends": "../devcontainer-base.json",
"name": "Apache Superset Development (Default)",
// Forward ports for development
"forwardPorts": [9001],
"portsAttributes": {
"9001": {
"label": "Superset (via Webpack Dev Server)",
"onAutoForward": "notify",
"visibility": "public"
}
},
// Auto-start Superset on Codespace resume
"postStartCommand": ".devcontainer/start-superset.sh"
}

View File

@@ -0,0 +1,39 @@
{
"name": "Apache Superset Development",
// Keep this in sync with the base image in Dockerfile (ARG PY_VER)
// Using the same base as Dockerfile, but non-slim for dev tools
"image": "python:3.11.13-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"dockerDashComposeVersion": "v2"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/common-utils:2": {
"configureZshAsDefaultShell": true
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
},
// Run commands after container is created
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
// VS Code customizations
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
}
}

View File

@@ -0,0 +1,52 @@
{
"name": "Apache Superset Development",
// Keep this in sync with the base image in Dockerfile (ARG PY_VER)
// Using the same base as Dockerfile, but non-slim for dev tools
"image": "python:3.11.13-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"dockerDashComposeVersion": "v2"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/common-utils:2": {
"configureZshAsDefaultShell": true
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
},
// Forward ports for development
"forwardPorts": [9001],
"portsAttributes": {
"9001": {
"label": "Superset (via Webpack Dev Server)",
"onAutoForward": "notify",
"visibility": "public"
}
},
// Run commands after container is created
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
// Auto-start Superset on Codespace resume
"postStartCommand": ".devcontainer/start-superset.sh",
// VS Code customizations
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
}
}

32
.devcontainer/setup-dev.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
# Setup script for Superset Codespaces development environment
echo "🔧 Setting up Superset development environment..."
# The universal image has most tools, just need Superset-specific libs
echo "📦 Installing Superset-specific dependencies..."
sudo apt-get update
sudo apt-get install -y \
libsasl2-dev \
libldap2-dev \
libpq-dev \
tmux \
gh
# Install uv for fast Python package management
echo "📦 Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
# Add cargo/bin to PATH for uv
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc
# Install Claude Code CLI via npm
echo "🤖 Installing Claude Code..."
npm install -g @anthropic-ai/claude-code
# Make the start script executable
chmod +x .devcontainer/start-superset.sh
echo "✅ Development environment setup complete!"
echo "🚀 Run '.devcontainer/start-superset.sh' to start Superset"

69
.devcontainer/start-superset.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# Startup script for Superset in Codespaces
echo "🚀 Starting Superset in Codespaces..."
echo "🌐 Frontend will be available at port 9001"
# Check if MCP is enabled
if [ "$ENABLE_MCP" = "true" ]; then
echo "🤖 MCP Service will be available at port 5008"
fi
# Find the workspace directory (Codespaces clones as 'superset', not 'superset-2')
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
if [ -n "$WORKSPACE_DIR" ]; then
cd "$WORKSPACE_DIR"
echo "📁 Working in: $WORKSPACE_DIR"
else
echo "📁 Using current directory: $(pwd)"
fi
# Check if docker is running
if ! docker info > /dev/null 2>&1; then
echo "⏳ Waiting for Docker to start..."
sleep 5
fi
# Clean up any existing containers
echo "🧹 Cleaning up existing containers..."
docker-compose -f docker-compose-light.yml --profile mcp down
# Start services
echo "🏗️ Building and starting services..."
echo ""
echo "📝 Once started, login with:"
echo " Username: admin"
echo " Password: admin"
echo ""
echo "📋 Running in foreground with live logs (Ctrl+C to stop)..."
# Run docker-compose and capture exit code
if [ "$ENABLE_MCP" = "true" ]; then
echo "🤖 Starting with MCP Service enabled..."
docker-compose -f docker-compose-light.yml --profile mcp up
else
docker-compose -f docker-compose-light.yml up
fi
EXIT_CODE=$?
# If it failed, provide helpful instructions
if [ $EXIT_CODE -ne 0 ] && [ $EXIT_CODE -ne 130 ]; then # 130 is Ctrl+C
echo ""
echo "❌ Superset startup failed (exit code: $EXIT_CODE)"
echo ""
echo "🔄 To restart Superset, run:"
echo " .devcontainer/start-superset.sh"
echo ""
echo "🔧 For troubleshooting:"
echo " # View logs:"
echo " docker-compose -f docker-compose-light.yml logs"
echo ""
echo " # Clean restart (removes volumes):"
echo " docker-compose -f docker-compose-light.yml down -v"
echo " .devcontainer/start-superset.sh"
echo ""
echo " # Common issues:"
echo " - Network timeouts: Just retry, often transient"
echo " - Port conflicts: Check 'docker ps'"
echo " - Database issues: Try clean restart with -v"
fi

View File

@@ -0,0 +1,29 @@
{
// Extend the base configuration
"extends": "../devcontainer-base.json",
"name": "Apache Superset Development with MCP",
// Forward ports for development
"forwardPorts": [9001, 5008],
"portsAttributes": {
"9001": {
"label": "Superset (via Webpack Dev Server)",
"onAutoForward": "notify",
"visibility": "public"
},
"5008": {
"label": "MCP Service (Model Context Protocol)",
"onAutoForward": "notify",
"visibility": "private"
}
},
// Auto-start Superset with MCP on Codespace resume
"postStartCommand": "ENABLE_MCP=true .devcontainer/start-superset.sh",
// Environment variables
"containerEnv": {
"ENABLE_MCP": "true"
}
}

215
CHART_METADATA_API.md Normal file
View File

@@ -0,0 +1,215 @@
# Chart Metadata API Reference
The Superset MCP service provides rich metadata alongside chart generation to enable better UI integration and user experiences.
## Background & Design Philosophy
Modern chart systems need to provide more than just visual output. Inspired by contemporary web standards and LLM integration patterns, this metadata system addresses several key needs:
**Accessibility-First Design**: Following WCAG guidelines and `aria-*` attribute patterns, charts include semantic descriptions and accessibility metadata to ensure inclusive experiences.
**Rich Context for AI Systems**: Similar to how platforms like social media generate rich previews (OpenGraph, Twitter Cards), charts provide semantic understanding beyond just visual representation - enabling AI agents to reason about and describe visualizations meaningfully.
**Performance-Aware Integration**: Modern web APIs emphasize performance transparency (Core Web Vitals, etc.). Charts include execution metrics and optimization suggestions to help UIs make informed decisions about rendering and user feedback.
**Capability-Driven UX**: Rather than requiring UIs to hardcode chart type behaviors, the system exposes what each chart can actually do - enabling dynamic, contextual interfaces that adapt to chart capabilities.
## Overview
When generating charts via `generate_chart`, the response includes structured metadata that helps UIs:
- Present appropriate controls and interactions
- Generate accessible descriptions
- Optimize rendering performance
- Guide user workflows
## Metadata Types
### ChartCapabilities
Describes what interactions and features the chart supports.
```python
{
"supports_interaction": bool, # User can interact (zoom, pan, hover)
"supports_real_time": bool, # Chart can update with live data
"supports_drill_down": bool, # Can navigate to more detailed views
"supports_export": bool, # Can be exported to other formats
"optimal_formats": [ # Recommended preview formats
"url", # Static image URL
"interactive", # HTML with JavaScript controls
"ascii", # Text-based representation
"vega_lite" # Vega-Lite specification
],
"data_types": [ # Types of data visualized
"time_series", # Time-based data
"categorical", # Discrete categories
"metric" # Numeric measurements
]
}
```
**UI Integration:**
- Show/hide interaction controls based on `supports_interaction`
- Enable real-time updates if `supports_real_time`
- Display drill-down options for `supports_drill_down`
- Choose optimal preview format from `optimal_formats`
### ChartSemantics
Provides semantic understanding of what the chart represents and reveals.
```python
{
"primary_insight": "Shows trends and changes over time",
"data_story": "This line chart analyzes sales, revenue over Q1-Q4",
"recommended_actions": [
"Review data patterns and trends",
"Consider filtering for more detail",
"Export chart for reporting"
],
"anomalies": [], # Notable outliers (future enhancement)
"statistical_summary": {} # Key statistics (future enhancement)
}
```
**UI Integration:**
- Display `primary_insight` as chart description
- Use `data_story` for accessibility and tooltips
- Show `recommended_actions` as suggested next steps
- Highlight `anomalies` in the visualization
### AccessibilityMetadata
Information for creating inclusive, accessible chart experiences.
```python
{
"color_blind_safe": bool, # Uses colorblind-friendly palette
"alt_text": "Chart showing Sales Data over time",
"high_contrast_available": bool # High contrast version available
}
```
**UI Integration:**
- Use `alt_text` for screen readers
- Show accessibility indicators if `color_blind_safe`
- Offer high contrast mode if available
### PerformanceMetadata
Performance information for optimization and user feedback.
```python
{
"query_duration_ms": 1250, # Time to generate chart data
"cache_status": "hit|miss|error", # Whether data came from cache
"optimization_suggestions": [ # Performance improvement tips
"Consider adding date filters to reduce data volume",
"Chart complexity may impact load time"
]
}
```
**UI Integration:**
- Show loading indicators based on `query_duration_ms`
- Display cache status for debugging
- Present `optimization_suggestions` to users
- Warn about slow queries
## Example Response
```json
{
"chart": {
"id": 123,
"slice_name": "Sales Trends Q1-Q4",
"viz_type": "echarts_timeseries_line",
"url": "/explore/?slice_id=123"
},
"capabilities": {
"supports_interaction": true,
"supports_real_time": false,
"supports_drill_down": false,
"supports_export": true,
"optimal_formats": ["url", "interactive", "ascii"],
"data_types": ["time_series", "metric"]
},
"semantics": {
"primary_insight": "Shows trends and changes over time",
"data_story": "This line chart analyzes sales over Q1-Q4",
"recommended_actions": [
"Review seasonal patterns",
"Export for quarterly report"
]
},
"accessibility": {
"color_blind_safe": true,
"alt_text": "Line chart showing sales trends from Q1 to Q4",
"high_contrast_available": false
},
"performance": {
"query_duration_ms": 450,
"cache_status": "miss",
"optimization_suggestions": []
}
}
```
## Usage Examples
### React Component Integration
```jsx
function ChartComponent({ chartData }) {
const { capabilities, semantics, accessibility, performance } = chartData;
return (
<div>
{/* Accessibility */}
<img
src={chartData.chart.url}
alt={accessibility.alt_text}
aria-describedby="chart-description"
/>
{/* Semantic description */}
<p id="chart-description">{semantics.primary_insight}</p>
{/* Conditional controls based on capabilities */}
{capabilities.supports_interaction && (
<InteractiveControls />
)}
{capabilities.supports_export && (
<ExportButton />
)}
{/* Performance feedback */}
{performance.query_duration_ms > 2000 && (
<SlowQueryWarning suggestions={performance.optimization_suggestions} />
)}
{/* Recommended actions */}
<ActionSuggestions actions={semantics.recommended_actions} />
</div>
);
}
```
## Chart Type Mapping
Different chart types provide different capabilities:
| Chart Type | Interaction | Real-time | Drill-down | Optimal Formats |
|------------|------------|-----------|------------|-----------------|
| `echarts_timeseries_line` | ✅ | ✅ | ❌ | url, interactive, ascii |
| `echarts_timeseries_bar` | ✅ | ✅ | ❌ | url, interactive, ascii |
| `table` | ❌ | ❌ | ✅ | url, table, ascii |
| `pie` | ✅ | ❌ | ❌ | url, interactive |
## Future Enhancements
- **Statistical Summary**: Automatic calculation of mean, median, trends
- **Anomaly Detection**: Identification of outliers and unusual patterns
- **Smart Recommendations**: ML-powered suggestions for chart improvements
- **Accessibility Scoring**: Automated accessibility compliance checking

View File

@@ -74,7 +74,7 @@ RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.j
COPY superset-frontend /app/superset-frontend
######################################################################
# superset-node used for compile frontend assets
# superset-node is used for compiling frontend assets
######################################################################
FROM superset-node-ci AS superset-node
@@ -90,7 +90,7 @@ RUN --mount=type=cache,target=/root/.npm \
# Copy translation files
COPY superset/translations /app/superset/translations
# Build the frontend if not in dev mode
# Build translations if enabled, then cleanup localization files
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \
npm run build-translation; \
fi; \

View File

@@ -180,6 +180,7 @@ pre-commit run eslint # Frontend linting
## Platform-Specific Instructions
- **[LLMS.md](LLMS.md)** - General LLM development guide (READ THIS FIRST)
- **[CLAUDE.md](CLAUDE.md)** - For Claude/Anthropic tools
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
- **[GEMINI.md](GEMINI.md)** - For Google Gemini tools

View File

@@ -25,6 +25,12 @@
# - Volumes are isolated by project name (e.g., project1_db_home_light, project2_db_home_light)
# - Database name is intentionally different (superset_light) to prevent accidental cross-connections
#
# MCP Service (Model Context Protocol):
# - Optional service for LLM agent integration, available under 'mcp' profile
# - To include MCP: docker-compose -f docker-compose-light.yml --profile mcp up
# - MCP runs on port 5008 by default (customize with MCP_PORT=5009)
# - Enable SQL debugging with MCP_SQL_DEBUG=true
#
# For verbose logging during development:
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
# -----------------------------------------------------------------------
@@ -150,6 +156,37 @@ services:
required: false
volumes: *superset-volumes
superset-mcp-light:
profiles:
- mcp
build:
<<: *common-build
command: ["/app/docker/docker-bootstrap.sh", "mcp"]
restart: unless-stopped
ports:
- "127.0.0.1:${MCP_PORT:-5008}:5008" # Parameterized port
extra_hosts:
- "host.docker.internal:host-gateway"
user: *superset-user
depends_on:
superset-init-light:
condition: service_completed_successfully
volumes: *superset-volumes
env_file:
- path: docker/.env # default
required: true
- path: docker/.env-local # optional override
required: false
environment:
# Override DB connection for light service
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
# Use light-specific config that disables Redis
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
# Enable SQL debugging for MCP if needed
SQLALCHEMY_DEBUG: ${MCP_SQL_DEBUG:-false}
volumes:
superset_home_light:
external: false

View File

@@ -78,6 +78,10 @@ case "${1}" in
echo "Starting web app..."
/usr/bin/run-server.sh
;;
mcp)
echo "Starting MCP service..."
superset mcp run --host 0.0.0.0 --port ${MCP_PORT:-5008} --debug
;;
*)
echo "Unknown Operation!!!"
;;

View File

@@ -120,6 +120,78 @@ docker volume rm superset_db_home
docker-compose up
```
## GitHub Codespaces (Cloud Development)
GitHub Codespaces provides a complete, pre-configured development environment in the cloud. This is ideal for:
- Quick contributions without local setup
- Consistent development environments across team members
- Working from devices that can't run Docker locally
- Safe experimentation in isolated environments
:::info
We're grateful to GitHub for providing this excellent cloud development service that makes
contributing to Apache Superset more accessible to developers worldwide.
:::
### Getting Started with Codespaces
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=codespaces&geo=UsWest&devcontainer_path=.devcontainer%2Fdevcontainer.json)
:::caution
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
Smaller instances will not have sufficient resources to run Superset effectively.
:::
2. **Wait for Setup**: The initial setup takes several minutes. The Codespace will:
- Build the development container
- Install all dependencies
- Start all required services (PostgreSQL, Redis, etc.)
- Initialize the database with example data
3. **Access Superset**: Once ready, check the **PORTS** tab in VS Code for port `9001`.
Click the globe icon to open Superset in your browser.
- Default credentials: `admin` / `admin`
### Key Features
- **Auto-reload**: Both Python and TypeScript files auto-refresh on save
- **Pre-installed Extensions**: VS Code extensions for Python, TypeScript, and database tools
- **Multiple Instances**: Run multiple Codespaces for different branches/features
- **SSH Access**: Connect via terminal using `gh cs ssh` or through the GitHub web UI
- **VS Code Integration**: Works seamlessly with VS Code desktop app
### Managing Codespaces
- **List active Codespaces**: `gh cs list`
- **SSH into a Codespace**: `gh cs ssh`
- **Stop a Codespace**: Via GitHub UI or `gh cs stop`
- **Delete a Codespace**: Via GitHub UI or `gh cs delete`
### Debugging and Logs
Since Codespaces uses `docker-compose-light.yml`, you can monitor all services:
```bash
# Stream logs from all services
docker compose -f docker-compose-light.yml logs -f
# Stream logs from a specific service
docker compose -f docker-compose-light.yml logs -f superset
# View last 100 lines and follow
docker compose -f docker-compose-light.yml logs --tail=100 -f
# List all running services
docker compose -f docker-compose-light.yml ps
```
:::tip
Codespaces automatically stop after 30 minutes of inactivity to save resources.
Your work is preserved and you can restart anytime.
:::
## Installing Development Tools
:::note

View File

@@ -0,0 +1,741 @@
---
title: API Reference
sidebar_position: 3
version: 1
---
# MCP Tools API Reference
Complete reference for all 16 MCP tools with request/response examples.
> 🚀 **First time here?** Start with [Dashboard Tools](#dashboard-tools) or [Chart Tools](#chart-tools) to see the most commonly used features.
>
> 🔐 **Need authentication?** See the [Authentication Guide](./authentication) for JWT setup.
>
> 🔧 **Want to add tools?** Check the [Development Guide](./development#adding-new-tools) for step-by-step instructions.
## Dashboard Tools
### list_dashboards
List dashboards with search, filtering, and pagination support.
**Request Schema:**
```json
{
"search": "sales", // Optional: Search term
"filters": [ // Optional: Advanced filters
{
"col": "published",
"opr": "eq",
"value": true
}
],
"page": 1, // Optional: Page number (default: 1)
"page_size": 20, // Optional: Items per page (default: 20)
"select_columns": [ // Optional: Specific columns
"id", "dashboard_title", "uuid"
],
"use_cache": true // Optional: Use cached data (default: true)
}
```
**Response Example:**
```json
{
"dashboards": [
{
"id": 1,
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dashboard_title": "Sales Performance",
"url": "/superset/dashboard/1/",
"published": true,
"owners": ["admin"],
"created_on": "2024-01-15T10:30:00Z",
"changed_on": "2024-01-20T14:15:00Z"
}
],
"total_count": 45,
"page": 1,
"page_size": 20,
"cache_status": {
"cache_hit": true,
"cache_age_seconds": 300
}
}
```
### get_dashboard_info
Get detailed information about a specific dashboard.
**Request Schema:**
```json
{
"identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", // ID, UUID, or slug
"use_cache": true
}
```
**Response Example:**
```json
{
"dashboard_id": 1,
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"dashboard_title": "Sales Performance Dashboard",
"slug": "sales-performance",
"url": "/superset/dashboard/1/",
"published": true,
"owners": ["admin", "analyst"],
"roles": ["Sales Team"],
"charts": [
{
"id": 10,
"slice_name": "Monthly Revenue",
"viz_type": "line"
},
{
"id": 11,
"slice_name": "Regional Sales",
"viz_type": "bar"
}
],
"filters": [
{
"column": "region",
"type": "select"
}
],
"created_on": "2024-01-15T10:30:00Z",
"changed_on": "2024-01-20T14:15:00Z"
}
```
### generate_dashboard
Create a new dashboard with multiple charts.
**Request Schema:**
```json
{
"chart_ids": [10, 11, 12, 13],
"dashboard_title": "Q4 Performance Dashboard",
"description": "Quarterly performance metrics and KPIs",
"published": true,
"layout_type": "grid" // Optional: "grid" or "tabs"
}
```
**Response Example:**
```json
{
"dashboard_id": 25,
"uuid": "new-dash-uuid-here",
"dashboard_title": "Q4 Performance Dashboard",
"url": "/superset/dashboard/25/",
"charts_added": 4,
"layout": {
"type": "grid",
"columns": 2,
"rows": 2
},
"created_on": "2024-01-25T16:45:00Z"
}
```
## Chart Tools
### list_charts
List charts with advanced filtering and search capabilities.
**Request Schema:**
```json
{
"search": "revenue",
"filters": [
{
"col": "viz_type",
"opr": "in",
"value": ["line", "bar", "area"]
}
],
"page": 1,
"page_size": 25,
"select_columns": ["id", "slice_name", "viz_type", "uuid"],
"use_cache": true
}
```
**Response Example:**
```json
{
"charts": [
{
"id": 10,
"uuid": "chart-uuid-1",
"slice_name": "Monthly Revenue Trend",
"viz_type": "line",
"datasource_name": "sales_data",
"owners": ["admin"],
"created_on": "2024-01-10T09:15:00Z"
}
],
"total_count": 125,
"page": 1,
"page_size": 25
}
```
### get_chart_info
Get comprehensive chart information including configuration.
**Request Schema:**
```json
{
"identifier": 10, // ID or UUID
"include_form_data": true, // Include chart configuration
"use_cache": true
}
```
**Response Example:**
```json
{
"chart_id": 10,
"uuid": "chart-uuid-1",
"slice_name": "Monthly Revenue Trend",
"viz_type": "line",
"datasource_id": 5,
"datasource_name": "sales_data",
"datasource_type": "table",
"form_data": {
"viz_type": "line",
"x_axis": "month",
"metrics": ["sum__revenue"],
"time_range": "Last 12 months"
},
"query_context": {
"datasource": {"id": 5, "type": "table"},
"queries": [{"columns": [], "metrics": ["sum__revenue"]}]
},
"explore_url": "/superset/explore/?form_data=%7B%22slice_id%22%3A10%7D",
"owners": ["admin"],
"created_on": "2024-01-10T09:15:00Z",
"changed_on": "2024-01-15T11:30:00Z"
}
```
### generate_chart
Create a new chart with specified configuration.
**Request Schema:**
```json
{
"dataset_id": "5",
"config": {
"chart_type": "xy",
"x": {"name": "month", "label": "Month"},
"y": [
{
"name": "revenue",
"aggregate": "SUM",
"label": "Total Revenue"
},
{
"name": "orders",
"aggregate": "COUNT",
"label": "Order Count"
}
],
"kind": "line",
"x_axis": {
"title": "Month",
"format": "smart_date"
},
"y_axis": {
"title": "Revenue ($)",
"format": "$,.0f"
},
"legend": {
"show": true,
"position": "top"
}
},
"slice_name": "Revenue and Orders Trend",
"description": "Monthly revenue and order count comparison",
"save_chart": true,
"generate_preview": true,
"preview_formats": ["url", "ascii"]
}
```
**Response Example:**
```json
{
"chart_id": 45,
"uuid": "new-chart-uuid",
"slice_name": "Revenue and Orders Trend",
"viz_type": "echarts_timeseries_line",
"datasource_id": 5,
"explore_url": "/superset/explore/?form_data=%7B%22slice_id%22%3A45%7D",
"query_executed": true,
"query_result": {
"status": "success",
"row_count": 12,
"execution_time": 0.145
},
"preview": {
"url": {
"preview_url": "http://localhost:5008/screenshot/chart/45.png",
"width": 800,
"height": 600
},
"ascii": {
"ascii_content": "Revenue Trend\n==============\nJan |████████████████ $125K\nFeb |██████████████████ $140K\n...",
"width": 80,
"height": 20
}
},
"created_on": "2024-01-25T14:20:00Z"
}
```
### get_chart_data
Export chart data in multiple formats.
**Request Schema:**
```json
{
"identifier": 10,
"format": "json", // "json", "csv", "excel"
"limit": 1000, // Optional: Row limit
"offset": 0, // Optional: Row offset
"filters": [ // Optional: Additional filters
{
"column": "region",
"op": "=",
"value": "US"
}
],
"use_cache": true,
"force_refresh": false
}
```
**Response Example:**
```json
{
"data": [
{
"month": "2024-01",
"revenue": 125000,
"orders": 450
},
{
"month": "2024-02",
"revenue": 140000,
"orders": 520
}
],
"total_rows": 12,
"columns": [
{"name": "month", "type": "DATE"},
{"name": "revenue", "type": "BIGINT"},
{"name": "orders", "type": "BIGINT"}
],
"query": {
"sql": "SELECT month, SUM(revenue) as revenue, COUNT(*) as orders FROM sales_data GROUP BY month ORDER BY month",
"execution_time": 0.089
},
"cache_status": {
"cache_hit": false,
"cache_type": "query",
"refreshed": true
}
}
```
### get_chart_preview
Generate chart previews in multiple formats.
**Request Schema:**
```json
{
"identifier": 10,
"format": "url", // "url", "base64", "ascii", "table"
"width": 800, // For image formats
"height": 600, // For image formats
"ascii_width": 80, // For ASCII format
"ascii_height": 20, // For ASCII format
"use_cache": true
}
```
**Response Examples:**
**URL Format:**
```json
{
"format": "url",
"preview_url": "http://localhost:5008/screenshot/chart/10.png",
"width": 800,
"height": 600,
"supports_interaction": false,
"expires_at": "2024-01-26T14:20:00Z"
}
```
**ASCII Format:**
```json
{
"format": "ascii",
"ascii_content": "Monthly Revenue Trend\n=====================\n\nJan |████████████████████ $125K\nFeb |██████████████████████ $140K\nMar |███████████████████ $135K\nApr |█████████████████████████ $155K\n\nRange: $125K to $155K\n▁▃▂▅▇▆▄▃▂▄▅▆▇▅▃▂",
"width": 80,
"height": 20,
"supports_color": false
}
```
**Table Format:**
```json
{
"format": "table",
"table_data": "Monthly Revenue Data\n====================\n\nMonth | Revenue | Orders\n---------|----------|--------\nJan 2024 | $125,000 | 450\nFeb 2024 | $140,000 | 520\nMar 2024 | $135,000 | 495\n\nTotal: 12 rows × 3 columns",
"row_count": 12,
"supports_sorting": true
}
```
## Dataset Tools
### list_datasets
List available datasets with columns and metrics.
**Request Schema:**
```json
{
"search": "sales",
"filters": [
{
"col": "is_active",
"opr": "eq",
"value": true
}
],
"include_columns": true, // Include column metadata
"include_metrics": true, // Include metric metadata
"page": 1,
"page_size": 15
}
```
**Response Example:**
```json
{
"datasets": [
{
"id": 1,
"uuid": "dataset-uuid-1",
"table_name": "sales_data",
"database_name": "main_warehouse",
"schema": "public",
"owners": ["admin"],
"columns": [
{
"column_name": "region",
"type": "VARCHAR",
"is_active": true,
"is_dttm": false
},
{
"column_name": "revenue",
"type": "DECIMAL",
"is_active": true,
"is_dttm": false
}
],
"metrics": [
{
"metric_name": "sum__revenue",
"expression": "SUM(revenue)",
"metric_type": "sum"
}
],
"created_on": "2024-01-05T08:00:00Z"
}
],
"total_count": 23,
"page": 1,
"page_size": 15
}
```
### get_dataset_info
Get detailed dataset information with full column/metric metadata.
**Request Schema:**
```json
{
"identifier": "dataset-uuid-1", // ID or UUID
"include_columns": true,
"include_metrics": true,
"use_cache": true
}
```
**Response Example:**
```json
{
"dataset_id": 1,
"uuid": "dataset-uuid-1",
"table_name": "sales_data",
"database_name": "main_warehouse",
"database_id": 1,
"schema": "public",
"sql": null,
"is_active": true,
"owners": ["admin", "data_team"],
"columns": [
{
"id": 101,
"column_name": "region",
"type": "VARCHAR",
"is_active": true,
"is_dttm": false,
"groupby": true,
"filterable": true,
"description": "Geographic region"
},
{
"id": 102,
"column_name": "order_date",
"type": "DATE",
"is_active": true,
"is_dttm": true,
"groupby": true,
"filterable": true
}
],
"metrics": [
{
"id": 201,
"metric_name": "sum__revenue",
"expression": "SUM(revenue)",
"metric_type": "sum",
"is_active": true,
"description": "Total revenue"
}
],
"created_on": "2024-01-05T08:00:00Z",
"changed_on": "2024-01-18T12:30:00Z"
}
```
## System Tools
### get_superset_instance_info
Get Superset instance information and statistics.
**Request Schema:**
```json
{
"include_statistics": true, // Include usage statistics
"include_tools": true, // Include available MCP tools
"use_cache": true
}
```
**Response Example:**
```json
{
"version": "4.1.0",
"build": "apache-superset-4.1.0",
"mcp_service_version": "1.0.0",
"authentication": {
"enabled": true,
"type": "jwt_bearer",
"required_scopes": ["dashboard:read", "chart:read"]
},
"statistics": {
"dashboards": {
"total": 45,
"published": 32
},
"charts": {
"total": 125,
"by_viz_type": {
"line": 35,
"bar": 28,
"table": 42,
"pie": 20
}
},
"datasets": {
"total": 23,
"active": 18
},
"users": {
"total": 15,
"active": 12
}
},
"mcp_tools": [
{
"name": "list_dashboards",
"description": "List dashboards with search and filtering",
"category": "dashboard"
},
{
"name": "generate_chart",
"description": "Create new charts programmatically",
"category": "chart"
}
],
"database_connections": [
{
"id": 1,
"database_name": "main_warehouse",
"backend": "postgresql",
"status": "healthy"
}
],
"cache_status": {
"enabled": true,
"backend": "redis",
"hit_rate": 0.85
}
}
```
### generate_explore_link
Generate Superset explore URLs with pre-configured chart settings.
**Request Schema:**
```json
{
"dataset_id": "1",
"chart_config": {
"viz_type": "line",
"x_axis": "month",
"metrics": ["sum__revenue"],
"time_range": "Last 6 months"
},
"title": "Revenue Analysis",
"cache_form_data": true
}
```
**Response Example:**
```json
{
"explore_url": "/superset/explore/?form_data_key=abc123def456",
"full_url": "http://localhost:8088/superset/explore/?form_data_key=abc123def456",
"form_data_key": "abc123def456",
"expires_at": "2024-01-26T16:45:00Z",
"chart_config": {
"viz_type": "line",
"datasource": "1__table",
"x_axis": "month",
"metrics": ["sum__revenue"],
"time_range": "Last 6 months"
}
}
```
## SQL Lab Tools
### open_sql_lab_with_context
Open SQL Lab with pre-configured database, schema, and SQL.
**Request Schema:**
```json
{
"database_connection_id": 1,
"schema": "public",
"dataset_in_context": "sales_data",
"sql": "SELECT region, SUM(revenue) as total_revenue\nFROM sales_data \nWHERE order_date >= '2024-01-01'\nGROUP BY region\nORDER BY total_revenue DESC",
"title": "Regional Sales Analysis"
}
```
**Response Example:**
```json
{
"sql_lab_url": "/superset/sqllab/?dbid=1&schema=public&sql_template=encoded_sql_here",
"full_url": "http://localhost:8088/superset/sqllab/?dbid=1&schema=public&sql_template=encoded_sql_here",
"database_connection": {
"id": 1,
"database_name": "main_warehouse",
"backend": "postgresql"
},
"schema": "public",
"sql_template": "SELECT region, SUM(revenue) as total_revenue...",
"context": {
"dataset": "sales_data",
"title": "Regional Sales Analysis"
}
}
```
## Error Responses
All tools can return error responses with this structure:
```json
{
"error": "Chart not found with identifier: 999",
"error_type": "NotFound",
"suggestions": [
"Verify the chart ID exists",
"Check if you have permission to access this chart",
"Try using the chart UUID instead of ID"
],
"details": {
"identifier": 999,
"identifier_type": "id"
}
}
```
## Cache Status
Many responses include cache status information:
```json
{
"cache_status": {
"cache_hit": true, // Data served from cache
"cache_type": "query", // Type: query, metadata, form_data
"cache_age_seconds": 300, // Age of cached data
"refreshed": false // Whether cache was refreshed
}
}
```
This API reference provides complete documentation for integrating with the Superset MCP service, including all request schemas, response formats, and error handling patterns.
## What's Next?
### 🔐 **Ready for Production?**
Set up authentication and security with the [Authentication Guide](./authentication).
### 🔧 **Want to Add More Tools?**
Learn how to extend the MCP service in the [Development Guide](./development).
### 🏗️ **Need Architecture Details?**
Understand the system design in the [Architecture Overview](./architecture).
### 🏢 **Enterprise Features?**
Explore advanced capabilities in the [Preset Integration Guide](./preset-integration).
> 📖 **Back to Documentation Index**: [MCP Service](./intro)

View File

@@ -0,0 +1,191 @@
---
title: Architecture Overview
sidebar_position: 5
version: 1
---
# Architecture Overview
The Superset Model Context Protocol (MCP) service provides a modular, schema-driven interface for programmatic access to Superset dashboards, charts, datasets, and instance metadata. Built on FastMCP for LLM agents and automation tools.
**Status:** Phase 1 Complete. Core functionality stable, authentication production-ready. See [SIP-171](https://github.com/apache/superset/issues/33870) for roadmap.
## Core Architecture
### Tool Structure
- **16 MCP tools** organized by domain: `dashboard/`, `chart/`, `dataset/`, `system/`
- All tools decorated with `@mcp.tool` and `@mcp_auth_hook`
- **Import inside functions**: All Superset DAOs/commands imported in function body to ensure proper app context
- Pydantic v2 schemas with LLM/OpenAPI-compatible field descriptions
### Request Schema Pattern
Eliminates LLM parameter validation issues using structured request objects:
```python
# New approach - single request object
get_dataset_info(request={"identifier": 123}) # ID
get_dataset_info(request={"identifier": "uuid-string"}) # UUID
# Old approach - replaced
get_dataset_info(dataset_id=123)
```
### Multi-Identifier Support
- **Charts/Datasets**: ID (numeric) or UUID (string)
- **Dashboards**: ID (numeric), UUID (string), or slug (string)
- Validation prevents conflicting parameters (search + filters)
## Available Tools
### Dashboard Tools (5)
- `list_dashboards` - List with search/filters/pagination
- `get_dashboard_info` - Get by ID/UUID/slug
- `get_dashboard_available_filters` - Discover filterable columns
- `generate_dashboard` - Create dashboards with multiple charts
- `add_chart_to_existing_dashboard` - Add charts to existing dashboards
### Chart Tools (8)
- `list_charts` - List with search/filters/pagination
- `get_chart_info` - Get by ID/UUID
- `get_chart_available_filters` - Discover filterable columns
- `generate_chart` - Create charts (table, line, bar, area, scatter)
- `update_chart` - Update saved charts
- `update_chart_preview` - Update cached previews
- `get_chart_data` - Export data (JSON/CSV/Excel)
- `get_chart_preview` - Screenshots, ASCII art, table previews
### Dataset Tools (3)
- `list_datasets` - List with columns/metrics
- `get_dataset_info` - Get by ID/UUID with metadata
- `get_dataset_available_filters` - Discover filterable columns
### System Tools (2)
- `get_superset_instance_info` - Instance statistics and version
- `generate_explore_link` - Generate chart exploration URLs
### SQL Lab Tools (1)
- `open_sql_lab_with_context` - Pre-configured SQL Lab sessions
## Authentication & Security
### JWT Bearer Authentication
Production-ready authentication with configurable factory pattern:
```python
# In superset_config.py
MCP_AUTH_ENABLED = True
MCP_JWKS_URI = "https://auth.company.com/.well-known/jwks.json"
MCP_JWT_ISSUER = "https://auth.company.com/"
MCP_JWT_AUDIENCE = "superset-mcp-api"
```
### Scope-Based Authorization
| Tool Category | Required Scope |
|---------------|----------------|
| Dashboard ops | `dashboard:read` |
| Chart ops | `chart:read` / `chart:write` |
| Dataset ops | `dataset:read` |
| System ops | `instance:read` |
### Audit Logging
All operations logged with MCP context:
- User impersonation tracking
- Tool execution details
- Sanitized payloads (sensitive data redacted)
## Cache Control
Leverages Superset's existing cache layers with comprehensive control:
### Cache Types
1. **Query Result Cache** - Database query results
2. **Metadata Cache** - Table schemas, columns, metrics
3. **Form Data Cache** - Chart configurations
4. **Dashboard Cache** - Rendered components
### Cache Parameters
Tools support cache control through request schemas:
- `use_cache`: Enable/disable caching (default: true)
- `force_refresh`: Force cache refresh (default: false)
- `cache_timeout`: Override timeout in seconds
- `refresh_metadata`: Force metadata refresh
### Cache Status Reporting
```json
{
"cache_status": {
"cache_hit": true,
"cache_type": "query",
"cache_age_seconds": 300,
"refreshed": false
}
}
```
## Tool Abstractions
### Generic Base Classes
- **ModelListTool**: Handles list/search/filter operations with pagination
- **ModelGetInfoTool**: Single object retrieval by multiple identifier types
- **ModelGetAvailableFiltersTool**: Returns filterable columns/operators
### Implementation Pattern
```python
@mcp.tool
@mcp_auth_hook
def my_tool(request: MyRequest) -> MyResponse:
# Import Superset modules inside function
from superset.daos.dashboard import DashboardDAO
from superset.commands.chart.create import CreateChartCommand
# Tool implementation
return response
```
## Configuration
### URL Configuration
Centralized URL management for consistent link generation:
```python
# In superset_config.py
SUPERSET_WEBSERVER_ADDRESS = "http://localhost:8088" # Development
SUPERSET_WEBSERVER_ADDRESS = "https://superset.company.com" # Production
```
### Schema Design Principles
- **Minimal columns** in list responses
- **Optional fields** in info schemas for missing data handling
- **Null exclusion** for cleaner JSON responses
- **Type safety** with clear Pydantic validation
## Adding New Tools
1. **Choose domain folder**: `dashboard/`, `chart/`, `dataset/`, or `system/`
2. **Define schemas**: Use Pydantic with field descriptions
3. **Implement tool**:
- Decorate with `@mcp.tool` and `@mcp_auth_hook`
- Import Superset modules inside function body
- Use generic abstractions where applicable
4. **Register**: Add to appropriate `__init__.py`
5. **Test**: Add unit tests in `tests/unit_tests/mcp_service/`
## Current Status
### ✅ Phase 1 Complete
- FastMCP server with CLI
- JWT authentication with RBAC
- All 16 core tools implemented
- Request schema pattern
- Cache control system
- Audit logging
- 194+ unit tests
### 🎯 Future Enhancements
- Demo notebooks and video examples
- OAuth integration for user impersonation
- Enhanced chart rendering formats
- Advanced security features
**Production Ready**: Core functionality stable with comprehensive testing and authentication.
---
For setup and usage, see the [MCP Service overview](./intro).

View File

@@ -0,0 +1,434 @@
---
title: Authentication & Security
sidebar_position: 4
version: 1
---
# Authentication & Security
The MCP service provides enterprise-grade JWT Bearer authentication with flexible configuration options and comprehensive security controls.
## Quick Start
### Development Mode (Default)
:::tip
Authentication is **disabled by default** for local development - no configuration needed.
:::
```bash
# No configuration needed - service runs without authentication
superset mcp run --port 5008 --debug
```
### Production Mode
:::warning
Always enable authentication for production deployments to secure your Superset instance.
:::
Enable JWT authentication in your Superset configuration:
```python
# In superset_config.py
MCP_AUTH_ENABLED = True
MCP_JWKS_URI = "https://auth.company.com/.well-known/jwks.json"
MCP_JWT_ISSUER = "https://auth.company.com/"
MCP_JWT_AUDIENCE = "superset-mcp-api"
MCP_REQUIRED_SCOPES = ["dashboard:read", "chart:read", "dataset:read"]
```
## Configuration Options
### Option 1: Simple Configuration
Add to your `superset_config.py`:
```python
# Enable authentication
MCP_AUTH_ENABLED = True
# JWT settings
MCP_JWKS_URI = "https://auth.company.com/.well-known/jwks.json"
MCP_JWT_ISSUER = "https://auth.company.com/"
MCP_JWT_AUDIENCE = "superset-mcp-api"
MCP_REQUIRED_SCOPES = ["dashboard:read", "chart:read"]
# Optional: User resolution
MCP_JWT_USER_CLAIM = "sub" # JWT claim for username (default: "sub")
MCP_JWT_EMAIL_CLAIM = "email" # JWT claim for email (default: "email")
MCP_FALLBACK_USER = "admin" # Fallback user if JWT user not found
```
### Option 2: Custom Factory
For advanced authentication requirements:
```python
def create_custom_mcp_auth(app):
"""Custom auth factory for enterprise environments."""
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
return BearerAuthProvider(
jwks_uri=app.config["MCP_JWKS_URI"],
issuer=app.config["MCP_JWT_ISSUER"],
audience=app.config["MCP_JWT_AUDIENCE"],
required_scopes=app.config.get("MCP_REQUIRED_SCOPES", []),
user_resolver=custom_user_resolver,
cache_ttl=300 # Cache JWKS for 5 minutes
)
MCP_AUTH_FACTORY = create_custom_mcp_auth
```
### Option 3: Environment Variables
For containerized deployments:
```bash
# Environment variables
export MCP_AUTH_ENABLED=true
export MCP_JWKS_URI=https://auth.company.com/.well-known/jwks.json
export MCP_JWT_ISSUER=https://auth.company.com/
export MCP_JWT_AUDIENCE=superset-mcp-api
export MCP_REQUIRED_SCOPES=dashboard:read,chart:read,dataset:read
```
## Identity Provider Integration
### Auth0
```python
# Auth0 configuration
MCP_JWKS_URI = "https://your-tenant.auth0.com/.well-known/jwks.json"
MCP_JWT_ISSUER = "https://your-tenant.auth0.com/"
MCP_JWT_AUDIENCE = "superset-mcp-api"
```
### Okta
```python
# Okta configuration
MCP_JWKS_URI = "https://your-org.okta.com/oauth2/default/v1/keys"
MCP_JWT_ISSUER = "https://your-org.okta.com/oauth2/default"
MCP_JWT_AUDIENCE = "api://superset-mcp"
```
### AWS Cognito
```python
# Cognito configuration
MCP_JWKS_URI = "https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json"
MCP_JWT_ISSUER = "https://cognito-idp.{region}.amazonaws.com/{userPoolId}"
MCP_JWT_AUDIENCE = "your-app-client-id"
```
### Azure AD
```python
# Azure AD configuration
MCP_JWKS_URI = "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys"
MCP_JWT_ISSUER = "https://login.microsoftonline.com/{tenant}/v2.0"
MCP_JWT_AUDIENCE = "api://superset-mcp"
```
## Scope-Based Authorization
### Standard Scopes
The MCP service defines these standard scopes:
| Scope | Description | Required For |
|-------|-------------|--------------|
| `dashboard:read` | Read dashboard information | `list_dashboards`, `get_dashboard_info` |
| `dashboard:write` | Create/modify dashboards | `generate_dashboard`, `add_chart_to_existing_dashboard` |
| `chart:read` | Read chart information | `list_charts`, `get_chart_info`, `get_chart_data` |
| `chart:write` | Create/modify charts | `generate_chart`, `update_chart` |
| `dataset:read` | Read dataset information | `list_datasets`, `get_dataset_info` |
| `instance:read` | Read instance information | `get_superset_instance_info` |
### Custom Scopes
Define custom scopes for specific use cases:
```python
# Custom scope definitions
CUSTOM_MCP_SCOPES = {
"analytics:export": "Export analytical data",
"reports:generate": "Generate automated reports",
"admin:config": "Access administrative configuration"
}
# Map tools to custom scopes
def get_custom_required_scopes(tool_name: str) -> List[str]:
scope_map = {
"get_chart_data": ["chart:read", "analytics:export"],
"generate_dashboard": ["dashboard:write", "reports:generate"],
"get_superset_instance_info": ["instance:read", "admin:config"]
}
return scope_map.get(tool_name, [])
MCP_SCOPE_RESOLVER = get_custom_required_scopes
```
## JWT Token Format
### Required Claims
Your JWT tokens must include these standard claims:
```json
{
"iss": "https://auth.company.com/", // Issuer
"aud": "superset-mcp-api", // Audience
"sub": "user@company.com", // Subject (username)
"exp": 1704118800, // Expiration timestamp
"iat": 1704115200, // Issued at timestamp
"scope": "dashboard:read chart:read" // Space-separated scopes
}
```
### Optional Claims
Additional claims for enhanced functionality:
```json
{
"email": "user@company.com", // User email
"name": "John Doe", // Full name
"groups": ["analysts", "sales_team"], // User groups
"tenant_id": "company_123", // Multi-tenant ID
"role": "analyst" // User role
}
```
## Client Integration
### API Client Usage
```python
import requests
# Get JWT token from your identity provider
token = get_jwt_token()
# Call MCP service with Bearer authentication
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
response = requests.post(
"http://localhost:5008/call_tool",
headers=headers,
json={
"tool": "list_dashboards",
"arguments": {"search": "sales"}
}
)
data = response.json()
```
### Claude Desktop with Authentication
For Claude Desktop, the proxy script handles authentication:
```bash
#!/bin/bash
# run_proxy_with_auth.sh
# Get token from environment or file
if [ -f ~/.superset_mcp_token ]; then
TOKEN=$(cat ~/.superset_mcp_token)
else
TOKEN=${SUPERSET_MCP_TOKEN}
fi
# Export token for proxy
export MCP_AUTH_TOKEN="$TOKEN"
cd /path/to/superset
source venv/bin/activate
exec fastmcp proxy http://localhost:5008 --auth-header "Authorization: Bearer $TOKEN"
```
## User Resolution
### Default User Resolution
The service maps JWT claims to Superset users:
```python
def default_user_resolver(claims: Dict[str, Any]) -> User:
"""Default user resolution from JWT claims."""
# Extract username from configurable claim
username = claims.get(app.config.get("MCP_JWT_USER_CLAIM", "sub"))
# Find Superset user
user = security_manager.find_user(username=username)
if not user:
# Try email lookup
email = claims.get(app.config.get("MCP_JWT_EMAIL_CLAIM", "email"))
if email:
user = security_manager.find_user(email=email)
if not user and app.config.get("MCP_FALLBACK_USER"):
# Use fallback user for development
user = security_manager.find_user(username=app.config["MCP_FALLBACK_USER"])
return user
```
### Custom User Resolution
Implement custom user resolution logic:
```python
def custom_user_resolver(claims: Dict[str, Any]) -> User:
"""Custom user resolution for enterprise environments."""
# Extract custom claims
employee_id = claims.get("employee_id")
tenant_id = claims.get("tenant_id")
# Multi-tenant user lookup
user = find_user_by_employee_id(employee_id, tenant_id)
if user:
# Set additional context
user.mcp_tenant_id = tenant_id
user.mcp_groups = claims.get("groups", [])
return user
# Use custom resolver
MCP_USER_RESOLVER = custom_user_resolver
```
## Security Features
### Token Validation
Comprehensive JWT validation:
- **Signature verification**: RS256 with JWKS key rotation support
- **Expiration checking**: Automatic token expiry validation
- **Audience validation**: Prevents token reuse across services
- **Issuer validation**: Ensures tokens from trusted sources only
- **Scope validation**: Enforces tool-level permissions
### Request Security
- **HTTPS enforcement**: Production deployments should use HTTPS
- **Rate limiting**: Configurable per-user rate limits
- **Request logging**: All authenticated requests logged with user context
- **Input validation**: Comprehensive request schema validation
### Audit Logging
Every tool call is logged with security context:
```json
{
"timestamp": "2024-01-25T14:30:00Z",
"user_id": "user@company.com",
"tool_name": "get_chart_data",
"source": "mcp",
"jwt_subject": "user@company.com",
"jwt_scopes": ["chart:read", "analytics:export"],
"tenant_id": "company_123",
"request_id": "req_12345",
"execution_time": 0.145,
"status": "success"
}
```
## Testing Authentication
### Generate Test Tokens
For development and testing:
```python
from fastmcp.server.auth.providers.bearer import RSAKeyPair
# Generate test keypair
keypair = RSAKeyPair.generate()
print("Public key:", keypair.public_key)
# Create test token
token = keypair.create_token(
subject="test@example.com",
issuer="https://test.example.com",
audience="superset-mcp-api",
scopes=["dashboard:read", "chart:read", "dataset:read"],
expires_in=3600 # 1 hour
)
print("Test token:", token)
```
### Test Configuration
```python
# Test configuration with generated keypair
MCP_AUTH_ENABLED = True
MCP_JWT_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"""
MCP_JWT_ISSUER = "https://test.example.com"
MCP_JWT_AUDIENCE = "superset-mcp-api"
MCP_FALLBACK_USER = "admin"
```
### Manual Testing
```bash
# Test with curl
curl -X POST http://localhost:5008/call_tool \
-H "Authorization: Bearer $TEST_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tool": "get_superset_instance_info",
"arguments": {"include_statistics": true}
}'
```
## Troubleshooting
### Common Issues
**Token Validation Errors:**
```
Error: Invalid JWT signature
Solution: Verify JWKS_URI is accessible and contains correct keys
```
**User Not Found:**
```
Error: User not found for JWT subject
Solution: Check MCP_JWT_USER_CLAIM configuration and user exists in Superset
```
**Insufficient Scopes:**
```
Error: Missing required scope 'chart:read'
Solution: Update JWT token to include required scopes
```
### Debug Configuration
Enable debug logging for authentication issues:
```python
# Enhanced logging for auth debugging
import logging
logging.getLogger('superset.mcp_service.auth').setLevel(logging.DEBUG)
# Log all JWT validation steps
MCP_AUTH_DEBUG = True
```
This authentication guide provides comprehensive coverage for securing the MCP service in production environments while maintaining development flexibility.

View File

@@ -0,0 +1,705 @@
---
title: Development Guide
sidebar_position: 2
version: 1
---
# MCP Service Development Guide
This guide covers the internal architecture, development workflows, and patterns for extending the Superset MCP service.
> 🚀 **New to MCP?** Start with the [Overview](./overview) to understand what the service does before diving into development.
>
> 📚 **Need API examples?** Check the [API Reference](./api-reference) to see how existing tools work.
>
> 🔐 **Planning production use?** Review [Authentication](./authentication) for security considerations.
## Internal Architecture
### Component Overview
The MCP service follows a layered architecture with clear separation of concerns:
```mermaid
graph TB
subgraph "Transport Layer"
HTTP[HTTP Server :5008]
FastMCP[FastMCP Protocol Handler]
end
subgraph "Auth & Middleware Layer"
AuthHook[Auth Hook Decorator]
JWT[JWT Validator]
RBAC[RBAC Engine]
Audit[Audit Logger]
end
subgraph "Tool Layer"
Tools[16 MCP Tools<br/>Tool Decorated]
Schemas[Pydantic Schemas]
Validation[Request Validation]
end
subgraph "Business Logic Layer"
Generic[Generic Tool Abstractions]
ModelList[ModelListTool]
ModelGet[ModelGetInfoTool]
ModelFilter[ModelGetAvailableFiltersTool]
end
subgraph "Data Access Layer"
DAOs[Superset DAOs]
Commands[Superset Commands]
Cache[Cache Manager]
end
subgraph "Storage Layer"
MetaDB[(Metadata DB)]
DataWH[(Data Warehouse)]
Redis[(Redis Cache)]
end
HTTP --> FastMCP
FastMCP --> AuthHook
AuthHook --> JWT
JWT --> RBAC
RBAC --> Audit
Audit --> Tools
Tools --> Schemas
Schemas --> Validation
Validation --> Generic
Generic --> ModelList
Generic --> ModelGet
Generic --> ModelFilter
ModelList --> DAOs
ModelGet --> DAOs
ModelFilter --> DAOs
Tools --> Commands
Commands --> Cache
DAOs --> MetaDB
Commands --> MetaDB
Commands --> DataWH
Cache --> Redis
```
### Request Flow
Every MCP tool call follows this execution pattern:
```mermaid
sequenceDiagram
participant Client as LLM Client
participant MCP as FastMCP Server
participant Auth as Auth Hook
participant Tool as MCP Tool
participant Generic as Generic Abstraction
participant DAO as Superset DAO
participant DB as Database
Client->>+MCP: tool_call(request)
MCP->>+Auth: validate_and_authorize()
Auth->>Auth: Validate JWT token
Auth->>Auth: Check required scopes
Auth->>Auth: Set Flask g.user context
Auth->>Auth: Log audit event
Auth->>+Tool: execute_tool(validated_request)
Tool->>Tool: Parse Pydantic request schema
Tool->>+Generic: Use generic abstraction
Generic->>+DAO: Query Superset data
DAO->>+DB: Execute SQL
DB-->>-DAO: Return results
DAO-->>-Generic: Return objects
Generic->>Generic: Apply pagination/filtering
Generic-->>-Tool: Return formatted data
Tool->>Tool: Build Pydantic response schema
Tool-->>-Auth: Return response
Auth->>Auth: Log success audit event
Auth-->>-MCP: Return validated response
MCP-->>-Client: JSON response
```
### Tool Registration System
Tools are automatically discovered and registered through the decorator pattern:
```python
# superset/mcp_service/mcp_app.py
from fastmcp import FastMCP
# Global MCP instance
mcp = FastMCP("Superset MCP Service")
# Tools register themselves via decorators
@mcp.tool
@mcp_auth_hook(['chart:read'])
def get_chart_info(request: GetChartInfoRequest) -> GetChartInfoResponse:
# Tool implementation
pass
# All tool modules imported to trigger registration
from superset.mcp_service.chart.tool import *
from superset.mcp_service.dashboard.tool import *
from superset.mcp_service.dataset.tool import *
from superset.mcp_service.system.tool import *
```
## Development Patterns
### Tool Implementation Pattern
All tools follow this standardized pattern:
```python
# Example: superset/mcp_service/chart/tool/get_chart_info.py
from superset.mcp_service.auth import mcp_auth_hook
from superset.mcp_service.mcp_app import mcp
from superset.mcp_service.schemas.chart_schemas import (
GetChartInfoRequest,
GetChartInfoResponse,
ChartError
)
@mcp.tool
@mcp_auth_hook(['chart:read'])
def get_chart_info(request: GetChartInfoRequest) -> GetChartInfoResponse:
"""
Get detailed information about a specific chart.
Supports lookup by ID or UUID with comprehensive metadata.
"""
try:
# CRITICAL: Import Superset modules inside function
from superset.daos.chart import ChartDAO
from superset.models.slice import Slice
# Use generic abstraction for common operations
from superset.mcp_service.generic_tools import ModelGetInfoTool
tool = ModelGetInfoTool(
dao=ChartDAO,
model=Slice,
response_schema=GetChartInfoResponse,
identifier_field_map={
'id': 'id',
'uuid': 'uuid'
}
)
return tool.execute(request)
except Exception as e:
return ChartError(
error=f"Failed to get chart info: {str(e)}",
error_type="ChartInfoError"
)
```
### Schema Design Patterns
Pydantic schemas follow these conventions:
```python
# Request Schema Pattern
class GetChartInfoRequest(BaseModel):
"""Request to get detailed chart information."""
identifier: Union[int, str] = Field(
...,
description="Chart ID (numeric) or UUID (string)"
)
include_form_data: bool = Field(
default=True,
description="Whether to include chart configuration"
)
use_cache: bool = Field(
default=True,
description="Whether to use cached data"
)
# Response Schema Pattern
class GetChartInfoResponse(BaseModel):
"""Detailed chart information response."""
chart_id: int = Field(description="Chart numeric ID")
uuid: Optional[str] = Field(description="Chart UUID")
slice_name: str = Field(description="Chart display name")
viz_type: str = Field(description="Visualization type")
datasource_id: Optional[int] = Field(description="Dataset ID")
form_data: Optional[Dict[str, Any]] = Field(description="Chart configuration")
explore_url: Optional[str] = Field(description="Explore URL for editing")
# Cache status for transparency
cache_status: Optional[CacheStatus] = Field(description="Cache hit information")
# Error Schema Pattern
class ChartError(BaseModel):
"""Chart operation error response."""
error: str = Field(description="Error message")
error_type: str = Field(description="Error type identifier")
suggestions: Optional[List[str]] = Field(description="Suggested fixes")
```
### Generic Tool Abstractions
Common operations are abstracted into reusable classes:
```python
# superset/mcp_service/generic_tools.py
from typing import Type, Dict, Any, List, Optional
from pydantic import BaseModel
class ModelListTool:
"""Generic tool for list operations with pagination and filtering."""
def __init__(self,
dao: Type,
model: Type,
response_schema: Type[BaseModel],
default_columns: List[str] = None,
searchable_columns: List[str] = None):
self.dao = dao
self.model = model
self.response_schema = response_schema
self.default_columns = default_columns or []
self.searchable_columns = searchable_columns or []
def execute(self, request: BaseModel) -> BaseModel:
"""Execute list operation with pagination and filtering."""
# Build query with filters
query = self.dao.find_all()
# Apply search if provided
if hasattr(request, 'search') and request.search:
query = self._apply_search(query, request.search)
# Apply filters if provided
if hasattr(request, 'filters') and request.filters:
query = self._apply_filters(query, request.filters)
# Apply pagination
total = query.count()
if hasattr(request, 'page') and hasattr(request, 'page_size'):
offset = (request.page - 1) * request.page_size
query = query.offset(offset).limit(request.page_size)
# Execute query and serialize
results = query.all()
serialized = [self._serialize_model(obj) for obj in results]
return self.response_schema(
results=serialized,
total_count=total,
page=getattr(request, 'page', 1),
page_size=getattr(request, 'page_size', len(serialized))
)
class ModelGetInfoTool:
"""Generic tool for getting single object by multiple identifier types."""
def __init__(self,
dao: Type,
model: Type,
response_schema: Type[BaseModel],
identifier_field_map: Dict[str, str]):
self.dao = dao
self.model = model
self.response_schema = response_schema
self.identifier_field_map = identifier_field_map
def execute(self, request: BaseModel) -> BaseModel:
"""Execute get operation with multi-identifier support."""
identifier = request.identifier
# Determine identifier type and field
if isinstance(identifier, int):
field = self.identifier_field_map.get('id', 'id')
obj = self.dao.find_by_id(identifier)
elif isinstance(identifier, str):
if len(identifier) == 36 and '-' in identifier: # UUID format
field = self.identifier_field_map.get('uuid', 'uuid')
obj = self.dao.find_by_uuid(identifier)
else: # Assume slug
field = self.identifier_field_map.get('slug', 'slug')
obj = getattr(self.dao, 'find_by_slug', lambda x: None)(identifier)
if not obj:
raise ValueError(f"Object not found with identifier: {identifier}")
# Serialize and return
serialized = self._serialize_model(obj)
return self.response_schema(**serialized)
```
## Adding New Tools
### Step-by-Step Process
1. **Define the Domain**
Choose the appropriate domain folder:
- `dashboard/` - Dashboard operations
- `chart/` - Chart operations
- `dataset/` - Dataset operations
- `system/` - System-level operations
2. **Create Schemas**
```bash
# Create schema file
touch superset/mcp_service/schemas/my_domain_schemas.py
```
```python
# Define request/response schemas
class MyToolRequest(BaseModel):
param1: str = Field(description="Parameter description")
param2: Optional[int] = Field(default=None, description="Optional parameter")
class MyToolResponse(BaseModel):
result: str = Field(description="Result description")
metadata: Dict[str, Any] = Field(description="Additional metadata")
```
3. **Implement the Tool**
```bash
# Create tool file
touch superset/mcp_service/my_domain/tool/my_tool.py
```
```python
@mcp.tool
@mcp_auth_hook(['required:scope'])
def my_tool(request: MyToolRequest) -> MyToolResponse:
"""Tool description for LLM."""
# Import Superset modules inside function
from superset.daos.my_dao import MyDAO
# Implement business logic
result = MyDAO.do_something(request.param1)
return MyToolResponse(
result=result,
metadata={"processed_at": datetime.utcnow()}
)
```
4. **Register the Tool**
```python
# Add to superset/mcp_service/my_domain/tool/__init__.py
from .my_tool import my_tool
__all__ = ['my_tool']
```
```python
# Import in superset/mcp_service/mcp_app.py
from superset.mcp_service.my_domain.tool import *
```
5. **Add Tests**
```bash
# Create test file
touch tests/unit_tests/mcp_service/test_my_tool.py
```
```python
import pytest
from superset.mcp_service.my_domain.tool.my_tool import my_tool
from superset.mcp_service.schemas.my_domain_schemas import MyToolRequest
class TestMyTool:
def test_my_tool_success(self):
request = MyToolRequest(param1="test")
response = my_tool(request)
assert response.result == "expected_result"
```
### Tool Best Practices
1. **Import Inside Functions**
```python
# ❌ DON'T: Import at module level
from superset.daos.chart import ChartDAO
@mcp.tool
def my_tool():
# Tool implementation
pass
# ✅ DO: Import inside function
@mcp.tool
def my_tool():
from superset.daos.chart import ChartDAO
# Tool implementation
pass
```
2. **Use Generic Abstractions**
```python
# ✅ Leverage existing patterns
@mcp.tool
def list_my_objects(request):
from superset.mcp_service.generic_tools import ModelListTool
tool = ModelListTool(
dao=MyDAO,
model=MyModel,
response_schema=ListMyObjectsResponse
)
return tool.execute(request)
```
3. **Comprehensive Error Handling**
```python
@mcp.tool
def my_tool(request):
try:
# Tool implementation
return success_response
except PermissionError as e:
return MyToolError(
error="Permission denied",
error_type="PermissionError",
suggestions=["Check user permissions"]
)
except Exception as e:
return MyToolError(
error=f"Unexpected error: {str(e)}",
error_type="InternalError"
)
```
## Testing Patterns
### Unit Test Structure
```python
# tests/unit_tests/mcp_service/test_chart_tools.py
import pytest
from unittest.mock import Mock, patch
from superset.mcp_service.chart.tool.get_chart_info import get_chart_info
from superset.mcp_service.schemas.chart_schemas import GetChartInfoRequest
class TestGetChartInfo:
"""Test suite for get_chart_info tool."""
@patch('superset.mcp_service.chart.tool.get_chart_info.ChartDAO')
def test_get_chart_info_by_id_success(self, mock_dao):
"""Test successful chart lookup by ID."""
# Setup mock
mock_chart = Mock()
mock_chart.id = 1
mock_chart.slice_name = "Test Chart"
mock_chart.viz_type = "line"
mock_dao.find_by_id.return_value = mock_chart
# Execute
request = GetChartInfoRequest(identifier=1)
response = get_chart_info(request)
# Verify
assert response.chart_id == 1
assert response.slice_name == "Test Chart"
mock_dao.find_by_id.assert_called_once_with(1)
@patch('superset.mcp_service.chart.tool.get_chart_info.ChartDAO')
def test_get_chart_info_not_found(self, mock_dao):
"""Test chart not found scenario."""
# Setup mock
mock_dao.find_by_id.return_value = None
# Execute
request = GetChartInfoRequest(identifier=999)
response = get_chart_info(request)
# Verify error response
assert hasattr(response, 'error')
assert "not found" in response.error.lower()
```
### Integration Test Patterns
```python
# tests/integration_tests/mcp_service/test_chart_integration.py
import pytest
from superset.app import create_app
from superset.mcp_service.mcp_app import mcp
from tests.integration_tests.base_tests import SupersetTestCase
class TestChartIntegration(SupersetTestCase):
"""Integration tests for chart tools."""
def setUp(self):
super().setUp()
self.app = create_app()
self.app_context = self.app.app_context()
self.app_context.push()
def tearDown(self):
self.app_context.pop()
super().tearDown()
def test_chart_workflow_integration(self):
"""Test complete chart workflow."""
# Create chart
create_request = {
"dataset_id": "1",
"config": {
"chart_type": "table",
"columns": [{"name": "region"}]
}
}
create_response = mcp.call_tool("generate_chart", create_request)
chart_id = create_response["chart_id"]
# Get chart info
info_request = {"identifier": chart_id}
info_response = mcp.call_tool("get_chart_info", info_request)
assert info_response["chart_id"] == chart_id
assert info_response["viz_type"] == "table"
# Get chart data
data_request = {"identifier": chart_id, "limit": 10}
data_response = mcp.call_tool("get_chart_data", data_request)
assert "data" in data_response
assert len(data_response["data"]) <= 10
```
## Performance Considerations
### Caching Strategy
The MCP service leverages Superset's existing cache layers:
```python
# Cache control in tools
@mcp.tool
def get_chart_data(request: GetChartDataRequest):
"""Tool with cache control."""
cache_config = {
'use_cache': request.use_cache,
'force_refresh': request.force_refresh,
'cache_timeout': request.cache_timeout
}
# Use Superset's cache infrastructure
result = execute_with_cache(query, cache_config)
return ChartDataResponse(
data=result.data,
cache_status=result.cache_status
)
```
### Query Optimization
```python
# Efficient pagination
def list_objects(query, page, page_size):
"""Optimized pagination pattern."""
# Count query optimization
total = query.count()
# Limit columns for list operations
query = query.options(load_only('id', 'name', 'created_on'))
# Apply pagination
offset = (page - 1) * page_size
results = query.offset(offset).limit(page_size).all()
return results, total
```
## Security Considerations
### Authentication Flow
```python
# JWT validation and user context
@mcp_auth_hook(['chart:read'])
def secure_tool(request):
"""Tool with proper security context."""
# g.user is set by auth hook
user_id = g.user.id
# Apply user-specific filtering
query = ChartDAO.find_all().filter(
Chart.owners.contains(g.user)
)
return execute_query(query)
```
### Input Validation
```python
# Comprehensive request validation
class CreateChartRequest(BaseModel):
"""Validated chart creation request."""
dataset_id: Union[int, str] = Field(
...,
description="Dataset ID or UUID"
)
config: ChartConfig = Field(
...,
description="Chart configuration"
)
@validator('dataset_id')
def validate_dataset_id(cls, v):
"""Validate dataset exists and user has access."""
# Validation logic
return v
@validator('config')
def validate_chart_config(cls, v):
"""Validate chart configuration."""
# Configuration validation
return v
```
This development guide provides comprehensive coverage of the MCP service's internal architecture and development patterns, enabling team members to effectively extend and maintain the system.
## Related Documentation
### 📚 **Ready to Use Your New Tools?**
Test your implementations with examples from the [API Reference](./api-reference).
### 🔐 **Securing Your Extensions?**
Add authentication to your tools using the [Authentication Guide](./authentication).
### 🏗️ **Understanding the Big Picture?**
See the complete system design in the [Architecture Overview](./architecture).
### 🏢 **Building Enterprise Features?**
Explore advanced patterns in the [Preset Integration Guide](./preset-integration).
> 📖 **Back to Documentation Index**: [MCP Service](./intro)

View File

@@ -0,0 +1,124 @@
---
title: MCP Service
sidebar_position: 1
version: 1
---
# Superset MCP Service
The Superset Model Context Protocol (MCP) service provides programmatic access to Superset dashboards, charts, datasets, and instance metadata. Built for LLM agents and automation tools.
## What is MCP?
The Model Context Protocol (MCP) is an open standard that allows AI assistants to securely connect to data sources and tools. Superset's MCP service exposes **16 production-ready tools** that enable:
- 📊 **Data Exploration**: List and query dashboards, charts, and datasets
- 🔧 **Chart Creation**: Generate visualizations programmatically
- 📈 **Data Export**: Extract data in multiple formats (JSON, CSV, Excel)
- 🔗 **Navigation**: Generate explore links and SQL Lab sessions
## Quick Start
### Installation
:::note
The MCP service is included with Superset development setup. FastMCP dependencies are installed automatically with `make install`.
:::
```bash
# MCP service is included with Superset development setup
git clone https://github.com/apache/superset.git
cd superset
make venv && source venv/bin/activate
make install
# Start Superset
superset run -p 8088 --with-threads --reload --debugger
# Start MCP service (separate terminal)
source venv/bin/activate
superset mcp run --port 5008 --debug
```
### Claude Desktop Integration
```json
{
"mcpServers": {
"Superset MCP": {
"command": "/path/to/superset/superset/mcp_service/run_proxy.sh",
"args": [],
"env": {}
}
}
}
```
## Key Features
### 🔧 **16 Production Tools**
| Category | Tools | Purpose |
|----------|-------|---------|
| **Dashboard** (5) | List, get info, create, add charts | Dashboard management |
| **Chart** (8) | Full CRUD, data export, previews | Chart operations |
| **Dataset** (3) | List, get info, discover filters | Dataset exploration |
| **System** (2) | Instance info, explore links | System integration |
| **SQL Lab** (1) | Pre-configured sessions | SQL development |
### 🔐 **Enterprise Security**
- **JWT Bearer Authentication**: Production-ready with configurable factory pattern
- **RBAC Integration**: Scope-based permissions with Superset's security model
- **Audit Logging**: Comprehensive MCP context tracking
### 📊 **Advanced Capabilities**
- **Multi-format Export**: JSON, CSV, Excel data export
- **Chart Previews**: Screenshots, ASCII art, table representations
- **Cache Control**: Leverage Superset's existing cache infrastructure
- **Request Schemas**: Eliminates LLM parameter validation issues
## Example Usage
```python
# List dashboards
dashboards = client.call_tool("list_dashboards", {
"search": "sales",
"page_size": 10
})
# Create a chart
chart = client.call_tool("generate_chart", {
"dataset_id": "1",
"config": {
"chart_type": "line",
"x": {"name": "date"},
"y": [{"name": "revenue", "aggregate": "SUM"}]
}
})
# Export chart data
data = client.call_tool("get_chart_data", {
"identifier": chart["chart_id"],
"format": "json",
"limit": 1000
})
```
## Status
✅ **Phase 1 Complete** - Core functionality stable, authentication production-ready, comprehensive testing coverage.
## Documentation Structure
### Getting Started
- **[Overview](./overview)** - Features, use cases, and examples
- **[API Reference](./api-reference)** - Complete tool documentation
### Development
- **[Development Guide](./development)** - Internal architecture and adding tools
- **[Architecture](./architecture)** - System design and patterns
### Production
- **[Authentication](./authentication)** - JWT setup and security
- **[Preset Integration](./preset-integration)** - Enterprise features
> 🚀 **Ready to start?** Continue with the [Overview](./overview) for detailed examples and use cases.

View File

@@ -0,0 +1,196 @@
---
title: MCP Service Overview
sidebar_position: 1
version: 1
---
# Superset MCP Service
The Superset Model Context Protocol (MCP) service provides a modular, schema-driven interface for programmatic access to Superset dashboards, charts, datasets, and instance metadata. Built on FastMCP, it's designed for LLM agents and automation tools.
**Status:** ✅ Phase 1 Complete. Core functionality stable, authentication production-ready, comprehensive testing coverage.
## What is MCP?
The Model Context Protocol (MCP) is an open standard for connecting AI assistants to data sources and tools. Superset's MCP service exposes 16 tools that allow LLM agents to:
- **Explore data**: List and query dashboards, charts, and datasets
- **Create visualizations**: Generate charts and dashboards programmatically
- **Export data**: Extract chart data in multiple formats
- **Navigate interfaces**: Generate explore links and SQL Lab sessions
## Key Features
### 🔧 **16 Production-Ready Tools**
- **Dashboard Tools (5)**: List, get info, create dashboards, add charts
- **Chart Tools (8)**: Full CRUD operations, data export, screenshot previews
- **Dataset Tools (3)**: List, get info, discover filterable columns
- **System Tools (2)**: Instance info, explore link generation
- **SQL Lab Tools (1)**: Pre-configured SQL sessions
### 🔐 **Enterprise Authentication**
- **JWT Bearer Authentication**: Production-ready with configurable factory pattern
- **RBAC Integration**: Scope-based permissions with Superset's security model
- **Audit Logging**: Comprehensive MCP context tracking with impersonation support
### 📊 **Advanced Capabilities**
- **Multi-format Export**: JSON, CSV, Excel data export
- **Chart Previews**: Screenshots, ASCII art, and table representations
- **Cache Control**: Comprehensive control over Superset's cache layers
- **Request Schema Pattern**: Eliminates LLM parameter validation issues
## Architecture Overview
```mermaid
graph TB
subgraph "Client Layer"
LLM[LLM/Agent Client]
Claude[Claude Desktop]
SDK[Custom SDK]
end
subgraph "MCP Service Layer"
FastMCP[FastMCP Server<br/>Port 5008]
Auth[JWT Auth Hook]
Tools[16 MCP Tools]
end
subgraph "Superset Integration"
DAOs[Superset DAOs]
Commands[Superset Commands]
Cache[Cache Layer]
end
subgraph "Data Layer"
DB[(Superset Database)]
DataWarehouse[(Data Warehouse)]
end
LLM --> FastMCP
Claude --> FastMCP
SDK --> FastMCP
FastMCP --> Auth
Auth --> Tools
Tools --> DAOs
Tools --> Commands
Tools --> Cache
DAOs --> DB
Commands --> DB
Commands --> DataWarehouse
```
## Getting Started
### Quick Setup
```bash
# Clone and install Superset
git clone https://github.com/apache/superset.git
cd superset
make venv && source venv/bin/activate
make install
# Start Superset
superset run -p 8088 --with-threads --reload --debugger
# Start MCP service (in separate terminal)
source venv/bin/activate
superset mcp run --port 5008 --debug
```
### Connect to Claude Desktop
:::note
The MCP service runs on HTTP and requires a proxy for Claude Desktop integration.
:::
```bash
# Install FastMCP proxy
pip install fastmcp
```
Configure Claude Desktop (`~/.config/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"Superset MCP": {
"command": "/path/to/superset/superset/mcp_service/run_proxy.sh",
"args": [],
"env": {}
}
}
}
```
## Use Cases
### Data Exploration
- "List all dashboards related to sales"
- "Show me the charts in the Q4 Performance dashboard"
- "What datasets are available for customer analysis?"
### Chart Creation
- "Create a line chart showing revenue trends by month"
- "Generate a table showing top 10 products by sales"
- "Build a bar chart comparing regional performance"
### Data Export
- "Export the sales data from this chart as CSV"
- "Get the underlying data for this dashboard as JSON"
- "Show me a preview of this chart as ASCII art"
### Dashboard Management
- "Create a new dashboard with these 4 charts"
- "Add this revenue chart to the executive dashboard"
- "Generate an explore link for this chart configuration"
## Example Workflow
```python
# List available dashboards
dashboards = client.call_tool("list_dashboards", {
"search": "sales",
"page_size": 10
})
# Get detailed dashboard info
dashboard = client.call_tool("get_dashboard_info", {
"identifier": dashboards["dashboards"][0]["id"]
})
# Create a new chart
chart = client.call_tool("generate_chart", {
"dataset_id": "1",
"config": {
"chart_type": "line",
"x": {"name": "date"},
"y": [{"name": "revenue", "aggregate": "SUM"}]
}
})
# Export chart data
data = client.call_tool("get_chart_data", {
"identifier": chart["chart_id"],
"format": "json",
"limit": 1000
})
```
## Next Steps
### Ready to Use MCP?
- **[📚 API Reference](./api-reference)** - Try all 16 tools with request/response examples
- **[🔐 Authentication](./authentication)** - Set up JWT security for production use
### Want to Extend MCP?
- **[🔧 Development Guide](./development)** - Learn internal architecture and add new tools
- **[🏗️ Architecture](./architecture)** - Understand system design and deployment patterns
### Enterprise Deployment?
- **[🏢 Preset Integration](./preset-integration)** - RBAC extensions and OIDC integration for enterprise
> 💡 **Getting started?** Return to the [MCP Service intro](./intro) for a complete overview.

View File

@@ -0,0 +1,483 @@
---
title: Preset.io Integration
sidebar_position: 6
version: 1
---
# Preset.io Integration Guide
This document outlines integration points for the Preset.io team to extend the Superset MCP service with enterprise features, RBAC customizations, and OIDC integration.
## RBAC Extension Points
### Custom Authorization Factory
The MCP service supports custom authorization logic through the factory pattern:
```python
# In preset_config.py or superset_config.py
def create_preset_mcp_auth(app):
"""Custom auth factory for Preset.io environments."""
from superset.mcp_service.auth import create_auth_provider
from preset.auth.mcp import PresetMCPAuthProvider
return PresetMCPAuthProvider(
jwks_uri=app.config["PRESET_JWKS_URI"],
issuer=app.config["PRESET_JWT_ISSUER"],
audience=app.config["PRESET_JWT_AUDIENCE"],
tenant_resolver=preset_tenant_resolver,
rbac_manager=app.security_manager,
)
MCP_AUTH_FACTORY = create_preset_mcp_auth
```
### Multi-Tenant RBAC
Extend the base auth hook for tenant-aware permissions:
```python
# preset/mcp/auth.py
from superset.mcp_service.auth import mcp_auth_hook
from functools import wraps
def preset_tenant_auth_hook(required_permissions=None):
"""Preset-specific auth hook with tenant isolation."""
def decorator(func):
@wraps(func)
@mcp_auth_hook(required_permissions)
def wrapper(*args, **kwargs):
# Extract tenant from JWT claims
tenant_id = g.user.tenant_id if hasattr(g.user, 'tenant_id') else None
# Inject tenant context
g.mcp_tenant_id = tenant_id
g.mcp_tenant_context = get_tenant_context(tenant_id)
return func(*args, **kwargs)
return wrapper
return decorator
```
### Custom Permission Scopes
Define Preset-specific permission scopes:
```python
# preset/mcp/permissions.py
PRESET_MCP_SCOPES = {
# Tenant-level permissions
"tenant:admin": "Full tenant administration",
"tenant:read": "Read tenant resources",
# Workspace-level permissions
"workspace:admin": "Full workspace administration",
"workspace:read": "Read workspace resources",
# Enhanced dashboard permissions
"dashboard:publish": "Publish dashboards to marketplace",
"dashboard:embed": "Generate embed tokens",
# Enhanced chart permissions
"chart:export": "Export chart data and configs",
"chart:alerts": "Manage chart alerts and notifications",
# Dataset permissions with row-level security
"dataset:rls": "Apply row-level security filters",
"dataset:pii": "Access PII-flagged columns",
}
def get_preset_required_scopes(tool_name: str, context: dict = None) -> List[str]:
"""Map tool calls to Preset-specific permission requirements."""
base_scopes = get_base_required_scopes(tool_name)
# Add tenant-aware scopes
if context and context.get('tenant_id'):
base_scopes.append(f"tenant:{context['tenant_id']}")
# Add workspace-aware scopes
if context and context.get('workspace_id'):
base_scopes.append(f"workspace:{context['workspace_id']}")
return base_scopes
```
### Row-Level Security Integration
Extend data access tools with RLS:
```python
# preset/mcp/rls.py
def apply_preset_rls_filters(query_context: dict, user_context: dict) -> dict:
"""Apply Preset row-level security filters to query context."""
# Get user's RLS rules from Preset metadata
rls_rules = get_user_rls_rules(
user_id=user_context['user_id'],
tenant_id=user_context['tenant_id'],
workspace_id=user_context.get('workspace_id')
)
# Apply RLS filters to query
for rule in rls_rules:
if rule.applies_to_dataset(query_context['datasource']['id']):
query_context = rule.apply_filter(query_context)
return query_context
# Usage in custom tools
@mcp.tool
@preset_tenant_auth_hook(['dataset:read', 'dataset:rls'])
def preset_get_chart_data(request: GetChartDataRequest) -> ChartDataResponse:
"""Get chart data with Preset RLS applied."""
# Apply RLS before executing query
query_context = build_query_context(request)
query_context = apply_preset_rls_filters(
query_context,
{'user_id': g.user.id, 'tenant_id': g.mcp_tenant_id}
)
return execute_chart_data_query(query_context)
```
## OIDC Integration Points
### Preset OIDC Provider
Custom OIDC integration for Preset environments:
```python
# preset/mcp/oidc.py
from superset.mcp_service.auth.providers.bearer import BearerAuthProvider
import requests
from typing import Dict, Any
class PresetOIDCAuthProvider(BearerAuthProvider):
"""OIDC-specific auth provider for Preset.io."""
def __init__(self,
oidc_discovery_url: str,
client_id: str,
client_secret: str = None,
**kwargs):
# Discover OIDC endpoints
self.discovery_doc = self._fetch_discovery_document(oidc_discovery_url)
super().__init__(
jwks_uri=self.discovery_doc['jwks_uri'],
issuer=self.discovery_doc['issuer'],
**kwargs
)
self.client_id = client_id
self.client_secret = client_secret
def _fetch_discovery_document(self, discovery_url: str) -> Dict[str, Any]:
"""Fetch OIDC discovery document."""
response = requests.get(discovery_url)
response.raise_for_status()
return response.json()
def validate_token(self, token: str) -> Dict[str, Any]:
"""Validate JWT token with OIDC-specific claims."""
claims = super().validate_token(token)
# Validate OIDC-specific claims
if claims.get('aud') != self.client_id:
raise ValueError("Invalid audience claim")
# Extract Preset-specific claims
claims['preset_tenant_id'] = claims.get('tenant_id')
claims['preset_workspace_id'] = claims.get('workspace_id')
claims['preset_roles'] = claims.get('roles', [])
return claims
def resolve_user(self, claims: Dict[str, Any]) -> Any:
"""Resolve Superset user from OIDC claims."""
from preset.auth.user_resolver import resolve_preset_user
return resolve_preset_user(
subject=claims['sub'],
email=claims.get('email'),
tenant_id=claims.get('preset_tenant_id'),
roles=claims.get('preset_roles', [])
)
```
### Configuration for OIDC
```python
# In preset_config.py
def create_preset_oidc_auth(app):
"""Factory for Preset OIDC authentication."""
from preset.mcp.oidc import PresetOIDCAuthProvider
return PresetOIDCAuthProvider(
oidc_discovery_url=app.config["PRESET_OIDC_DISCOVERY_URL"],
client_id=app.config["PRESET_OIDC_CLIENT_ID"],
client_secret=app.config["PRESET_OIDC_CLIENT_SECRET"],
audience=app.config["PRESET_MCP_AUDIENCE"],
required_scopes=app.config.get("PRESET_MCP_REQUIRED_SCOPES", [])
)
# MCP Configuration
MCP_AUTH_ENABLED = True
MCP_AUTH_FACTORY = create_preset_oidc_auth
# OIDC Configuration
PRESET_OIDC_DISCOVERY_URL = "https://auth.preset.io/.well-known/openid_configuration"
PRESET_OIDC_CLIENT_ID = "preset-mcp-service"
PRESET_OIDC_CLIENT_SECRET = os.environ.get("PRESET_OIDC_CLIENT_SECRET")
PRESET_MCP_AUDIENCE = "preset-superset-mcp"
PRESET_MCP_REQUIRED_SCOPES = [
"openid", "profile", "email",
"superset:read", "superset:write"
]
```
## Preset-Specific Tools
### Tenant Management Tools
```python
# preset/mcp/tools/tenant.py
@mcp.tool
@preset_tenant_auth_hook(['tenant:read'])
def get_tenant_info(request: GetTenantInfoRequest) -> TenantInfoResponse:
"""Get Preset tenant information and quotas."""
tenant_id = g.mcp_tenant_id
tenant = get_tenant_by_id(tenant_id)
return TenantInfoResponse(
tenant_id=tenant.id,
name=tenant.name,
plan=tenant.plan,
quotas=tenant.quotas,
usage=get_tenant_usage(tenant_id),
workspaces=list_tenant_workspaces(tenant_id)
)
@mcp.tool
@preset_tenant_auth_hook(['workspace:read'])
def list_workspace_assets(request: ListWorkspaceAssetsRequest) -> ListWorkspaceAssetsResponse:
"""List all assets in a Preset workspace."""
workspace_id = request.workspace_id
tenant_id = g.mcp_tenant_id
# Validate workspace belongs to tenant
validate_workspace_access(workspace_id, tenant_id)
assets = {
'dashboards': list_workspace_dashboards(workspace_id),
'charts': list_workspace_charts(workspace_id),
'datasets': list_workspace_datasets(workspace_id)
}
return ListWorkspaceAssetsResponse(
workspace_id=workspace_id,
assets=assets,
total_count=sum(len(v) for v in assets.values())
)
```
### Embed Token Generation
```python
# preset/mcp/tools/embed.py
@mcp.tool
@preset_tenant_auth_hook(['dashboard:embed'])
def generate_embed_token(request: GenerateEmbedTokenRequest) -> EmbedTokenResponse:
"""Generate secure embed token for dashboard/chart."""
# Validate resource access
resource = validate_embed_resource_access(
resource_type=request.resource_type,
resource_id=request.resource_id,
tenant_id=g.mcp_tenant_id
)
# Generate signed embed token
embed_token = create_embed_token(
resource=resource,
user_id=g.user.id,
tenant_id=g.mcp_tenant_id,
permissions=request.permissions,
expiry=request.expiry_hours
)
return EmbedTokenResponse(
embed_token=embed_token,
embed_url=f"{get_preset_base_url()}/embed/{embed_token}",
expires_at=embed_token.expires_at
)
```
## Audit and Compliance Extensions
### Enhanced Audit Logging
```python
# preset/mcp/audit.py
from superset.mcp_service.auth import get_audit_context
def create_preset_audit_context(user_context: dict, tool_name: str,
request_data: dict) -> dict:
"""Create Preset-specific audit context."""
base_context = get_audit_context(user_context, tool_name, request_data)
# Add Preset-specific fields
preset_context = {
**base_context,
'tenant_id': user_context.get('tenant_id'),
'workspace_id': user_context.get('workspace_id'),
'preset_user_role': user_context.get('preset_role'),
'data_classification': classify_request_data(request_data),
'compliance_flags': get_compliance_flags(tool_name, request_data)
}
return preset_context
def log_preset_mcp_access(audit_context: dict):
"""Log MCP access to Preset audit systems."""
# Log to Superset's audit system
log_superset_audit_event(audit_context)
# Log to Preset's compliance system
log_preset_compliance_event(audit_context)
# Log to external SIEM if configured
if app.config.get('PRESET_SIEM_ENABLED'):
log_to_siem(audit_context)
```
### Data Classification
```python
# preset/mcp/classification.py
def classify_request_data(request_data: dict) -> dict:
"""Classify data sensitivity in MCP requests."""
classification = {
'contains_pii': False,
'data_level': 'public',
'retention_policy': 'standard'
}
# Check for PII in request
if contains_pii_fields(request_data):
classification['contains_pii'] = True
classification['data_level'] = 'restricted'
classification['retention_policy'] = 'pii_compliant'
# Check for sensitive datasets
if references_sensitive_datasets(request_data):
classification['data_level'] = 'confidential'
return classification
```
## Deployment Considerations
### Multi-Region Deployment
```python
# preset/mcp/deployment.py
def get_region_specific_config():
"""Get region-specific MCP configuration."""
region = os.environ.get('PRESET_REGION', 'us-east-1')
config_map = {
'us-east-1': {
'jwks_uri': 'https://auth-us.preset.io/.well-known/jwks.json',
'base_url': 'https://app.preset.io',
'data_residency': 'US'
},
'eu-west-1': {
'jwks_uri': 'https://auth-eu.preset.io/.well-known/jwks.json',
'base_url': 'https://eu.preset.io',
'data_residency': 'EU'
}
}
return config_map.get(region, config_map['us-east-1'])
# Usage in config
region_config = get_region_specific_config()
PRESET_JWKS_URI = region_config['jwks_uri']
SUPERSET_WEBSERVER_ADDRESS = region_config['base_url']
```
### Health Check Extensions
```python
# preset/mcp/health.py
@mcp.tool
def preset_health_check() -> HealthCheckResponse:
"""Preset-specific health check for MCP service."""
checks = {
'mcp_service': check_mcp_service_health(),
'database': check_database_health(),
'auth_provider': check_auth_provider_health(),
'tenant_isolation': check_tenant_isolation(),
'rls_engine': check_rls_engine_health()
}
overall_status = 'healthy' if all(
check['status'] == 'healthy' for check in checks.values()
) else 'degraded'
return HealthCheckResponse(
status=overall_status,
checks=checks,
region=os.environ.get('PRESET_REGION'),
version=get_preset_mcp_version()
)
```
## Configuration Templates
### Production Configuration
```python
# preset_production_config.py
from preset.mcp.auth import create_preset_oidc_auth
from preset.mcp.audit import create_preset_audit_context
# MCP Service Configuration
MCP_AUTH_ENABLED = True
MCP_AUTH_FACTORY = create_preset_oidc_auth
MCP_AUDIT_CONTEXT_FACTORY = create_preset_audit_context
# Preset OIDC Configuration
PRESET_OIDC_DISCOVERY_URL = "https://auth.preset.io/.well-known/openid_configuration"
PRESET_OIDC_CLIENT_ID = "preset-mcp-production"
PRESET_MCP_AUDIENCE = "preset-superset-mcp"
# Security Configuration
PRESET_MCP_REQUIRED_SCOPES = [
"openid", "profile", "email",
"tenant:read", "workspace:read",
"dashboard:read", "chart:read", "dataset:read"
]
# Audit Configuration
PRESET_AUDIT_ENABLED = True
PRESET_SIEM_ENABLED = True
PRESET_COMPLIANCE_MODE = "SOC2"
# Performance Configuration
PRESET_MCP_CACHE_ENABLED = True
PRESET_MCP_RATE_LIMIT = "1000/hour"
PRESET_MCP_TIMEOUT = 30
```
This integration guide provides the Preset.io team with concrete extension points for implementing enterprise features while maintaining compatibility with the base MCP service architecture.

View File

@@ -87,6 +87,16 @@ const sidebars = {
},
],
},
{
type: 'category',
label: 'MCP Service',
items: [
{
type: 'autogenerated',
dirName: 'mcp-service',
},
],
},
{
type: 'doc',
label: 'FAQ',

View File

@@ -133,6 +133,7 @@ solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
excel = ["xlrd>=1.2.0, <1.3"]
fastmcp = ["fastmcp>=2.8.1"]
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]
@@ -202,6 +203,7 @@ development = [
"pyinstrument>=4.0.2,<5",
"pylint",
"pytest<8.0.0", # hairy issue with pytest >=8 where current_app proxies are not set in time
"pytest-asyncio", # need this due to not using latest pytest
"pytest-cov",
"pytest-mock",
"python-ldap>=3.4.4",

View File

@@ -16,4 +16,4 @@
# specific language governing permissions and limitations
# under the License.
#
-e .[development,bigquery,druid,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
-e .[development,bigquery,druid,fastmcp,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]

View File

@@ -10,6 +10,14 @@ amqp==5.3.1
# via
# -c requirements/base.txt
# kombu
annotated-types==0.7.0
# via pydantic
anyio==4.9.0
# via
# httpx
# mcp
# sse-starlette
# starlette
apispec==6.6.1
# via
# -c requirements/base.txt
@@ -24,11 +32,14 @@ attrs==25.3.0
# via
# -c requirements/base.txt
# cattrs
# cyclopts
# jsonschema
# outcome
# referencing
# requests-cache
# trio
authlib==1.6.1
# via fastmcp
babel==2.17.0
# via
# -c requirements/base.txt
@@ -77,6 +88,8 @@ celery==5.5.2
certifi==2025.6.15
# via
# -c requirements/base.txt
# httpcore
# httpx
# requests
# selenium
cffi==1.17.1
@@ -101,6 +114,7 @@ click==8.2.1
# click-repl
# flask
# flask-appbuilder
# uvicorn
click-didyoumean==0.3.1
# via
# -c requirements/base.txt
@@ -140,10 +154,13 @@ cryptography==44.0.3
# via
# -c requirements/base.txt
# apache-superset
# authlib
# paramiko
# pyopenssl
cycler==0.12.1
# via matplotlib
cyclopts==3.22.2
# via fastmcp
db-dtypes==1.3.1
# via pandas-gbq
defusedxml==0.7.1
@@ -168,14 +185,23 @@ dnspython==2.7.0
# email-validator
docker==7.0.0
# via apache-superset
docstring-parser==0.17.0
# via cyclopts
docutils==0.21.2
# via rich-rst
email-validator==2.2.0
# via
# -c requirements/base.txt
# flask-appbuilder
# pydantic
et-xmlfile==2.0.0
# via
# -c requirements/base.txt
# openpyxl
exceptiongroup==1.3.0
# via fastmcp
fastmcp==2.10.6
# via apache-superset
filelock==3.12.2
# via virtualenv
flask==2.3.3
@@ -327,6 +353,8 @@ gunicorn==23.0.0
h11==0.16.0
# via
# -c requirements/base.txt
# httpcore
# uvicorn
# wsproto
hashids==1.3.1
# via
@@ -337,6 +365,14 @@ holidays==0.25
# -c requirements/base.txt
# apache-superset
# prophet
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via
# fastmcp
# mcp
httpx-sse==0.4.1
# via mcp
humanize==4.12.3
# via
# -c requirements/base.txt
@@ -346,7 +382,9 @@ identify==2.5.36
idna==3.10
# via
# -c requirements/base.txt
# anyio
# email-validator
# httpx
# requests
# trio
# url-normalize
@@ -378,6 +416,7 @@ jsonschema==4.23.0
# via
# -c requirements/base.txt
# flask-appbuilder
# mcp
# openapi-schema-validator
# openapi-spec-validator
jsonschema-path==0.3.4
@@ -437,6 +476,8 @@ matplotlib==3.9.0
# via prophet
mccabe==0.7.0
# via pylint
mcp==1.12.0
# via fastmcp
mdurl==0.1.2
# via
# -c requirements/base.txt
@@ -475,6 +516,8 @@ odfpy==1.4.1
# via
# -c requirements/base.txt
# pandas
openapi-pydantic==0.5.1
# via fastmcp
openapi-schema-validator==0.6.3
# via
# -c requirements/base.txt
@@ -607,6 +650,16 @@ pycparser==2.22
# via
# -c requirements/base.txt
# cffi
pydantic==2.11.7
# via
# fastmcp
# mcp
# openapi-pydantic
# pydantic-settings
pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.10.1
# via mcp
pydata-google-auth==1.9.0
# via pandas-gbq
pydruid==0.6.9
@@ -642,6 +695,8 @@ pyparsing==3.2.3
# -c requirements/base.txt
# apache-superset
# matplotlib
pyperclip==1.9.0
# via fastmcp
pysocks==1.7.1
# via
# -c requirements/base.txt
@@ -649,8 +704,11 @@ pysocks==1.7.1
pytest==7.4.4
# via
# apache-superset
# pytest-asyncio
# pytest-cov
# pytest-mock
pytest-asyncio==0.23.8
# via apache-superset
pytest-cov==6.0.0
# via apache-superset
pytest-mock==3.10.0
@@ -674,12 +732,16 @@ python-dotenv==1.1.0
# via
# -c requirements/base.txt
# apache-superset
# fastmcp
# pydantic-settings
python-geohash==0.8.5
# via
# -c requirements/base.txt
# apache-superset
python-ldap==3.4.4
# via apache-superset
python-multipart==0.0.20
# via mcp
pytz==2025.2
# via
# -c requirements/base.txt
@@ -734,7 +796,12 @@ rfc3339-validator==0.1.4
rich==13.9.4
# via
# -c requirements/base.txt
# cyclopts
# fastmcp
# flask-limiter
# rich-rst
rich-rst==1.3.1
# via cyclopts
rpds-py==0.25.0
# via
# -c requirements/base.txt
@@ -779,6 +846,7 @@ slack-sdk==3.35.0
sniffio==1.3.1
# via
# -c requirements/base.txt
# anyio
# trio
sortedcontainers==2.4.0
# via
@@ -808,10 +876,14 @@ sqlglot==27.3.0
# apache-superset
sqloxide==0.1.51
# via apache-superset
sse-starlette==2.4.1
# via mcp
sshtunnel==0.4.0
# via
# -c requirements/base.txt
# apache-superset
starlette==0.47.2
# via mcp
statsd==4.0.1
# via apache-superset
tabulate==0.9.0
@@ -839,13 +911,23 @@ typing-extensions==4.14.0
# via
# -c requirements/base.txt
# alembic
# anyio
# apache-superset
# cattrs
# exceptiongroup
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# starlette
# typing-inspection
typing-inspection==0.4.1
# via
# pydantic
# pydantic-settings
tzdata==2025.2
# via
# -c requirements/base.txt
@@ -864,6 +946,8 @@ urllib3==2.5.0
# requests
# requests-cache
# selenium
uvicorn==0.35.0
# via mcp
vine==5.1.0
# via
# -c requirements/base.txt

View File

@@ -10877,6 +10877,12 @@
"url": "https://opencollective.com/immer"
}
},
"node_modules/@reduxjs/toolkit/node_modules/reselect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
"license": "MIT"
},
"node_modules/@rjsf/core": {
"version": "5.24.1",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.1.tgz",
@@ -24605,6 +24611,12 @@
"d3-time": "1 - 2"
}
},
"node_modules/encodable/node_modules/reselect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -50629,9 +50641,9 @@
"license": "MIT"
},
"node_modules/reselect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
@@ -59085,7 +59097,7 @@
"csstype": "^3.1.3",
"d3-format": "^1.3.2",
"d3-interpolate": "^3.0.1",
"d3-scale": "^3.0.0",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.13",
@@ -59108,7 +59120,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^4.0.0",
"reselect": "^5.1.1",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.14"
@@ -59187,16 +59199,19 @@
"license": "BSD-3-Clause"
},
"packages/superset-ui-core/node_modules/d3-scale": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz",
"integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==",
"license": "BSD-3-Clause",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "^2.3.0",
"d3-format": "1 - 2",
"d3-interpolate": "1.2.0 - 2",
"d3-time": "^2.1.1",
"d3-time-format": "2 - 3"
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/d3-scale/node_modules/d3-interpolate": {

View File

@@ -41,6 +41,53 @@ import {
import { checkColumnType } from '../utils/checkColumnType';
import { isSortable } from '../utils/isSortable';
// Aggregation choices with computation methods for plugins and controls
export const aggregationChoices = {
raw: {
label: 'Overall value',
compute: (data: number[]) => {
if (!data.length) return null;
return data[0];
},
},
LAST_VALUE: {
label: 'Last Value',
compute: (data: number[]) => {
if (!data.length) return null;
return data[0];
},
},
sum: {
label: 'Total (Sum)',
compute: (data: number[]) =>
data.length ? data.reduce((a, b) => a + b, 0) : null,
},
mean: {
label: 'Average (Mean)',
compute: (data: number[]) =>
data.length ? data.reduce((a, b) => a + b, 0) / data.length : null,
},
min: {
label: 'Minimum',
compute: (data: number[]) => (data.length ? Math.min(...data) : null),
},
max: {
label: 'Maximum',
compute: (data: number[]) => (data.length ? Math.max(...data) : null),
},
median: {
label: 'Median',
compute: (data: number[]) => {
if (!data.length) return null;
const sorted = [...data].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
},
},
} as const;
export const contributionModeControl = {
name: 'contributionMode',
config: {
@@ -69,17 +116,12 @@ export const aggregationControl = {
default: 'LAST_VALUE',
clearable: false,
renderTrigger: false,
choices: [
['raw', t('None')],
['LAST_VALUE', t('Last Value')],
['sum', t('Total (Sum)')],
['mean', t('Average (Mean)')],
['min', t('Minimum')],
['max', t('Maximum')],
['median', t('Median')],
],
choices: Object.entries(aggregationChoices).map(([value, { label }]) => [
value,
t(label),
]),
description: t(
'Aggregation method used to compute the Big Number from the Trendline.For non-additive metrics like ratios, averages, distinct counts, etc use NONE.',
'Method to compute the displayed value. "Overall value" calculates a single metric across the entire filtered time period, ideal for non-additive metrics like ratios, averages, or distinct counts. Other methods operate over the time series data points.',
),
provideFormDataToProps: true,
mapStateToProps: ({ form_data }: ControlPanelState) => ({

View File

@@ -37,7 +37,7 @@
"d3-format": "^1.3.2",
"dayjs": "^1.11.13",
"d3-interpolate": "^3.0.1",
"d3-scale": "^3.0.0",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dompurify": "^3.2.4",
@@ -59,7 +59,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^4.0.0",
"reselect": "^5.1.1",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"@visx/responsive": "^3.12.0",

View File

@@ -17,11 +17,8 @@
* under the License.
*/
/** Type checking is disabled for this file due to reselect only supporting
* TS declarations for selectors with up to 12 arguments. */
// @ts-nocheck
import { RefObject } from 'react';
import { createSelector } from 'reselect';
import { createSelector, lruMemoize } from 'reselect';
import {
AppSection,
Behavior,
@@ -37,7 +34,7 @@ import {
SetDataMaskHook,
} from '../types/Base';
import { QueryData, DataRecordFilters } from '..';
import { SupersetTheme } from '../../theme';
import { supersetTheme, SupersetTheme } from '../../theme';
// TODO: more specific typing for these fields of ChartProps
type AnnotationData = PlainObject;
@@ -109,6 +106,8 @@ export interface ChartPropsConfig {
theme: SupersetTheme;
/* legend index */
legendIndex?: number;
inContextMenu?: boolean;
emitCrossFilters?: boolean;
}
const DEFAULT_WIDTH = 800;
@@ -161,7 +160,11 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
theme: SupersetTheme;
constructor(config: ChartPropsConfig & { formData?: FormData } = {}) {
constructor(
config: ChartPropsConfig & { formData?: FormData } = {
theme: supersetTheme,
},
) {
const {
annotationData = {},
datasource = {},
@@ -276,5 +279,16 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
emitCrossFilters,
theme,
}),
// Below config is to retain usage of 1-sized `lruMemoize` object in Reselect v4
// Reselect v5 introduces `weakMapMemoize` which is more performant but potentially memory-leaky
// due to infinite cache size.
// Source: https://github.com/reduxjs/reselect/releases/tag/v5.0.1
{
memoize: lruMemoize,
argsMemoize: lruMemoize,
memoizeOptions: {
maxSize: 10,
},
},
);
};

View File

@@ -16,14 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, css, useTheme } from '@superset-ui/core';
import {
Icons,
Modal,
Typography,
Button,
Flex,
} from '@superset-ui/core/components';
import { t } from '@superset-ui/core';
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
import type { FC, ReactElement } from 'react';
export type UnsavedChangesModalProps = {
@@ -42,66 +36,30 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
onConfirmNavigation,
title = 'Unsaved Changes',
body = "If you don't save, changes will be lost.",
}): ReactElement => {
const theme = useTheme();
return (
<Modal
name={title}
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
title={
<Flex>
<Icons.WarningOutlined
iconColor={theme.colorWarning}
css={css`
margin-right: ${theme.sizeUnit * 2}px;
`}
iconSize="l"
/>
<Typography.Title
css={css`
&& {
margin: 0;
margin-bottom: 0;
}
`}
level={5}
>
{title}
</Typography.Title>
</Flex>
}
footer={
<Flex
justify="flex-end"
css={css`
width: 100%;
`}
>
<Button
htmlType="button"
buttonSize="small"
buttonStyle="secondary"
onClick={onConfirmNavigation}
>
{t('Discard')}
</Button>
<Button
htmlType="button"
buttonSize="small"
buttonStyle="primary"
onClick={handleSave}
>
{t('Save')}
</Button>
</Flex>
}
>
<Typography.Text>{body}</Typography.Text>
</Modal>
);
};
}: UnsavedChangesModalProps): ReactElement => (
<Modal
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
title={
<>
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
{title}
</>
}
footer={
<>
<Button buttonStyle="secondary" onClick={onConfirmNavigation}>
{t('Discard')}
</Button>
<Button buttonStyle="primary" onClick={handleSave}>
{t('Save')}
</Button>
</>
}
>
<Typography.Text>{body}</Typography.Text>
</Modal>
);

View File

@@ -119,7 +119,7 @@ describe('ChartProps', () => {
});
expect(props1).not.toBe(props2);
});
it('selector returns a new chartProps if some input fields change', () => {
it('selector returns a new chartProps if some input fields change and returns memoized chart props', () => {
const props1 = selector({
width: 800,
height: 600,
@@ -145,7 +145,7 @@ describe('ChartProps', () => {
theme: supersetTheme,
});
expect(props1).not.toBe(props2);
expect(props1).not.toBe(props3);
expect(props1).toBe(props3);
});
});
});

View File

@@ -1385,7 +1385,7 @@ export default function (config) {
p[0] = p[0] - __.margin.left;
p[1] = p[1] - __.margin.top;
(dims = dimensionsForPoint(p)),
((dims = dimensionsForPoint(p)),
(strum = {
p1: p,
dims: dims,
@@ -1393,7 +1393,7 @@ export default function (config) {
maxX: xscale(dims.right),
minY: 0,
maxY: h(),
});
}));
strums[dims.i] = strum;
strums.active = dims.i;
@@ -1942,7 +1942,7 @@ export default function (config) {
p[0] = p[0] - __.margin.left;
p[1] = p[1] - __.margin.top;
(dims = dimensionsForPoint(p)),
((dims = dimensionsForPoint(p)),
(arc = {
p1: p,
dims: dims,
@@ -1953,7 +1953,7 @@ export default function (config) {
startAngle: undefined,
endAngle: undefined,
arc: d3.svg.arc().innerRadius(0),
});
}));
arcs[dims.i] = arc;
arcs.active = dims.i;

View File

@@ -49,38 +49,53 @@ describe('BigNumberWithTrendline buildQuery', () => {
aggregation: null,
};
it('creates raw metric query when aggregation is null', () => {
const queryContext = buildQuery({ ...baseFormData });
it('creates raw metric query when aggregation is "raw"', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'raw' });
const bigNumberQuery = queryContext.queries[1];
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
expect(bigNumberQuery.is_timeseries).toBe(true);
expect(bigNumberQuery.post_processing).toEqual([]);
expect(bigNumberQuery.is_timeseries).toBe(false);
expect(bigNumberQuery.columns).toEqual([]);
});
it('adds aggregation operator when aggregation is "sum"', () => {
it('returns single query for aggregation methods that can be computed client-side', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'sum' });
const bigNumberQuery = queryContext.queries[1];
expect(bigNumberQuery.post_processing).toEqual([
expect(queryContext.queries.length).toBe(1);
expect(queryContext.queries[0].post_processing).toEqual([
{ operation: 'pivot' },
{ operation: 'aggregation', options: { operator: 'sum' } },
{ operation: 'rolling' },
{ operation: 'resample' },
{ operation: 'flatten' },
]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('skips aggregation when aggregation is LAST_VALUE', () => {
it('returns single query for LAST_VALUE aggregation', () => {
const queryContext = buildQuery({
...baseFormData,
aggregation: 'LAST_VALUE',
});
const bigNumberQuery = queryContext.queries[1];
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
expect(bigNumberQuery.is_timeseries).toBe(true);
expect(queryContext.queries.length).toBe(1);
expect(queryContext.queries[0].post_processing).toEqual([
{ operation: 'pivot' },
{ operation: 'rolling' },
{ operation: 'resample' },
{ operation: 'flatten' },
]);
});
it('always returns two queries', () => {
const queryContext = buildQuery({ ...baseFormData });
it('returns two queries only for raw aggregation', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'raw' });
expect(queryContext.queries.length).toBe(2);
const queryContextLastValue = buildQuery({
...baseFormData,
aggregation: 'LAST_VALUE',
});
expect(queryContextLastValue.queries.length).toBe(1);
const queryContextSum = buildQuery({ ...baseFormData, aggregation: 'sum' });
expect(queryContextSum.queries.length).toBe(1);
});
});

View File

@@ -39,28 +39,37 @@ export default function buildQuery(formData: QueryFormData) {
? ensureIsArray(getXAxisColumn(formData))
: [];
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
columns: [...timeColumn],
...(timeColumn.length ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
resampleOperator(formData, baseQueryObject),
flattenOperator(formData, baseQueryObject),
],
},
{
...baseQueryObject,
columns: [...(isRawMetric ? [] : timeColumn)],
is_timeseries: !isRawMetric,
post_processing: isRawMetric
? []
: [
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
],
},
]);
return buildQueryContext(formData, baseQueryObject => {
const queries = [
{
...baseQueryObject,
columns: [...timeColumn],
...(timeColumn.length ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
resampleOperator(formData, baseQueryObject),
flattenOperator(formData, baseQueryObject),
].filter(Boolean),
},
];
// Only add second query for raw metrics which need different query structure
// All other aggregations (sum, mean, min, max, median, LAST_VALUE) can be computed client-side from trendline data
if (formData.aggregation === 'raw') {
queries.push({
...baseQueryObject,
columns: [...(isRawMetric ? [] : timeColumn)],
is_timeseries: !isRawMetric,
post_processing: isRawMetric
? []
: ([
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
].filter(Boolean) as any[]),
});
}
return queries;
});
}

View File

@@ -20,6 +20,41 @@ import { GenericDataType } from '@superset-ui/core';
import transformProps from './transformProps';
import { BigNumberWithTrendlineChartProps, BigNumberDatum } from '../types';
// Mock chart-controls to avoid styled-components issues in Jest
jest.mock('@superset-ui/chart-controls', () => ({
aggregationChoices: {
raw: {
label: 'Force server-side aggregation',
compute: (data: number[]) => data[0] ?? null,
},
LAST_VALUE: {
label: 'Last Value',
compute: (data: number[]) => data[0] ?? null,
},
sum: {
label: 'Total (Sum)',
compute: (data: number[]) => data.reduce((a, b) => a + b, 0),
},
mean: {
label: 'Average (Mean)',
compute: (data: number[]) =>
data.reduce((a, b) => a + b, 0) / data.length,
},
min: { label: 'Minimum', compute: (data: number[]) => Math.min(...data) },
max: { label: 'Maximum', compute: (data: number[]) => Math.max(...data) },
median: {
label: 'Median',
compute: (data: number[]) => {
const sorted = [...data].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
},
},
},
}));
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Temporal: 2, String: 1 },
extractTimegrain: jest.fn(() => 'P1D'),
@@ -218,7 +253,7 @@ describe('BigNumberWithTrendline transformProps', () => {
coltypes: ['NUMERIC'],
},
],
formData: { ...baseFormData, aggregation: 'SUM' },
formData: { ...baseFormData, aggregation: 'sum' },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,

View File

@@ -29,6 +29,7 @@ import {
tooltipHtml,
} from '@superset-ui/core';
import { EChartsCoreOption, graphic } from 'echarts/core';
import { aggregationChoices } from '@superset-ui/chart-controls';
import {
BigNumberVizProps,
BigNumberDatum,
@@ -43,6 +44,31 @@ const formatPercentChange = getNumberFormatter(
NumberFormats.PERCENT_SIGNED_1_POINT,
);
// Client-side aggregation function using shared aggregationChoices
function computeClientSideAggregation(
data: [number | null, number | null][],
aggregation: string | undefined | null,
): number | null {
if (!data.length) return null;
// Find the aggregation method, handling case variations
const methodKey = Object.keys(aggregationChoices).find(
key => key.toLowerCase() === (aggregation || '').toLowerCase(),
);
// Use the compute method from aggregationChoices, fallback to LAST_VALUE
const selectedMethod = methodKey
? aggregationChoices[methodKey as keyof typeof aggregationChoices]
: aggregationChoices.LAST_VALUE;
// Extract values from tuple array and filter out nulls
const values = data
.map(([, value]) => value)
.filter((v): v is number => v !== null);
return selectedMethod.compute(values);
}
export default function transformProps(
chartProps: BigNumberWithTrendlineChartProps,
): BigNumberVizProps {
@@ -126,27 +152,33 @@ export default function transformProps(
// sort in time descending order
.sort((a, b) => (a[0] !== null && b[0] !== null ? b[0] - a[0] : 0));
}
if (hasAggregatedData && aggregatedData) {
if (
aggregatedData[metricName] !== null &&
aggregatedData[metricName] !== undefined
) {
bigNumber = aggregatedData[metricName];
} else {
const metricKeys = Object.keys(aggregatedData).filter(
key =>
key !== xAxisLabel &&
aggregatedData[key] !== null &&
typeof aggregatedData[key] === 'number',
);
bigNumber = metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null;
}
timestamp = sortedData.length > 0 ? sortedData[0][0] : null;
} else if (sortedData.length > 0) {
bigNumber = sortedData[0][1];
if (sortedData.length > 0) {
timestamp = sortedData[0][0];
// Raw aggregation uses server-side data, all others use client-side
if (aggregation === 'raw' && hasAggregatedData && aggregatedData) {
// Use server-side aggregation for raw
if (
aggregatedData[metricName] !== null &&
aggregatedData[metricName] !== undefined
) {
bigNumber = aggregatedData[metricName];
} else {
const metricKeys = Object.keys(aggregatedData).filter(
key =>
key !== xAxisLabel &&
aggregatedData[key] !== null &&
typeof aggregatedData[key] === 'number',
);
bigNumber =
metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null;
}
} else {
// Use client-side aggregation for all other methods
bigNumber = computeClientSideAggregation(sortedData, aggregation);
}
// Handle null bigNumber case
if (bigNumber === null) {
bigNumberFallback = sortedData.find(d => d[1] !== null);
bigNumber = bigNumberFallback ? bigNumberFallback[1] : null;

View File

@@ -128,9 +128,10 @@ describe('BigNumberWithTrendline', () => {
expect(lastDatum?.[0]).toStrictEqual(100);
expect(lastDatum?.[1]).toBeNull();
// should note this is a fallback
// should get the last non-null value
expect(transformed.bigNumber).toStrictEqual(1.2345);
expect(transformed.bigNumberFallback).not.toBeNull();
// bigNumberFallback is only set when bigNumber is null after aggregation
expect(transformed.bigNumberFallback).toBeNull();
// should successfully formatTime by granularity
// @ts-ignore

View File

@@ -97,10 +97,6 @@ export const COST_ESTIMATE_STARTED = 'COST_ESTIMATE_STARTED';
export const COST_ESTIMATE_RETURNED = 'COST_ESTIMATE_RETURNED';
export const COST_ESTIMATE_FAILED = 'COST_ESTIMATE_FAILED';
export const COST_THRESHOLD_CHECK_STARTED = 'COST_THRESHOLD_CHECK_STARTED';
export const COST_THRESHOLD_CHECK_RETURNED = 'COST_THRESHOLD_CHECK_RETURNED';
export const COST_THRESHOLD_CHECK_FAILED = 'COST_THRESHOLD_CHECK_FAILED';
export const CREATE_DATASOURCE_STARTED = 'CREATE_DATASOURCE_STARTED';
export const CREATE_DATASOURCE_SUCCESS = 'CREATE_DATASOURCE_SUCCESS';
export const CREATE_DATASOURCE_FAILED = 'CREATE_DATASOURCE_FAILED';
@@ -237,45 +233,6 @@ export function estimateQueryCost(queryEditor) {
};
}
export function checkCostThreshold(queryEditor) {
return (dispatch, getState) => {
const { dbId, catalog, schema, sql, selectedText, templateParams } =
getUpToDateQuery(getState(), queryEditor);
const requestSql = selectedText || sql;
const postPayload = {
database_id: dbId,
catalog,
schema,
sql: requestSql,
template_params: JSON.parse(templateParams || '{}'),
};
return Promise.all([
dispatch({ type: COST_THRESHOLD_CHECK_STARTED, query: queryEditor }),
SupersetClient.post({
endpoint: '/api/v1/sqllab/check_cost_threshold/',
body: JSON.stringify(postPayload),
headers: { 'Content-Type': 'application/json' },
})
.then(({ json }) =>
dispatch({ type: COST_THRESHOLD_CHECK_RETURNED, query: queryEditor, json }),
)
.catch(response =>
getClientErrorObject(response).then(error => {
const message =
error.error ||
error.statusText ||
t('Failed at checking cost threshold');
return dispatch({
type: COST_THRESHOLD_CHECK_FAILED,
query: queryEditor,
error: message,
});
}),
),
]);
};
}
export function clearInactiveQueries(interval) {
return { type: CLEAR_INACTIVE_QUERIES, interval };
}

View File

@@ -1,131 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@superset-ui/core';
import { theme } from 'src/preamble';
import CostWarningModal from './index';
const mockProps = {
visible: true,
onHide: jest.fn(),
onProceed: jest.fn(),
warningMessage: 'This query will scan 10 GB of data, which exceeds the threshold of 5 GB.',
thresholdInfo: {
bytes_threshold: 5 * 1024 ** 3, // 5 GB
estimated_bytes: 10 * 1024 ** 3, // 10 GB
},
};
const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
describe('CostWarningModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders with warning message', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
expect(screen.getByText('Query Cost Warning')).toBeInTheDocument();
expect(screen.getByText(mockProps.warningMessage)).toBeInTheDocument();
});
it('shows threshold details when provided', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
expect(screen.getByText('Threshold Details:')).toBeInTheDocument();
expect(screen.getByText('Data to scan:')).toBeInTheDocument();
expect(screen.getByText('10.0 GB')).toBeInTheDocument();
expect(screen.getByText('5.0 GB')).toBeInTheDocument();
});
it('disables proceed button until checkbox is checked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const proceedButton = screen.getByText('Run Query Anyway');
const checkbox = screen.getByRole('checkbox');
expect(proceedButton).toBeDisabled();
fireEvent.click(checkbox);
expect(proceedButton).not.toBeDisabled();
});
it('calls onProceed when proceed button is clicked with checkbox checked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const checkbox = screen.getByRole('checkbox');
const proceedButton = screen.getByText('Run Query Anyway');
fireEvent.click(checkbox);
fireEvent.click(proceedButton);
expect(mockProps.onProceed).toHaveBeenCalledTimes(1);
});
it('calls onHide when cancel button is clicked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockProps.onHide).toHaveBeenCalledTimes(1);
});
it('renders without threshold details when not provided', () => {
const propsWithoutThreshold = {
...mockProps,
thresholdInfo: undefined,
};
renderWithTheme(<CostWarningModal {...propsWithoutThreshold} />);
expect(screen.queryByText('Threshold Details:')).not.toBeInTheDocument();
});
it('shows default message when warningMessage is null', () => {
const propsWithNoMessage = {
...mockProps,
warningMessage: null,
};
renderWithTheme(<CostWarningModal {...propsWithNoMessage} />);
expect(screen.getByText('This query may be expensive to run.')).toBeInTheDocument();
});
it('handles cost threshold details', () => {
const propsWithCostThreshold = {
...mockProps,
thresholdInfo: {
cost_threshold: 100,
estimated_cost: 250,
},
};
renderWithTheme(<CostWarningModal {...propsWithCostThreshold} />);
expect(screen.getByText('Estimated cost:')).toBeInTheDocument();
expect(screen.getByText('250')).toBeInTheDocument();
expect(screen.getByText('Cost threshold:')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
});

View File

@@ -1,166 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { styled, t } from '@superset-ui/core';
import { Button, Modal, Checkbox } from '@superset-ui/core/components';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
const StyledModal = styled(Modal)`
.ant-modal-body {
padding: 24px;
}
`;
const WarningContent = styled.div`
margin: 16px 0;
font-size: 14px;
line-height: 1.5;
`;
const DetailsSection = styled.div`
margin: 16px 0;
padding: 12px;
background-color: ${({ theme }) => theme.colors.grayscale.light4};
border-radius: 4px;
font-size: 12px;
`;
const CheckboxWrapper = styled.div`
margin: 16px 0;
`;
interface CostWarningModalProps {
visible: boolean;
onHide: () => void;
onProceed: () => void;
warningMessage: string | null;
thresholdInfo?: {
bytes_threshold?: number;
estimated_bytes?: number;
cost_threshold?: number;
estimated_cost?: number;
};
}
export default function CostWarningModal({
visible,
onHide,
onProceed,
warningMessage,
thresholdInfo,
}: CostWarningModalProps) {
const [proceedAnyway, setProceedAnyway] = useState(false);
const handleProceed = () => {
if (proceedAnyway) {
onProceed();
}
};
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes < 1024 ** 5) return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
return `${(bytes / 1024 ** 5).toFixed(1)} PB`;
};
const renderThresholdDetails = () => {
if (!thresholdInfo) return null;
const details = [];
if (thresholdInfo.bytes_threshold && thresholdInfo.estimated_bytes) {
details.push(
<div key="bytes">
<strong>{t('Data to scan:')}</strong> {formatBytes(thresholdInfo.estimated_bytes)}
<br />
<strong>{t('Threshold:')}</strong> {formatBytes(thresholdInfo.bytes_threshold)}
</div>
);
}
if (thresholdInfo.cost_threshold && thresholdInfo.estimated_cost) {
details.push(
<div key="cost">
<strong>{t('Estimated cost:')}</strong> {thresholdInfo.estimated_cost}
<br />
<strong>{t('Cost threshold:')}</strong> {thresholdInfo.cost_threshold}
</div>
);
}
return details.length > 0 ? (
<DetailsSection>
<div style={{ marginBottom: '8px' }}>
<strong>{t('Threshold Details:')}</strong>
</div>
{details.map((detail, index) => (
<div key={index} style={{ marginBottom: index < details.length - 1 ? '8px' : '0' }}>
{detail}
</div>
))}
</DetailsSection>
) : null;
};
return (
<StyledModal
show={visible}
onHide={onHide}
title={
<ModalTitleWithIcon
icon="exclamation-triangle"
title={t('Query Cost Warning')}
/>
}
footer={
<>
<Button onClick={onHide}>
{t('Cancel')}
</Button>
<Button
buttonStyle="primary"
onClick={handleProceed}
disabled={!proceedAnyway}
>
{t('Run Query Anyway')}
</Button>
</>
}
>
<WarningContent>
{warningMessage || t('This query may be expensive to run.')}
</WarningContent>
{renderThresholdDetails()}
<CheckboxWrapper>
<Checkbox
checked={proceedAnyway}
onChange={(e) => setProceedAnyway(e.target.checked)}
>
{t('I understand the cost implications and want to proceed anyway')}
</Checkbox>
</CheckboxWrapper>
</StyledModal>
);
}

View File

@@ -71,7 +71,6 @@ import {
addNewQueryEditor,
CtasEnum,
estimateQueryCost,
checkCostThreshold,
persistEditorHeight,
postStopQuery,
queryEditorSetAutorun,
@@ -124,7 +123,6 @@ import SouthPane from '../SouthPane';
import SaveQuery, { QueryPayload } from '../SaveQuery';
import ScheduleQueryButton from '../ScheduleQueryButton';
import EstimateQueryCostButton from '../EstimateQueryCostButton';
import CostWarningModal from '../CostWarningModal';
import ShareSqlLabQuery from '../ShareSqlLabQuery';
import SqlEditorLeftBar from '../SqlEditorLeftBar';
import AceEditorWrapper from '../AceEditorWrapper';
@@ -272,7 +270,6 @@ const SqlEditor: FC<Props> = ({
hideLeftBar,
currentQueryEditorId,
hasSqlStatement,
costThresholdData,
} = useSelector<
SqlLabRootState,
{
@@ -281,9 +278,8 @@ const SqlEditor: FC<Props> = ({
hideLeftBar?: boolean;
currentQueryEditorId: QueryEditor['id'];
hasSqlStatement: boolean;
costThresholdData?: any;
}
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory, queryCostThresholds } }) => {
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory } }) => {
let { dbId, latestQueryId, hideLeftBar } = queryEditor;
if (unsavedQueryEditor?.id === queryEditor.id) {
dbId = unsavedQueryEditor.dbId || dbId;
@@ -299,7 +295,6 @@ const SqlEditor: FC<Props> = ({
latestQuery: queries[latestQueryId || ''],
hideLeftBar,
currentQueryEditorId: tabHistory.slice(-1)[0],
costThresholdData: queryCostThresholds[queryEditor.id],
};
}, shallowEqual);
@@ -322,11 +317,6 @@ const SqlEditor: FC<Props> = ({
);
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState('');
const [showCostWarningModal, setShowCostWarningModal] = useState(false);
const [costWarningData, setCostWarningData] = useState<{
warningMessage: string | null;
thresholdInfo?: any;
} | null>(null);
const currentSQL = useRef<string>(queryEditor.sql);
const showEmptyState = useMemo(
() => !database || isEmpty(database),
@@ -340,69 +330,7 @@ const SqlEditor: FC<Props> = ({
const isTempId = (value: unknown): boolean => Number.isNaN(Number(value));
const checkCostThresholdAndRun = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
if (!database) {
return;
}
// Check if cost threshold checking is enabled via feature flag or configuration
// For now, we'll implement the logic directly
dispatch(checkCostThreshold(queryEditor)).then(([_, response]) => {
if (response && response.json) {
const { exceeds_threshold, formatted_warning, threshold_info } = response.json;
if (exceeds_threshold && formatted_warning) {
// Show warning modal
setCostWarningData({
warningMessage: formatted_warning,
thresholdInfo: threshold_info,
});
setShowCostWarningModal(true);
return;
}
}
// If no threshold exceeded or checking failed, proceed with query
dispatch(
runQueryFromSqlEditor(
database,
queryEditor,
defaultQueryLimit,
ctasArg ? ctas : '',
ctasArg,
ctas_method,
),
);
dispatch(setActiveSouthPaneTab('Results'));
}).catch(() => {
// If cost checking fails, proceed with query anyway
dispatch(
runQueryFromSqlEditor(
database,
queryEditor,
defaultQueryLimit,
ctasArg ? ctas : '',
ctasArg,
ctas_method,
),
);
dispatch(setActiveSouthPaneTab('Results'));
});
},
[ctas, database, defaultQueryLimit, dispatch, queryEditor],
);
const startQuery = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
// Use cost threshold checking for regular queries
checkCostThresholdAndRun(ctasArg, ctas_method);
},
[checkCostThresholdAndRun],
);
// Direct query execution without cost checking (for modal "proceed anyway")
const executeQueryDirectly = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
if (!database) {
return;
@@ -1193,20 +1121,6 @@ const SqlEditor: FC<Props> = ({
<span>{t('Name')}</span>
<Input placeholder={createModalPlaceHolder} onChange={ctasChanged} />
</Modal>
<CostWarningModal
visible={showCostWarningModal}
onHide={() => {
setShowCostWarningModal(false);
setCostWarningData(null);
}}
onProceed={() => {
setShowCostWarningModal(false);
setCostWarningData(null);
executeQueryDirectly();
}}
warningMessage={costWarningData?.warningMessage || null}
thresholdInfo={costWarningData?.thresholdInfo}
/>
</StyledSqlEditor>
);
};

View File

@@ -264,7 +264,6 @@ export default function getInitialState({
queriesLastUpdate: Date.now(),
editorTabLastUpdatedAt,
queryCostEstimates: {},
queryCostThresholds: {},
unsavedQueryEditor,
lastUpdatedActiveTab,
destroyedQueryEditors,

View File

@@ -315,51 +315,6 @@ export default function sqlLabReducer(state = {}, action) {
},
};
},
[actions.COST_THRESHOLD_CHECK_STARTED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: false,
exceedsThreshold: false,
thresholdInfo: null,
formattedWarning: null,
error: null,
},
},
};
},
[actions.COST_THRESHOLD_CHECK_RETURNED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: true,
exceedsThreshold: action.json.exceeds_threshold,
thresholdInfo: action.json.threshold_info,
formattedWarning: action.json.formatted_warning,
error: null,
},
},
};
},
[actions.COST_THRESHOLD_CHECK_FAILED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: false,
exceedsThreshold: false,
thresholdInfo: null,
formattedWarning: null,
error: action.error,
},
},
};
},
[actions.START_QUERY]() {
let newState = { ...state };
if (action.query.sqlEditorId) {

View File

@@ -164,7 +164,7 @@ const v1ChartDataRequest = async (
ownState,
parseMethod,
) => {
const payload = buildV1ChartDataPayload({
const payload = await buildV1ChartDataPayload({
formData,
resultType,
resultFormat,
@@ -255,7 +255,7 @@ export function runAnnotationQuery({
isDashboardRequest = false,
force = false,
}) {
return function (dispatch, getState) {
return async function (dispatch, getState) {
const { charts, common } = getState();
const sliceKey = key || Object.keys(charts)[0];
const queryTimeout = timeout || common.conf.SUPERSET_WEBSERVER_TIMEOUT;
@@ -310,17 +310,19 @@ export function runAnnotationQuery({
fd.annotation_layers[annotationIndex].overrides = sliceFormData;
}
const payload = await buildV1ChartDataPayload({
formData: fd,
force,
resultFormat: 'json',
resultType: 'full',
});
return SupersetClient.post({
url,
signal,
timeout: queryTimeout * 1000,
headers: { 'Content-Type': 'application/json' },
jsonPayload: buildV1ChartDataPayload({
formData: fd,
force,
resultFormat: 'json',
resultType: 'full',
}),
jsonPayload: payload,
})
.then(({ json }) => {
const data = json?.result?.[0]?.annotation_data?.[annotation.name];
@@ -420,6 +422,8 @@ export function exploreJSON(
const setDataMask = dataMask => {
dispatch(updateDataMask(formData.slice_id, dataMask));
};
dispatch(chartUpdateStarted(controller, formData, key));
const chartDataRequest = getChartDataRequest({
setDataMask,
formData,
@@ -431,8 +435,6 @@ export function exploreJSON(
ownState,
});
dispatch(chartUpdateStarted(controller, formData, key));
const [useLegacyApi] = getQuerySettings(formData);
const chartDataRequestCaught = chartDataRequest
.then(({ response, json }) =>

View File

@@ -64,6 +64,7 @@ describe('chart actions', () => {
let dispatch;
let getExploreUrlStub;
let getChartDataUriStub;
let buildV1ChartDataPayloadStub;
let waitForAsyncDataStub;
let fakeMetadata;
@@ -85,6 +86,13 @@ describe('chart actions', () => {
getChartDataUriStub = sinon
.stub(exploreUtils, 'getChartDataUri')
.callsFake(({ qs }) => URI(MOCK_URL).query(qs));
buildV1ChartDataPayloadStub = sinon
.stub(exploreUtils, 'buildV1ChartDataPayload')
.resolves({
some_param: 'fake query!',
result_type: 'full',
result_format: 'json',
});
fakeMetadata = { useLegacyApi: true };
getChartMetadataRegistry.mockImplementation(() => ({
get: () => fakeMetadata,
@@ -104,6 +112,7 @@ describe('chart actions', () => {
afterEach(() => {
getExploreUrlStub.restore();
getChartDataUriStub.restore();
buildV1ChartDataPayloadStub.restore();
fetchMock.resetHistory();
waitForAsyncDataStub.restore();
@@ -362,7 +371,7 @@ describe('chart actions timeout', () => {
jest.clearAllMocks();
});
it('should use the timeout from arguments when given', () => {
it('should use the timeout from arguments when given', async () => {
const postSpy = jest.spyOn(SupersetClient, 'post');
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
const timeout = 10; // Set the timeout value here
@@ -370,7 +379,7 @@ describe('chart actions timeout', () => {
const key = 'chartKey'; // Set the chart key here
const store = mockStore(initialState);
store.dispatch(
await store.dispatch(
actions.runAnnotationQuery({
annotation: {
value: 'annotationValue',
@@ -394,14 +403,14 @@ describe('chart actions timeout', () => {
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
});
it('should use the timeout from common.conf when not passed as an argument', () => {
it('should use the timeout from common.conf when not passed as an argument', async () => {
const postSpy = jest.spyOn(SupersetClient, 'post');
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
const formData = { datasource: 'table__1' }; // Set the formData here
const key = 'chartKey'; // Set the chart key here
const store = mockStore(initialState);
store.dispatch(
await store.dispatch(
actions.runAnnotationQuery({
annotation: {
value: 'annotationValue',

View File

@@ -91,7 +91,7 @@ afterEach(() => {
});
const getFormatSwitch = () =>
screen.getByRole('switch', { name: 'Show original SQL' });
screen.getByRole('switch', { name: 'formatted original' });
test('renders the component with Formatted SQL and buttons', async () => {
const { container } = setup(mockProps);

View File

@@ -26,11 +26,17 @@ import {
} from 'react';
import { useSelector } from 'react-redux';
import rison from 'rison';
import { styled, SupersetClient, t } from '@superset-ui/core';
import { Icons, Switch, Button, Skeleton } from '@superset-ui/core/components';
import { styled, SupersetClient, t, useTheme } from '@superset-ui/core';
import {
Icons,
Switch,
Button,
Skeleton,
Card,
Space,
} from '@superset-ui/core/components';
import { CopyToClipboard } from 'src/components';
import { RootState } from 'src/dashboard/types';
import { CopyButton } from 'src/explore/components/DataTableControl';
import { findPermission } from 'src/utils/findPermission';
import CodeSyntaxHighlighter, {
SupportedLanguage,
@@ -38,14 +44,6 @@ import CodeSyntaxHighlighter, {
} from '@superset-ui/core/components/CodeSyntaxHighlighter';
import { useHistory } from 'react-router-dom';
const CopyButtonViewQuery = styled(CopyButton)`
${({ theme }) => `
&& {
margin: 0 0 ${theme.sizeUnit}px;
}
`}
`;
export interface ViewQueryProps {
sql: string;
datasource: string;
@@ -58,26 +56,14 @@ const StyledSyntaxContainer = styled.div`
flex-direction: column;
`;
const StyledHeaderMenuContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: ${({ theme }) => -theme.sizeUnit * 4}px;
align-items: flex-end;
`;
const StyledHeaderActionContainer = styled.div`
display: flex;
flex-direction: row;
column-gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const StyledThemedSyntaxHighlighter = styled(CodeSyntaxHighlighter)`
flex: 1;
`;
const StyledLabel = styled.label`
font-size: ${({ theme }) => theme.fontSize}px;
const StyledFooter = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const DATASET_BACKEND_QUERY = {
@@ -87,6 +73,7 @@ const DATASET_BACKEND_QUERY = {
const ViewQuery: FC<ViewQueryProps> = props => {
const { sql, language = 'sql', datasource } = props;
const theme = useTheme();
const datasetId = datasource.split('__')[0];
const [formattedSQL, setFormattedSQL] = useState<string>();
const [showFormatSQL, setShowFormatSQL] = useState(true);
@@ -153,46 +140,57 @@ const ViewQuery: FC<ViewQueryProps> = props => {
}, [sql]);
return (
<StyledSyntaxContainer key={sql}>
<StyledHeaderMenuContainer>
<StyledHeaderActionContainer>
<CopyToClipboard
text={currentSQL}
shouldShowText={false}
copyNode={
<CopyButtonViewQuery
<Card bodyStyle={{ padding: theme.sizeUnit * 4 }}>
<StyledSyntaxContainer key={sql}>
{!formattedSQL && <Skeleton active />}
{formattedSQL && (
<StyledThemedSyntaxHighlighter
language={language}
customStyle={{ flex: 1, marginBottom: theme.sizeUnit * 3 }}
>
{currentSQL}
</StyledThemedSyntaxHighlighter>
)}
<StyledFooter>
<Space size={theme.sizeUnit * 2}>
<CopyToClipboard
text={currentSQL}
shouldShowText={false}
copyNode={
<Button
buttonStyle="secondary"
buttonSize="small"
icon={<Icons.CopyOutlined />}
>
{t('Copy')}
</Button>
}
/>
{canAccessSQLLab && (
<Button
buttonStyle="secondary"
buttonSize="small"
icon={<Icons.CopyOutlined />}
onClick={navToSQLLab}
>
{t('Copy')}
</CopyButtonViewQuery>
}
/>
{canAccessSQLLab && (
<Button onClick={navToSQLLab}>{t('View in SQL Lab')}</Button>
)}
</StyledHeaderActionContainer>
<StyledHeaderActionContainer>
<Switch
id="formatSwitch"
checked={!showFormatSQL}
onChange={formatCurrentQuery}
/>
<StyledLabel htmlFor="formatSwitch">
{t('Show original SQL')}
</StyledLabel>
</StyledHeaderActionContainer>
</StyledHeaderMenuContainer>
{!formattedSQL && <Skeleton active />}
{formattedSQL && (
<StyledThemedSyntaxHighlighter
language={language}
customStyle={{ flex: 1 }}
>
{currentSQL}
</StyledThemedSyntaxHighlighter>
)}
</StyledSyntaxContainer>
{t('View in SQL Lab')}
</Button>
)}
</Space>
<Space size={theme.sizeUnit * 2} align="center">
<Icons.ConsoleSqlOutlined />
<Switch
id="formatSwitch"
checked={showFormatSQL}
onChange={formatCurrentQuery}
checkedChildren={t('formatted')}
unCheckedChildren={t('original')}
/>
</Space>
</StyledFooter>
</StyledSyntaxContainer>
</Card>
);
};

View File

@@ -42,6 +42,7 @@ const ViewQueryModalContainer = styled.div`
height: 100%;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
@@ -86,9 +87,10 @@ const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
return (
<ViewQueryModalContainer>
{result.map(item =>
{result.map((item, index) =>
item.query ? (
<ViewQuery
key={`query-${index}`}
datasource={latestQueryFormData.datasource}
sql={item.query}
language="sql"

View File

@@ -41,6 +41,9 @@ import {
import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src';
import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index';
// Mock scrollIntoView to avoid errors in test environment
jest.mock('scroll-into-view-if-needed', () => jest.fn());
jest.useFakeTimers();
class MainPreset extends Preset {
@@ -256,4 +259,22 @@ describe('VizTypeControl', () => {
expect(defaultProps.onChange).toHaveBeenCalledWith(VizType.Line);
});
it('Search input is focused when modal opens', async () => {
// Mock the focus method to track if it was called
const focusSpy = jest.fn();
const originalFocus = HTMLInputElement.prototype.focus;
HTMLInputElement.prototype.focus = focusSpy;
await waitForRenderWrapper();
const searchInput = screen.getByTestId(getTestId('search-input'));
// Verify that focus() was called on the search input
expect(focusSpy).toHaveBeenCalled();
expect(searchInput).toBeInTheDocument();
// Restore the original focus method
HTMLInputElement.prototype.focus = originalFocus;
});
});

View File

@@ -575,6 +575,13 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
setIsSearchFocused(true);
}, []);
// Auto-focus the search input when the modal opens
useEffect(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, []);
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
event => setSearchInputValue(event.target.value),
[],

View File

@@ -191,8 +191,8 @@ describe('exploreUtils', () => {
});
describe('buildV1ChartDataPayload', () => {
it('generate valid request payload despite no registered buildQuery', () => {
const v1RequestPayload = buildV1ChartDataPayload({
it('generate valid request payload despite no registered buildQuery', async () => {
const v1RequestPayload = await buildV1ChartDataPayload({
formData: { ...formData, viz_type: 'my_custom_viz' },
});
expect(v1RequestPayload.hasOwnProperty('queries')).toBeTruthy();

View File

@@ -207,7 +207,7 @@ export const getQuerySettings = formData => {
];
};
export const buildV1ChartDataPayload = ({
export const buildV1ChartDataPayload = async ({
formData,
force,
resultFormat,
@@ -242,7 +242,7 @@ export const buildV1ChartDataPayload = ({
export const getLegacyEndpointType = ({ resultType, resultFormat }) =>
resultFormat === 'csv' ? resultFormat : resultType;
export const exportChart = ({
export const exportChart = async ({
formData,
resultFormat = 'json',
resultType = 'full',
@@ -262,7 +262,7 @@ export const exportChart = ({
payload = formData;
} else {
url = ensureAppRoot('/api/v1/chart/data');
payload = buildV1ChartDataPayload({
payload = await buildV1ChartDataPayload({
formData,
force,
resultFormat,

View File

@@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import {
render,
screen,
waitFor,
userEvent,
} from 'spec/helpers/testing-library';
import Footer from 'src/features/datasets/AddDataset/Footer';
const mockHistoryPush = jest.fn();
@@ -27,6 +32,14 @@ jest.mock('react-router-dom', () => ({
}),
}));
// Mock the API call
const mockCreateResource = jest.fn();
jest.mock('src/views/CRUD/hooks', () => ({
useSingleViewResource: () => ({
createResource: mockCreateResource,
}),
}));
const mockedProps = {
url: 'realwebsite.com',
};
@@ -34,7 +47,7 @@ const mockedProps = {
const mockPropsWithDataset = {
url: 'realwebsite.com',
datasetObject: {
database: {
db: {
id: '1',
database_name: 'examples',
},
@@ -47,6 +60,10 @@ const mockPropsWithDataset = {
};
describe('Footer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders a Footer with a cancel button and a disabled create button', () => {
render(<Footer {...mockedProps} />, { useRedux: true });
@@ -55,21 +72,28 @@ describe('Footer', () => {
});
const createButton = screen.getByRole('button', {
name: /Create/i,
name: /Create dataset and create chart/i,
});
expect(saveButton).toBeVisible();
expect(createButton).toBeDisabled();
});
test('renders a Create Dataset button when a table is selected', () => {
test('renders a Create Dataset dropdown button when a table is selected', () => {
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
const createButton = screen.getByRole('button', {
name: /Create/i,
name: /Create dataset and create chart/i,
});
expect(createButton).toBeEnabled();
// Check that it's a dropdown button with the correct text
expect(createButton).toHaveTextContent('Create dataset and create chart');
// Check for the dropdown arrow
const dropdownArrow = screen.getByRole('img', { hidden: true });
expect(dropdownArrow).toBeInTheDocument();
});
test('create button becomes disabled when table already has a dataset', () => {
@@ -78,9 +102,119 @@ describe('Footer', () => {
});
const createButton = screen.getByRole('button', {
name: /Create/i,
name: /Create dataset and create chart/i,
});
expect(createButton).toBeDisabled();
});
test('shows dropdown menu when dropdown arrow is clicked', async () => {
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
// Find and click the dropdown trigger (the arrow part)
const dropdownTrigger = screen.getByRole('button', { name: 'down' });
userEvent.click(dropdownTrigger);
// Check that the dropdown menu option is visible
await waitFor(() => {
expect(screen.getByText('Create dataset only')).toBeVisible();
});
});
test('navigates to chart creation when main button is clicked', async () => {
mockCreateResource.mockResolvedValue(123); // Mock successful dataset creation
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
});
userEvent.click(createButton);
await waitFor(() => {
expect(mockCreateResource).toHaveBeenCalledWith({
database: '1',
catalog: undefined,
schema: 'public',
table_name: 'real_info',
});
expect(mockHistoryPush).toHaveBeenCalledWith(
'/chart/add/?dataset=real_info',
);
});
});
test('navigates to dataset list when "Create dataset only" menu option is clicked', async () => {
mockCreateResource.mockResolvedValue(123);
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
// Open dropdown menu
const dropdownTrigger = screen.getByRole('button', { name: 'down' });
userEvent.click(dropdownTrigger);
// Click the "Create dataset only" option
await waitFor(() => {
const datasetOnlyOption = screen.getByText('Create dataset only');
userEvent.click(datasetOnlyOption);
});
await waitFor(() => {
expect(mockCreateResource).toHaveBeenCalledWith({
database: '1',
catalog: undefined,
schema: 'public',
table_name: 'real_info',
});
expect(mockHistoryPush).toHaveBeenCalledWith('/tablemodelview/list/');
});
});
test('handles dataset creation failure gracefully', async () => {
mockCreateResource.mockResolvedValue(null); // Mock failed dataset creation
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
});
userEvent.click(createButton);
await waitFor(() => {
expect(mockCreateResource).toHaveBeenCalled();
// Should not navigate if creation failed
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
test('passes correct data to createResource with catalog', async () => {
const mockPropsWithCatalog = {
...mockPropsWithDataset,
datasetObject: {
...mockPropsWithDataset.datasetObject,
catalog: 'test_catalog',
},
};
mockCreateResource.mockResolvedValue(456);
render(<Footer {...mockPropsWithCatalog} />, { useRedux: true });
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
});
userEvent.click(createButton);
await waitFor(() => {
expect(mockCreateResource).toHaveBeenCalledWith({
database: '1',
catalog: 'test_catalog',
schema: 'public',
table_name: 'real_info',
});
});
});
});

View File

@@ -17,8 +17,14 @@
* under the License.
*/
import { useHistory } from 'react-router-dom';
import { Button } from '@superset-ui/core/components';
import { t } from '@superset-ui/core';
import {
Button,
DropdownButton,
Menu,
Flex,
} from '@superset-ui/core/components';
import { t, useTheme } from '@superset-ui/core';
import { Icons } from '@superset-ui/core/components/Icons';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
import { logEvent } from 'src/logger/actions';
import withToasts from 'src/components/MessageToasts/withToasts';
@@ -55,6 +61,7 @@ function Footer({
datasets,
}: FooterProps) {
const history = useHistory();
const theme = useTheme();
const { createResource } = useSingleViewResource<Partial<DatasetObject>>(
'dataset',
t('dataset'),
@@ -85,7 +92,7 @@ function Footer({
const tooltipText = t('Select a database table.');
const onSave = () => {
const onSave = (createChart: boolean = true) => {
if (datasetObject) {
const data = {
database: datasetObject.db?.id,
@@ -100,32 +107,57 @@ function Footer({
if (typeof response === 'number') {
logEvent(LOG_ACTIONS_DATASET_CREATION_SUCCESS, datasetObject);
// When a dataset is created the response we get is its ID number
history.push(`/chart/add/?dataset=${datasetObject.table_name}`);
if (createChart) {
history.push(`/chart/add/?dataset=${datasetObject.table_name}`);
} else {
history.push('/tablemodelview/list/');
}
}
});
}
};
const onSaveOnly = () => {
onSave(false);
};
const CREATE_DATASET_TEXT = t('Create dataset and create chart');
const CREATE_DATASET_ONLY_TEXT = t('Create dataset only');
const disabledCheck =
!datasetObject?.table_name ||
!hasColumns ||
datasets?.includes(datasetObject?.table_name);
const dropdownMenu = (
<Menu>
<Menu.Item key="create-only" onClick={onSaveOnly}>
{CREATE_DATASET_ONLY_TEXT}
</Menu.Item>
</Menu>
);
return (
<>
<Flex align="center" justify="flex-end" gap="8px">
<Button buttonStyle="secondary" onClick={cancelButtonOnClick}>
{t('Cancel')}
</Button>
<Button
buttonStyle="primary"
<DropdownButton
type="primary"
disabled={disabledCheck}
tooltip={!datasetObject?.table_name ? tooltipText : undefined}
onClick={onSave}
onClick={() => onSave(true)}
popupRender={() => dropdownMenu}
icon={
<Icons.DownOutlined
iconSize="xs"
iconColor={theme.colors.grayscale.light5}
/>
}
trigger={['click']}
>
{CREATE_DATASET_TEXT}
</Button>
</>
</DropdownButton>
</Flex>
);
}

43
superset/cli/mcp.py Normal file
View File

@@ -0,0 +1,43 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""CLI module for MCP service"""
import os
import click
from superset.mcp_service.server import run_server
@click.group()
def mcp() -> None:
"""Model Context Protocol service commands"""
pass
@mcp.command()
@click.option("--host", default="127.0.0.1", help="Host to bind to")
@click.option("--port", default=5008, help="Port to bind to")
@click.option("--debug", is_flag=True, help="Enable debug mode")
@click.option("--sql-debug", is_flag=True, help="Enable SQL query logging")
def run(host: str, port: int, debug: bool, sql_debug: bool) -> None:
"""Run the MCP service"""
if sql_debug:
os.environ["SQLALCHEMY_DEBUG"] = "1"
click.echo("🔍 SQL Debug mode enabled")
run_server(host=host, port=port, debug=debug)

View File

@@ -199,6 +199,11 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
:raises DatasetUnAllowedDataURI: If a dataset is trying
to load data from a URI that is not allowed.
"""
from superset.examples.helpers import normalize_example_data_url
# Convert example URLs to align with configuration
data_uri = normalize_example_data_url(data_uri)
validate_data_uri(data_uri)
logger.info("Downloading data from %s", data_uri)
data = request.urlopen(data_uri) # pylint: disable=consider-using-with # noqa: S310

View File

@@ -37,6 +37,10 @@ class CreateFormDataCommand(BaseCommand):
def __init__(self, cmd_params: CommandParameters):
self._cmd_params = cmd_params
def _get_session_id(self) -> str:
"""Get session ID. Can be overridden in subclasses."""
return session.get("_id")
def run(self) -> str:
self.validate()
try:
@@ -47,7 +51,7 @@ class CreateFormDataCommand(BaseCommand):
form_data = self._cmd_params.form_data
check_access(datasource_id, chart_id, datasource_type)
contextual_key = cache_key(
session.get("_id"), tab_id, datasource_id, chart_id, datasource_type
self._get_session_id(), tab_id, datasource_id, chart_id, datasource_type
)
key = cache_manager.explore_form_data_cache.get(contextual_key)
if not key or not tab_id:

View File

@@ -190,6 +190,12 @@ def load_configs(
db_ssh_tunnel_priv_key_passws[config["uuid"]]
)
# Normalize example data URLs before schema validation
if prefix == "datasets" and "data" in config:
from superset.examples.helpers import normalize_example_data_url
config["data"] = normalize_example_data_url(config["data"])
schema.load(config)
configs[file_name] = config
except ValidationError as exc:

View File

@@ -1,230 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import logging
from typing import Any, TypedDict
from superset import app
from superset.commands.base import BaseCommand
from superset.commands.sql_lab.estimate import QueryEstimationCommand, EstimateQueryCostType
config = app.config
logger = logging.getLogger(__name__)
class CostThresholdResult(TypedDict):
exceeds_threshold: bool
estimated_cost: list[dict[str, Any]]
threshold_info: dict[str, Any]
formatted_warning: str | None
class QueryCostThresholdCheckCommand(BaseCommand):
"""
Command to check if a query's estimated cost exceeds configured thresholds.
"""
_estimation_command: QueryEstimationCommand
def __init__(self, estimation_params: EstimateQueryCostType) -> None:
self._estimation_command = QueryEstimationCommand(estimation_params)
def validate(self) -> None:
# Use the estimation command's validation
self._estimation_command.validate()
def run(self) -> CostThresholdResult:
"""
Check if query cost exceeds thresholds.
Returns a result indicating whether the query exceeds cost thresholds
and provides information for user warnings.
"""
self.validate()
# Check if cost checking is enabled
if not config.get("SQLLAB_QUERY_COST_CHECKING_ENABLED", False):
return self._create_empty_result()
estimated_cost = self._get_estimated_cost()
if not estimated_cost:
return self._create_empty_result()
thresholds = self._get_engine_thresholds()
if not thresholds:
return CostThresholdResult(
exceeds_threshold=False,
estimated_cost=estimated_cost,
threshold_info={},
formatted_warning=None,
)
return self._check_thresholds(estimated_cost, thresholds)
def _create_empty_result(self) -> CostThresholdResult:
"""Create an empty result when cost checking is disabled or fails."""
return CostThresholdResult(
exceeds_threshold=False,
estimated_cost=[],
threshold_info={},
formatted_warning=None,
)
def _get_estimated_cost(self) -> list[dict[str, Any]] | None:
"""Get cost estimation, returning None if it fails."""
try:
return self._estimation_command.run()
except Exception as ex:
logger.warning("Cost estimation failed: %s", str(ex))
return None
def _get_engine_thresholds(self) -> dict[str, Any]:
"""Get thresholds for the current database engine."""
database = self._estimation_command._database
engine_name = database.db_engine_spec.engine_name
if engine_name is None:
return {}
engine_name = engine_name.lower()
return config.get("SQLLAB_QUERY_COST_THRESHOLDS", {}).get(engine_name, {})
def _check_thresholds(
self, estimated_cost: list[dict[str, Any]], thresholds: dict[str, Any]
) -> CostThresholdResult:
"""Check if estimated cost exceeds configured thresholds."""
exceeds_threshold = False
warning_messages = []
threshold_info = {}
for cost_item in estimated_cost:
if self._check_bytes_threshold(cost_item, thresholds, threshold_info, warning_messages):
exceeds_threshold = True
if self._check_cost_threshold(cost_item, thresholds, threshold_info, warning_messages):
exceeds_threshold = True
formatted_warning = None
if warning_messages:
formatted_warning = (
" ".join(warning_messages) + " Are you sure you want to continue?"
)
return CostThresholdResult(
exceeds_threshold=exceeds_threshold,
estimated_cost=estimated_cost,
threshold_info=threshold_info,
formatted_warning=formatted_warning,
)
def _check_bytes_threshold(
self,
cost_item: dict[str, Any],
thresholds: dict[str, Any],
threshold_info: dict[str, Any],
warning_messages: list[str]
) -> bool:
"""Check bytes scanned threshold. Returns True if threshold exceeded."""
if "bytes_scanned" not in thresholds or "Bytes Scanned" not in cost_item:
return False
try:
bytes_scanned = self._parse_bytes_from_cost_item(cost_item["Bytes Scanned"])
threshold_bytes = thresholds["bytes_scanned"]
threshold_info["bytes_threshold"] = threshold_bytes
threshold_info["estimated_bytes"] = bytes_scanned
if bytes_scanned > threshold_bytes:
warning_messages.append(
f"This query will scan approximately {self._format_bytes(bytes_scanned)} "
f"of data, which exceeds the threshold of {self._format_bytes(threshold_bytes)}."
)
return True
except (ValueError, KeyError) as ex:
logger.warning("Failed to parse bytes from cost estimation: %s", str(ex))
return False
def _check_cost_threshold(
self,
cost_item: dict[str, Any],
thresholds: dict[str, Any],
threshold_info: dict[str, Any],
warning_messages: list[str]
) -> bool:
"""Check cost threshold. Returns True if threshold exceeded."""
if "cost_threshold" not in thresholds or "Cost" not in cost_item:
return False
try:
cost_value = float(cost_item["Cost"])
threshold_cost = thresholds["cost_threshold"]
threshold_info["cost_threshold"] = threshold_cost
threshold_info["estimated_cost"] = cost_value
if cost_value > threshold_cost:
warning_messages.append(
f"This query has an estimated cost of {cost_value}, "
f"which exceeds the threshold of {threshold_cost}."
)
return True
except (ValueError, KeyError) as ex:
logger.warning("Failed to parse cost from cost estimation: %s", str(ex))
return False
def _parse_bytes_from_cost_item(self, bytes_str: str) -> int:
"""Parse bytes from formatted string like '5.2 GB' or '1024 MB'."""
if not isinstance(bytes_str, str):
return int(bytes_str)
# Remove commas and split
parts = bytes_str.replace(",", "").strip().split()
if len(parts) != 2:
raise ValueError(f"Cannot parse bytes from: {bytes_str}")
value_str, unit = parts
value = float(value_str)
unit = unit.upper()
multipliers = {
"B": 1,
"KB": 1024,
"MB": 1024**2,
"GB": 1024**3,
"TB": 1024**4,
"PB": 1024**5,
}
if unit not in multipliers:
raise ValueError(f"Unknown unit: {unit}")
return int(value * multipliers[unit])
def _format_bytes(self, bytes_count: int) -> str:
"""Format bytes into human-readable string."""
if bytes_count < 1024:
return f"{bytes_count} B"
elif bytes_count < 1024**2:
return f"{bytes_count / 1024:.1f} KB"
elif bytes_count < 1024**3:
return f"{bytes_count / (1024**2):.1f} MB"
elif bytes_count < 1024**4:
return f"{bytes_count / (1024**3):.1f} GB"
elif bytes_count < 1024**5:
return f"{bytes_count / (1024**4):.1f} TB"
else:
return f"{bytes_count / (1024**5):.1f} PB"

View File

@@ -1191,18 +1191,6 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = int(timedelta(hours=6).total_seconds())
# timeout.
SQLLAB_QUERY_COST_ESTIMATE_TIMEOUT = int(timedelta(seconds=10).total_seconds())
# Query cost governance configuration
# Enable automatic cost checking before query execution
SQLLAB_QUERY_COST_CHECKING_ENABLED = False
# Cost thresholds that trigger warnings before query execution
# This is a dictionary where keys are database engine names and values are threshold configs
# Each threshold config can contain:
# - 'bytes_scanned': maximum bytes that can be scanned without warning
# - 'cost_threshold': monetary cost threshold (engine-specific units)
# Example: {'bigquery': {'bytes_scanned': 5 * 1024**4}, 'presto': {'cost_threshold': 1000}}
SQLLAB_QUERY_COST_THRESHOLDS = {}
# Timeout duration for SQL Lab fetching query results by the resultsKey.
# 0 means no timeout.
SQLLAB_QUERY_RESULT_TIMEOUT = 0

View File

@@ -1368,10 +1368,23 @@ class SqlaTable(
return get_template_processor(table=self, database=self.database, **kwargs)
def get_sqla_table(self) -> TableClause:
tbl = table(self.table_name)
# For databases that support cross-catalog queries (like BigQuery),
# include the catalog in the table identifier to generate
# project.dataset.table format
if self.catalog and self.database.db_engine_spec.supports_cross_catalog_queries:
# SQLAlchemy doesn't have built-in catalog support for TableClause,
# so we need to construct the full identifier manually
if self.schema:
full_name = f"{self.catalog}.{self.schema}.{self.table_name}"
else:
full_name = f"{self.catalog}.{self.table_name}"
return table(full_name)
if self.schema:
tbl.schema = self.schema
return tbl
return table(self.table_name, schema=self.schema)
return table(self.table_name)
def get_from_clause(
self,

View File

@@ -16,18 +16,87 @@
# under the License.
from __future__ import annotations
from typing import Any, Generic, get_args, TypeVar
import logging
import uuid as uuid_lib
from enum import Enum
from typing import (
Any,
Dict,
Generic,
get_args,
List,
Optional,
Sequence,
Tuple,
TypeVar,
)
from flask_appbuilder.models.filters import BaseFilter
from flask_appbuilder.models.sqla import Model
from flask_appbuilder.models.sqla.interface import SQLAInterface
from pydantic import BaseModel, Field
from sqlalchemy import asc, cast, desc, or_, Text
from sqlalchemy.exc import StatementError
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import ColumnProperty, joinedload, RelationshipProperty
from superset.extensions import db
T = TypeVar("T", bound=Model)
class ColumnOperatorEnum(str, Enum):
eq = "eq"
ne = "ne"
sw = "sw"
ew = "ew"
in_ = "in"
nin = "nin"
gt = "gt"
gte = "gte"
lt = "lt"
lte = "lte"
like = "like"
ilike = "ilike"
is_null = "is_null"
is_not_null = "is_not_null"
@classmethod
def operator_map(cls) -> Dict[ColumnOperatorEnum, Any]:
return {
cls.eq: lambda col, val: col == val,
cls.ne: lambda col, val: col != val,
cls.sw: lambda col, val: col.like(f"{val}%"),
cls.ew: lambda col, val: col.like(f"%{val}"),
cls.in_: lambda col, val: col.in_(
val if isinstance(val, (list, tuple)) else [val]
),
cls.nin: lambda col, val: ~col.in_(
val if isinstance(val, (list, tuple)) else [val]
),
cls.gt: lambda col, val: col > val,
cls.gte: lambda col, val: col >= val,
cls.lt: lambda col, val: col < val,
cls.lte: lambda col, val: col <= val,
cls.like: lambda col, val: col.like(f"%{val}%"),
cls.ilike: lambda col, val: col.ilike(f"%{val}%"),
cls.is_null: lambda col, _: col.is_(None),
cls.is_not_null: lambda col, _: col.isnot(None),
}
def apply(self, column: Any, value: Any) -> Any:
op_func = self.operator_map().get(self)
if not op_func:
raise ValueError(f"Unsupported operator: {self}")
return op_func(column, value)
class ColumnOperator(BaseModel):
col: str = Field(..., description="Column name to filter on")
opr: ColumnOperatorEnum = Field(..., description="Operator")
value: Any = Field(None, description="Value for the filter")
class BaseDAO(Generic[T]):
"""
Base DAO, implement base CRUD sqlalchemy operations
@@ -50,45 +119,128 @@ class BaseDAO(Generic[T]):
)[0]
@classmethod
def find_by_id(
cls,
model_id: str | int,
skip_base_filter: bool = False,
) -> T | None:
def _apply_base_filter(
cls, query: Any, skip_base_filter: bool = False, data_model: Any = None
) -> Any:
"""
Find a model by id, if defined applies `base_filter`
Apply the base_filter to the query if it exists and skip_base_filter is False.
"""
query = db.session.query(cls.model_cls)
if cls.base_filter and not skip_base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
if data_model is None:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model
).apply(query, None)
id_column = getattr(cls.model_cls, cls.id_column_name)
return query
@classmethod
def _convert_value_for_column(cls, column: Any, value: Any) -> Any:
"""
Convert a value to the appropriate type for a given SQLAlchemy column.
Args:
column: SQLAlchemy column object
value: Value to convert
Returns:
Converted value or None if conversion fails
"""
if (
hasattr(column.type, "python_type")
and column.type.python_type == uuid_lib.UUID
):
if isinstance(value, str):
try:
return uuid_lib.UUID(value)
except (ValueError, AttributeError):
return None
return value
@classmethod
def _find_by_column(
cls,
column_name: str,
value: str | int,
skip_base_filter: bool = False,
) -> T | None:
"""
Private method to find a model by any column value.
Args:
column_name: Name of the column to search by
value: Value to search for
skip_base_filter: Whether to skip base filtering
Returns:
Model instance or None if not found
"""
query = db.session.query(cls.model_cls)
query = cls._apply_base_filter(query, skip_base_filter)
if not hasattr(cls.model_cls, column_name):
return None
column = getattr(cls.model_cls, column_name)
converted_value = cls._convert_value_for_column(column, value)
if converted_value is None:
return None
try:
return query.filter(id_column == model_id).one_or_none()
return query.filter(column == converted_value).one_or_none()
except StatementError:
# can happen if int is passed instead of a string or similar
return None
@classmethod
def find_by_id(
cls,
model_id: str | int,
skip_base_filter: bool = False,
id_column: str | None = None,
) -> T | None:
"""
Find a model by ID using specified or default ID column.
Args:
model_id: ID value to search for
skip_base_filter: Whether to skip base filtering
id_column: Column name to use (defaults to cls.id_column_name)
Returns:
Model instance or None if not found
"""
column = id_column or cls.id_column_name
return cls._find_by_column(column, model_id, skip_base_filter)
@classmethod
def find_by_ids(
cls,
model_ids: list[str] | list[int],
model_ids: Sequence[str | int],
skip_base_filter: bool = False,
id_column: str | None = None,
) -> list[T]:
"""
Find a List of models by a list of ids, if defined applies `base_filter`
:param model_ids: List of IDs to find
:param skip_base_filter: If true, skip applying the base filter
:param id_column: Optional column name to use for ID lookup
(defaults to id_column_name)
"""
id_col = getattr(cls.model_cls, cls.id_column_name, None)
column = id_column or cls.id_column_name
id_col = getattr(cls.model_cls, column, None)
if id_col is None:
return []
query = db.session.query(cls.model_cls).filter(id_col.in_(model_ids))
if cls.base_filter and not skip_base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model
).apply(query, None)
# Convert IDs to appropriate types based on column type
converted_ids: list[str | int | uuid_lib.UUID] = []
for id_val in model_ids:
converted_value = cls._convert_value_for_column(id_col, id_val)
if converted_value is not None:
converted_ids.append(converted_value)
query = db.session.query(cls.model_cls).filter(id_col.in_(converted_ids))
query = cls._apply_base_filter(query, skip_base_filter)
return query.all()
@classmethod
@@ -97,11 +249,7 @@ class BaseDAO(Generic[T]):
Get all that fit the `base_filter`
"""
query = db.session.query(cls.model_cls)
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model
).apply(query, None)
query = cls._apply_base_filter(query)
return query.all()
@classmethod
@@ -110,11 +258,7 @@ class BaseDAO(Generic[T]):
Get the first that fit the `base_filter`
"""
query = db.session.query(cls.model_cls)
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model
).apply(query, None)
query = cls._apply_base_filter(query)
return query.filter_by(**filter_by).one_or_none()
@classmethod
@@ -184,3 +328,247 @@ class BaseDAO(Generic[T]):
for item in items:
db.session.delete(item)
@classmethod
def apply_column_operators(
cls, query: Any, column_operators: Optional[List[ColumnOperator]] = None
) -> Any:
"""
Apply column operators (list of ColumnOperator) to the query using
ColumnOperatorEnum logic. Raises ValueError if a filter references a
non-existent column.
"""
if not column_operators:
return query
for c in column_operators:
if not isinstance(c, ColumnOperator):
continue
col = c.col
opr = c.opr
value = c.value
if not col or not hasattr(cls.model_cls, col):
model_name = cls.model_cls.__name__ if cls.model_cls else "Unknown"
logging.error(
f"Invalid filter: column '{col}' does not exist on {model_name}"
)
raise ValueError(
f"Invalid filter: column '{col}' does not exist on {model_name}"
)
column = getattr(cls.model_cls, col)
try:
# Always use ColumnOperatorEnum's apply method
operator_enum = ColumnOperatorEnum(opr)
query = query.filter(operator_enum.apply(column, value))
except Exception as e:
logging.error(f"Error applying filter on column '{col}': {e}")
raise
return query
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
"""
Returns a dict mapping filterable columns (including hybrid/computed fields if
present) to their supported operators. Used by MCP tools to dynamically expose
filter options. Custom fields supported by the DAO but not present on the model
should be documented here.
"""
from sqlalchemy.ext.hybrid import hybrid_property
mapper = inspect(cls.model_cls)
columns = {c.key: c for c in mapper.columns}
# Add hybrid properties
hybrids = {
name: attr
for name, attr in vars(cls.model_cls).items()
if isinstance(attr, hybrid_property)
}
# You may add custom fields here, e.g.:
# custom_fields = {"tags": ["eq", "in_", "like"], ...}
custom_fields: Dict[str, List[str]] = {}
# Map SQLAlchemy types to supported operators
type_operator_map = {
"string": [
"eq",
"ne",
"sw",
"ew",
"in_",
"nin",
"like",
"ilike",
"is_null",
"is_not_null",
],
"boolean": ["eq", "ne", "is_null", "is_not_null"],
"number": [
"eq",
"ne",
"gt",
"gte",
"lt",
"lte",
"in_",
"nin",
"is_null",
"is_not_null",
],
"datetime": [
"eq",
"ne",
"gt",
"gte",
"lt",
"lte",
"in_",
"nin",
"is_null",
"is_not_null",
],
}
import sqlalchemy as sa
filterable = {}
for name, col in columns.items():
if isinstance(col.type, (sa.String, sa.Text)):
filterable[name] = type_operator_map["string"]
elif isinstance(col.type, (sa.Boolean,)):
filterable[name] = type_operator_map["boolean"]
elif isinstance(col.type, (sa.Integer, sa.Float, sa.Numeric)):
filterable[name] = type_operator_map["number"]
elif isinstance(col.type, (sa.DateTime, sa.Date, sa.Time)):
filterable[name] = type_operator_map["datetime"]
else:
# Fallback to eq/ne/null
filterable[name] = ["eq", "ne", "is_null", "is_not_null"]
# Add hybrid properties as string fields by default
for name in hybrids:
filterable[name] = type_operator_map["string"]
# Add custom fields
filterable.update(custom_fields)
return filterable
@classmethod
def _build_query(
cls,
column_operators: Optional[List[ColumnOperator]] = None,
search: Optional[str] = None,
search_columns: Optional[List[str]] = None,
custom_filters: Optional[Dict[str, BaseFilter]] = None,
skip_base_filter: bool = False,
data_model: Optional[SQLAInterface] = None,
) -> Any:
"""
Build a SQLAlchemy query with base filter, column operators, search, and
custom filters.
"""
if data_model is None:
data_model = SQLAInterface(cls.model_cls, db.session)
query = data_model.session.query(cls.model_cls)
query = cls._apply_base_filter(
query, skip_base_filter=skip_base_filter, data_model=data_model
)
if search and search_columns:
search_filters = []
for column_name in search_columns:
if hasattr(cls.model_cls, column_name):
column = getattr(cls.model_cls, column_name)
search_filters.append(cast(column, Text).ilike(f"%{search}%"))
if search_filters:
query = query.filter(or_(*search_filters))
if custom_filters:
for filter_class in custom_filters.values():
query = filter_class.apply(query, None)
if column_operators:
query = cls.apply_column_operators(query, column_operators)
return query
@classmethod
def list( # noqa: C901
cls,
column_operators: Optional[List[ColumnOperator]] = None,
order_column: str = "changed_on",
order_direction: str = "desc",
page: int = 0,
page_size: int = 100,
search: Optional[str] = None,
search_columns: Optional[List[str]] = None,
custom_filters: Optional[Dict[str, BaseFilter]] = None,
columns: Optional[List[str]] = None,
) -> Tuple[List[Any], int]:
"""
Generic list method for filtered, sorted, and paginated results.
If columns is specified, returns a list of tuples (one per row),
otherwise returns model instances.
"""
data_model = SQLAInterface(cls.model_cls, db.session)
column_attrs = []
relationship_loads = []
if columns is None:
columns = []
for name in columns:
attr = getattr(cls.model_cls, name, None)
if attr is None:
continue
prop = getattr(attr, "property", None)
if isinstance(prop, ColumnProperty):
column_attrs.append(attr)
elif isinstance(prop, RelationshipProperty):
relationship_loads.append(joinedload(attr))
# Ignore properties and other non-queryable attributes
if relationship_loads:
# If any relationships are requested, query the full model and joinedload
# relationships
query = data_model.session.query(cls.model_cls)
for loader in relationship_loads:
query = query.options(loader)
elif column_attrs:
# Only columns requested
query = data_model.session.query(*column_attrs)
else:
# Fallback: query the full model
query = data_model.session.query(cls.model_cls)
query = cls._apply_base_filter(query, data_model=data_model)
if search and search_columns:
search_filters = []
for column_name in search_columns:
if hasattr(cls.model_cls, column_name):
column = getattr(cls.model_cls, column_name)
search_filters.append(cast(column, Text).ilike(f"%{search}%"))
if search_filters:
query = query.filter(or_(*search_filters))
if custom_filters:
for filter_class in custom_filters.values():
query = filter_class.apply(query, None)
if column_operators:
query = cls.apply_column_operators(query, column_operators)
total_count = query.count()
if hasattr(cls.model_cls, order_column):
column = getattr(cls.model_cls, order_column)
if order_direction.lower() == "desc":
query = query.order_by(desc(column))
else:
query = query.order_by(asc(column))
page = page
page_size = max(page_size, 1)
query = query.offset(page * page_size).limit(page_size)
items = query.all()
# If columns are specified, SQLAlchemy returns Row objects (not tuples or
# model instances)
return items, total_count
@classmethod
def count(
cls,
column_operators: Optional[List[ColumnOperator]] = None,
skip_base_filter: bool = False,
) -> int:
"""
Count the number of records for the model, optionally filtered by column
operators.
"""
query = cls._build_query(
column_operators=column_operators, skip_base_filter=skip_base_filter
)
return query.count()

View File

@@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from typing import Dict, List, TYPE_CHECKING
from superset.charts.filters import ChartFilter
from superset.daos.base import BaseDAO
@@ -36,6 +36,20 @@ logger = logging.getLogger(__name__)
class ChartDAO(BaseDAO[Slice]):
base_filter = ChartFilter
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
filterable = super().get_filterable_columns_and_operators()
# Add custom fields for charts
filterable.update(
{
"tags": ["eq", "in_", "like"],
"owner": ["eq", "in_"],
"viz_type": ["eq", "in_", "like"],
"datasource_name": ["eq", "in_", "like"],
}
)
return filterable
@staticmethod
def favorited_ids(charts: list[Slice]) -> list[FavStar]:
ids = [chart.id for chart in charts]

View File

@@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from typing import Any, Dict, List
from flask import g
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -48,6 +48,20 @@ logger = logging.getLogger(__name__)
class DashboardDAO(BaseDAO[Dashboard]):
base_filter = DashboardAccessFilter
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
filterable = super().get_filterable_columns_and_operators()
# Add custom fields for dashboards
filterable.update(
{
"tags": ["eq", "in_", "like"],
"owner": ["eq", "in_"],
"published": ["eq"],
"favorite": ["eq"],
}
)
return filterable
@classmethod
def get_by_id_or_slug(cls, id_or_slug: int | str) -> Dashboard:
if is_uuid(id_or_slug):

View File

@@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from typing import Any, Dict, List
import dateutil.parser
from sqlalchemy.exc import SQLAlchemyError
@@ -37,6 +37,13 @@ logger = logging.getLogger(__name__)
class DatasetDAO(BaseDAO[SqlaTable]):
"""
DAO for datasets. Supports filtering on model fields, hybrid properties, and custom
fields:
- tags: list of tags (eq, in_, like)
- owner: user id (eq, in_)
"""
base_filter = DatasourceFilter
@staticmethod
@@ -351,6 +358,18 @@ class DatasetDAO(BaseDAO[SqlaTable]):
.one_or_none()
)
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
filterable = super().get_filterable_columns_and_operators()
# Add custom fields
filterable.update(
{
"tags": ["eq", "in_", "like"],
"owner": ["eq", "in_"],
}
)
return filterable
class DatasetColumnDAO(BaseDAO[TableColumn]):
pass

View File

@@ -38,7 +38,7 @@ def load_bart_lines(only_metadata: bool = False, force: bool = False) -> None:
if not only_metadata and (not table_exists or force):
df = read_example_data(
"bart-lines.json.gz", encoding="latin-1", compression="gzip"
"examples://bart-lines.json.gz", encoding="latin-1", compression="gzip"
)
df["path_json"] = df.path.map(json.dumps)
df["polyline"] = df.path.map(polyline.encode)

View File

@@ -57,7 +57,7 @@ def gen_filter(
def load_data(tbl_name: str, database: Database, sample: bool = False) -> None:
pdf = read_example_data("birth_names2.json.gz", compression="gzip")
pdf = read_example_data("examples://birth_names2.json.gz", compression="gzip")
# TODO(bkyryliuk): move load examples data into the pytest fixture
if database.backend == "presto":
@@ -584,8 +584,8 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
}
}"""
)
# pylint: disable=echarts_timeseries_line-too-long
pos = json.loads(
# pylint: disable=line-too-long
pos = json.loads( # noqa: TID251
textwrap.dedent(
"""\
{
@@ -859,11 +859,11 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
""" # noqa: E501
)
)
# pylint: enable=echarts_timeseries_line-too-long
# pylint: enable=line-too-long
# dashboard v2 doesn't allow add markup slice
dash.slices = [slc for slc in slices if slc.viz_type != "markup"]
update_slice_ids(pos)
dash.dashboard_title = "USA Births Names"
dash.position_json = json.dumps(pos, indent=4)
dash.position_json = json.dumps(pos, indent=4) # noqa: TID251
dash.slug = "births"
return dash

View File

@@ -1490,4 +1490,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://github.com/apache-superset/examples-data/raw/master/datasets/examples/fcc_survey_2018.csv.gz
data: examples://datasets/examples/fcc_survey_2018.csv.gz

View File

@@ -60,4 +60,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/channel_members.csv
data: examples://datasets/examples/slack/channel_members.csv

View File

@@ -360,4 +360,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/channels.csv
data: examples://datasets/examples/slack/channels.csv

View File

@@ -344,4 +344,4 @@ columns:
extra: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/sales.csv
data: examples://datasets/examples/sales.csv

View File

@@ -204,4 +204,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/covid_vaccines.csv
data: examples://datasets/examples/covid_vaccines.csv

View File

@@ -260,4 +260,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/exported_stats.csv
data: examples://datasets/examples/slack/exported_stats.csv

View File

@@ -480,4 +480,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/messages.csv
data: examples://datasets/examples/slack/messages.csv

View File

@@ -180,4 +180,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/threads.csv
data: examples://datasets/examples/slack/threads.csv

View File

@@ -90,4 +90,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/unicode_test.csv
data: examples://datasets/examples/unicode_test.csv

View File

@@ -220,4 +220,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/users.csv
data: examples://datasets/examples/slack/users.csv

View File

@@ -60,4 +60,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/users_channels.csv
data: examples://datasets/examples/slack/users_channels.csv

View File

@@ -153,4 +153,4 @@ columns:
python_date_format: null
version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: https://github.com/apache-superset/examples-data/raw/lowercase_columns_examples/datasets/examples/video_game_sales.csv
data: examples://datasets/examples/video_game_sales.csv

View File

@@ -49,7 +49,7 @@ def load_country_map_data(only_metadata: bool = False, force: bool = False) -> N
if not only_metadata and (not table_exists or force):
data = read_example_data(
"birth_france_data_for_country_map.csv", encoding="utf-8"
"examples://birth_france_data_for_country_map.csv", encoding="utf-8"
)
data["dttm"] = datetime.datetime.now().date()
data.to_sql(

View File

@@ -50,7 +50,7 @@ def load_energy(
table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force):
pdf = read_example_data("energy.json.gz", compression="gzip")
pdf = read_example_data("examples://energy.json.gz", compression="gzip")
pdf = pdf.head(100) if sample else pdf
pdf.to_sql(
tbl_name,

View File

@@ -38,12 +38,12 @@ def load_flights(only_metadata: bool = False, force: bool = False) -> None:
if not only_metadata and (not table_exists or force):
pdf = read_example_data(
"flight_data.csv.gz", encoding="latin-1", compression="gzip"
"examples://flight_data.csv.gz", encoding="latin-1", compression="gzip"
)
# Loading airports info to join and get lat/long
airports = read_example_data(
"airports.csv.gz", encoding="latin-1", compression="gzip"
"examples://airports.csv.gz", encoding="latin-1", compression="gzip"
)
airports = airports.set_index("IATA_CODE")

View File

@@ -54,6 +54,8 @@ from superset.connectors.sqla.models import SqlaTable
from superset.models.slice import Slice
from superset.utils import json
EXAMPLES_PROTOCOL = "examples://"
# ---------------------------------------------------------------------------
# Public sampledata mirror configuration
# ---------------------------------------------------------------------------
@@ -125,6 +127,20 @@ def get_example_url(filepath: str) -> str:
return f"{BASE_URL}{filepath}"
def normalize_example_data_url(url: str) -> str:
"""Convert example data URLs to use the configured CDN.
Transforms examples:// URLs to the configured CDN URL.
Non-example URLs are returned unchanged.
"""
if url.startswith(EXAMPLES_PROTOCOL):
relative_path = url[len(EXAMPLES_PROTOCOL) :]
return get_example_url(relative_path)
# Not an examples URL, return unchanged
return url
def read_example_data(
filepath: str,
max_attempts: int = 5,
@@ -132,9 +148,7 @@ def read_example_data(
**kwargs: Any,
) -> pd.DataFrame:
"""Load CSV or JSON from example data mirror with retry/backoff."""
from superset.examples.helpers import get_example_url
url = get_example_url(filepath)
url = normalize_example_data_url(filepath)
is_json = filepath.endswith(".json") or filepath.endswith(".json.gz")
for attempt in range(1, max_attempts + 1):

View File

@@ -48,7 +48,7 @@ def load_long_lat_data(only_metadata: bool = False, force: bool = False) -> None
if not only_metadata and (not table_exists or force):
pdf = read_example_data(
"san_francisco.csv.gz", encoding="utf-8", compression="gzip"
"examples://san_francisco.csv.gz", encoding="utf-8", compression="gzip"
)
start = datetime.datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0

View File

@@ -49,7 +49,7 @@ def load_multiformat_time_series( # pylint: disable=too-many-locals
if not only_metadata and (not table_exists or force):
pdf = read_example_data(
"multiformat_time_series.json.gz", compression="gzip"
"examples://multiformat_time_series.json.gz", compression="gzip"
)
# TODO(bkyryliuk): move load examples data into the pytest fixture

View File

@@ -37,7 +37,7 @@ def load_paris_iris_geojson(only_metadata: bool = False, force: bool = False) ->
table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force):
df = read_example_data("paris_iris.json.gz", compression="gzip")
df = read_example_data("examples://paris_iris.json.gz", compression="gzip")
df["features"] = df.features.map(json.dumps)
df.to_sql(

View File

@@ -46,7 +46,9 @@ def load_random_time_series_data(
table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force):
pdf = read_example_data("random_time_series.json.gz", compression="gzip")
pdf = read_example_data(
"examples://random_time_series.json.gz", compression="gzip"
)
if database.backend == "presto":
pdf.ds = pd.to_datetime(pdf.ds, unit="s")
pdf.ds = pdf.ds.dt.strftime("%Y-%m-%d %H:%M%:%S")

View File

@@ -39,7 +39,9 @@ def load_sf_population_polygons(
table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force):
df = read_example_data("sf_population.json.gz", compression="gzip")
df = read_example_data(
"examples://sf_population.json.gz", compression="gzip"
)
df["contour"] = df.contour.map(json.dumps)
df.to_sql(

View File

@@ -55,7 +55,7 @@ def load_world_bank_health_n_pop( # pylint: disable=too-many-locals
table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force):
pdf = read_example_data("countries.json.gz", compression="gzip")
pdf = read_example_data("examples://countries.json.gz", compression="gzip")
pdf.columns = [col.replace(".", "_") for col in pdf.columns]
if database.backend == "presto":
pdf.year = pd.to_datetime(pdf.year)

View File

@@ -34,6 +34,7 @@ from flask_appbuilder.utils.base import get_safe_redirect
from flask_babel import lazy_gettext as _, refresh
from flask_compress import Compress
from flask_session import Session
from sqlalchemy import inspect
from werkzeug.middleware.proxy_fix import ProxyFix
from superset.constants import CHANGE_ME_SECRET_KEY
@@ -470,6 +471,31 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
icon="fa-lock",
)
def _init_database_dependent_features(self) -> None:
"""
Initialize features that require database tables to exist.
This is called during app initialization but checks table existence
to handle cases where the app starts before database migration.
"""
inspector = inspect(db.engine)
# Check if core tables exist (use 'dashboards' as proxy for Superset tables)
if not inspector.has_table("dashboards"):
logger.debug(
"Superset tables not yet created. Skipping database-dependent "
"initialization. These features will be initialized after migration."
)
return
# Register SQLA event listeners for tagging system
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
register_sqla_event_listeners()
# Seed system themes from configuration
from superset.commands.theme.seed import SeedSystemThemesCommand
SeedSystemThemesCommand().run()
def init_app_in_ctx(self) -> None:
"""
Runs init logic in the context of the app
@@ -487,16 +513,8 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
if flask_app_mutator := self.config["FLASK_APP_MUTATOR"]:
flask_app_mutator(self.superset_app)
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
register_sqla_event_listeners()
# Seed system themes from configuration
try:
from superset.commands.theme.seed import SeedSystemThemesCommand
SeedSystemThemesCommand().run()
except Exception:
logger.exception("Failed to seed system themes")
# Initialize database-dependent features only if database is ready
self._init_database_dependent_features()
self.init_views()

View File

@@ -0,0 +1,126 @@
# Chart Generation Improvement Plan
Based on user feedback from testing the chart generation API, this document tracks improvements needed for the MCP service chart generation functionality.
## Status: Active Development
### 1. **Fix ASCII Preview Rendering** 🟡 In Progress
**Status**: Partially Complete
**Issue**: ASCII previews show "Range: nan to nan" for time series data
**Tasks:**
- [x] Fix ASCII chart rendering for time series/datetime data
- [x] Add proper NaN/null value handling in ASCII generation
- [x] Implement fallback messages when data can't be visualized
- [ ] Add unit tests for edge cases (empty data, NaN values, date formats)
### 2. **Enhance Error Messages with Context** 🟢 Complete
**Status**: Complete
**Issue**: Generic error messages without helpful context
**Tasks:**
- [x] Create detailed error response schema with:
- [x] Invalid field name
- [x] Available columns list
- [x] Fuzzy matching suggestions for typos
- [x] Data type mismatches
- [x] Implement column validation with helpful error messages
- [x] Add dataset schema introspection for better error context
### 3. **Fix Table Chart Aggregation** 🟢 Complete
**Status**: Complete
**Issue**: Table previews truncate headers, unexpected aggregation behavior
**Tasks:**
- [x] Fix column header truncation in table previews
- [x] Clarify GROUP BY behavior for non-aggregated columns
- [x] Improve table formatting with proper column width calculation
- [x] Add option to control grouping behavior explicitly
- [x] Document expected table aggregation behavior
### 4. **Fix Preview Generation Consistency** 🟢 Complete
**Status**: Complete
**Issue**: Previews not generated when `save_chart=false`
**Tasks:**
- [x] Ensure preview generation works regardless of save_chart flag
- [x] Fix the logic flow to generate previews before/after save
- [x] Add preview generation from form data for unsaved charts
- [x] Remove base64 preview support (never return base64)
- [ ] Add integration tests for all preview generation scenarios
- [ ] Validate preview_formats parameter is respected
### 5. **Implement Rich Performance Analytics** 🟢 Medium Priority
**Status**: Not Started
**Issue**: Generic performance feedback
**Tasks:**
- [ ] Add query analysis with specific optimization suggestions:
- [ ] Index recommendations based on filter columns
- [ ] Partitioning suggestions for large datasets
- [ ] Caching recommendations with specific TTL values
- [ ] Include metrics:
- [ ] Rows processed
- [ ] Bytes scanned
- [ ] Execution plan hints
- [ ] Implement cost estimation when available
### 6. **Enhance Semantic Analysis** 🟢 Medium Priority
**Status**: Not Started
**Issue**: Basic semantic responses without actual insights
**Tasks:**
- [ ] Implement statistical analysis:
- [ ] Trend detection (increasing/decreasing/stable)
- [ ] Seasonality detection
- [ ] Outlier detection with specific values
- [ ] Growth rate calculations
- [ ] Add data storytelling:
- [ ] Key insights based on actual data
- [ ] Anomaly descriptions with context
- [ ] Comparative analysis (YoY, MoM)
- [ ] Include summary statistics in response
### 7. **Additional Improvements** 🔵 Low Priority
**Status**: Not Started
- [ ] Add preview format validation
- [ ] Implement preview size constraints
- [ ] Add chart type validation against dataset characteristics
- [ ] Improve caching for preview generation
- [ ] Add preview quality options (low/medium/high)
## Implementation Timeline
### Phase 1 (Critical Fixes - Current Sprint)
1. Fix preview generation when `save_chart=false` (#4)
2. Fix ASCII preview NaN handling (#1)
3. Fix table header truncation (#3)
### Phase 2 (Error Handling - Next Sprint)
1. Implement enhanced error response schema (#2)
2. Add column validation with suggestions (#2)
3. Add comprehensive error tests (#2)
### Phase 3 (Data Quality - Sprint 3)
1. Fix table aggregation behavior (#3)
2. Implement semantic analysis engine (#6)
3. Add statistical calculations (#6)
### Phase 4 (Performance - Sprint 4)
1. Add performance analytics (#5)
2. Implement optimization suggestions (#5)
3. Add cost estimation (#5)
## Testing Requirements
- Unit tests for each component
- Integration tests for full chart generation flow
- Edge case testing (empty data, large datasets, special characters)
- Performance benchmarking
## Documentation Requirements
- API documentation with examples
- Error response catalog
- Best practices guide
- Migration guide for breaking changes
## Progress Tracking
- 🔴 Critical - Must fix immediately
- 🟡 High Priority - Fix in current release
- 🟢 Medium Priority - Plan for next release
- 🔵 Low Priority - Nice to have
Last Updated: 2025-07-30

View File

@@ -0,0 +1,150 @@
# MCP Service Demo Script for Claude Desktop
This is a safe, read-only demo script to showcase the MCP service capabilities. Run these commands in sequence in Claude Desktop.
## Prerequisites
- Ensure Superset is running locally on port 8088
- MCP service should be running on port 5008
- You should have some sample data loaded
## Demo Script
### 1. Check Instance Health
```
First, let's verify the Superset instance is running and get some basic stats:
Use the get_superset_instance_info tool
```
### 2. List Available Datasets
```
Now let's see what datasets are available:
Use the list_datasets tool with these parameters:
- page: 1
- page_size: 5
```
### 3. Get Dataset Details
```
Pick a dataset ID from the list above and get detailed information:
Use the get_dataset_info tool with:
- dataset_id: [ID from previous list]
```
### 4. List Dashboards
```
Let's explore existing dashboards:
Use the list_dashboards tool with:
- page: 1
- page_size: 5
```
### 5. Get Dashboard Details
```
Get details about a specific dashboard:
Use the get_dashboard_info tool with:
- dashboard_id: [ID from dashboard list]
```
### 6. List Charts with Filters
```
Let's see charts, filtered by a specific dataset:
Use the list_charts tool with:
- page: 1
- page_size: 5
- filters: {"datasource_id": [dataset_id from step 3]}
```
### 7. Get Chart Preview
```
Get a visual preview of a chart:
Use the get_chart_preview tool with:
- chart_id: [ID from chart list]
- format: "url"
```
### 8. Generate Explore Link
```
Create a custom explore link for data analysis:
Use the generate_explore_link tool with:
- dataset_id: [ID from step 2]
- metrics: ["COUNT(*)"]
- dimensions: ["[column_name from dataset info]"]
- time_range: "Last week"
```
### 9. Check Available Filters
```
See what filtering options are available for datasets:
Use the get_dataset_available_filters tool
```
### 10. Advanced Dataset Search
```
Search for datasets with specific criteria:
Use the list_datasets tool with:
- page: 1
- page_size: 10
- filters: {
"database_name": {"operator": "contains", "value": "examples"},
"table_name": {"operator": "contains", "value": "sales"}
}
- sort_by: "changed_on_delta_humanized"
- sort_desc: true
```
## Expected Results
Each command should return:
- ✅ Structured JSON responses with detailed information
- ✅ Preview URLs for charts (viewable in browser)
- ✅ Metadata about relationships between entities
- ✅ Human-readable timestamps and descriptions
## Safety Notes
- All operations in this demo are **read-only**
- No data is modified or created
- Preview URLs expire after cache timeout
- Filters validate column names to prevent errors
## Troubleshooting
If you get errors:
1. Verify Superset is running: `curl http://localhost:8088/health`
2. Check MCP service is running on port 5008
3. Ensure you have datasets loaded in Superset
4. Use valid IDs from the list responses
## Advanced Demo (Optional)
For a more advanced demo showing chart generation capabilities:
```
Create a simple table chart:
Use the generate_chart tool with:
- dataset_id: [valid dataset ID]
- config: {
"chart_type": "table",
"columns": [
{"name": "[column1_name]"},
{"name": "[column2_name]", "aggregate": "COUNT"}
]
}
- chart_name: "Demo Table Chart"
- save_chart: false
- generate_preview: true
- preview_formats: ["table", "url"]
```
This will generate a preview without saving the chart to the database.

View File

@@ -0,0 +1,262 @@
# Superset Entity Testing Plan
## Overview
This plan provides a systematic approach to test all parameter combinations for Superset list endpoints (`list_datasets`, `list_charts`, `list_dashboards`, etc.). Each test validates different aspects of the API functionality.
## Prerequisites
1. Access to Superset MCP Proxy tools
2. At least 10+ entities in the target category for meaningful testing
3. Knowledge of available filter fields (use `get_[entity]_available_filters` first)
## Test Execution Steps
### Step 0: Preparation
**Get Available Filters**
```
Tool: get_[entity]_available_filters
Purpose: Understand filterable fields and supported operators
```
### Step 1: Basic Default Parameters
**Objective:** Validate basic functionality with minimal parameters
```json
{}
```
**Validates:**
- Default pagination (page 1, page_size 100)
- Default ordering (usually by changed_on desc)
- Total count and basic entity structure
### Step 2: Pagination Parameters
**Objective:** Test pagination controls
```json
{"page": 2, "page_size": 5}
```
**Validates:**
- Custom page size working
- Page navigation
- Pagination metadata (total_pages, has_next, has_previous)
### Step 3: Ordering Parameters
**Objective:** Test sorting functionality
```json
{"page_size": 10, "order_column": "[sortable_field]", "order_direction": "asc"}
```
**Common sortable fields:**
- Datasets: `table_name`, `id`, `changed_on`, `created_on`
- Charts: `slice_name`, `id`, `changed_on`, `created_on`
- Dashboards: `dashboard_title`, `id`, `changed_on`, `created_on`
**Validates:**
- Custom sorting working
- Result order matches requested direction
### Step 4: Text Search
**Objective:** Test search functionality
```json
{"search": "[common_term]", "page_size": 5}
```
**Common search terms:**
- Datasets: "birth", "sales", "user"
- Charts: "revenue", "sales", "time"
- Dashboards: "dashboard", "overview"
**Validates:**
- Search filtering working
- Reduced total_count from filtering
### Step 5: Basic Filters
**Objective:** Test single filter functionality
```json
{"filters": [{"col": "[filter_field]", "opr": "eq", "value": "[filter_value]"}], "page_size": 5}
```
**Common filters:**
- Datasets: `{"col": "schema", "opr": "eq", "value": "main"}`
- Charts: `{"col": "viz_type", "opr": "eq", "value": "line"}`
- Dashboards: `{"col": "published", "opr": "eq", "value": true}`
**Validates:**
- Single filter application
- `filters_applied` metadata
- Filtered result count
### Step 6: Multiple Filters with Different Operators
**Objective:** Test multiple filters and different operators
```json
{
"filters": [
{"col": "[field1]", "opr": "sw", "value": "[prefix]"},
{"col": "[field2]", "opr": "eq", "value": "[exact_value]"}
],
"page_size": 5
}
```
**Example combinations:**
- Datasets: `table_name` starts with + `schema` equals
- Charts: `slice_name` starts with + `viz_type` equals
- Dashboards: `dashboard_title` contains + `published` equals
**Validates:**
- Multiple filter combination (AND logic)
- Different operator types working
- Complex filtering accuracy
### Step 7: Custom Column Selection
**Objective:** Test selective field retrieval
```json
{"page_size": 8, "select_columns": ["id", "[name_field]", "[key_fields]"]}
```
**Common column selections:**
- Datasets: `["id", "table_name", "database_name", "is_virtual"]`
- Charts: `["id", "slice_name", "viz_type", "datasource_name"]`
- Dashboards: `["id", "dashboard_title", "published", "slug"]`
**Validates:**
- Column selection working
- `columns_requested` vs `columns_loaded` metadata
- Response structure with limited fields
### Step 8: Cache Control Parameters
**Objective:** Test caching behavior
```json
{"page_size": 3, "use_cache": false, "force_refresh": true}
```
**Validates:**
- Cache bypass functionality
- Fresh data retrieval
- Performance impact of cache settings
### Step 9: Metadata Refresh Parameters
**Objective:** Test metadata refresh functionality
```json
{
"page_size": 4,
"order_column": "id",
"order_direction": "asc",
"refresh_metadata": true
}
```
**Validates:**
- Metadata refresh working
- Fresh schema/column information
- Impact on response completeness
### Step 10: Complex Parameter Combination
**Objective:** Test all parameter types working together
```json
{
"page": 2,
"filters": [{"col": "[field]", "opr": "like", "value": "%[pattern]%"}],
"page_size": 3,
"use_cache": false,
"order_column": "changed_on",
"force_refresh": true,
"order_direction": "desc",
"refresh_metadata": true
}
```
**Validates:**
- Complex parameter interaction
- No conflicts between parameter types
- All functionality working simultaneously
## Entity-Specific Adaptations
### For Datasets (`list_datasets`)
- **Tool:** `Superset MCP Proxy:list_datasets`
- **Filter prep:** `get_dataset_available_filters`
- **Key fields:** `table_name`, `schema`, `database_name`
- **Search terms:** Table/dataset names
- **Common filters:** `schema`, `table_name`, `owner`
### For Charts (`list_charts`)
- **Tool:** `Superset MCP Proxy:list_charts`
- **Filter prep:** `get_chart_available_filters`
- **Key fields:** `slice_name`, `viz_type`, `datasource_name`
- **Search terms:** Chart names, visualization types
- **Common filters:** `viz_type`, `slice_name`, `datasource_name`
### For Dashboards (`list_dashboards`)
- **Tool:** `Superset MCP Proxy:list_dashboards`
- **Filter prep:** `get_dashboard_available_filters`
- **Key fields:** `dashboard_title`, `published`, `slug`
- **Search terms:** Dashboard names
- **Common filters:** `published`, `dashboard_title`, `favorite`
### For New Entities
1. Identify the `list_[entity]` tool
2. Check if `get_[entity]_available_filters` exists
3. Examine initial response to understand:
- Key identifying fields
- Available sortable columns
- Common filterable fields
- Typical data patterns
4. Adapt test values accordingly
## Validation Checklist
For each test, verify:
-**Response Structure:** Proper JSON with expected fields
-**Status:** No errors returned
-**Data Integrity:** Results match expected parameters
-**Metadata:** Pagination, filtering, and sorting metadata accurate
-**Count Consistency:** `count` matches actual results returned
-**Pagination Logic:** Page boundaries and navigation work correctly
## Common Issues to Watch For
1. **Empty Results:** Page 2+ with small result sets
2. **Filter Mismatches:** Case sensitivity in string filters
3. **Column Selection:** Some fields may not populate as expected
4. **Cache Behavior:** Performance differences with cache settings
5. **Operator Support:** Not all operators work with all field types
## Automation Considerations
This plan can be automated by:
1. Creating parameterized test functions
2. Building entity-specific configuration objects
3. Implementing validation assertion helpers
4. Adding performance timing measurements
5. Generating test reports with pass/fail status
## Example Execution Flow
```
1. Run get_[entity]_available_filters
2. Execute Steps 1-10 sequentially
3. Wait for "next" confirmation between steps
4. Document any unexpected behaviors
5. Verify all parameter combinations work
6. Generate summary report
```
This plan ensures comprehensive testing of all Superset list endpoint functionality while being adaptable to any current or future entity type.
## Improvements and Enhancements
### Suggested Improvements
1. **Performance Testing:** Add response time measurements for cache vs non-cache scenarios
2. **Edge Case Testing:** Test with extreme values (very large page_size, invalid dates, etc.)
3. **Error Handling Testing:** Test invalid parameters to verify proper error responses
4. **Data Quality Testing:** Verify data consistency across different parameter combinations
5. **Concurrent Testing:** Test multiple simultaneous requests to check for race conditions
6. **Memory Usage Testing:** Monitor memory consumption with large result sets
7. **Backward Compatibility:** Test with legacy parameter formats if applicable
### Implementation Suggestions
1. **Test Configuration Files:** Create JSON configs for each entity type with common test values
2. **Result Comparison:** Add utilities to compare results across different parameter combinations
3. **Regression Testing:** Save baseline results to detect unexpected changes
4. **Visual Reports:** Generate HTML reports with pass/fail status and performance metrics
5. **CI Integration:** Automate this test plan as part of continuous integration
### Advanced Testing Scenarios
1. **Load Testing:** Test with hundreds of concurrent requests
2. **Data Volume Testing:** Test with databases containing millions of records
3. **Network Failure Testing:** Test behavior with intermittent network issues
4. **Permission Testing:** Test with different user roles and permissions
5. **Multi-tenant Testing:** Test across different organization contexts

View File

@@ -0,0 +1,941 @@
# MCP Chart Tools Test Plan
This document provides a comprehensive test plan for testing the MCP chart tools with Claude Desktop.
## Important Test Instructions
### 🔗 **ALWAYS SHOW URLs**
When any tool returns a URL (e.g., `url`, `preview_url`, `explore_url`), **always display the complete URL** in your response. For example:
- "Chart created successfully! View it at: http://localhost:8088/explore/?slice_id=123"
- "Preview URL: http://localhost:8088/superset/slice/123/"
### 🖼️ **EMBED IMAGES WHEN POSSIBLE**
When testing preview tools:
1. For `format: "url"` - Display the preview URL and attempt to embed the image
2. For `format: "base64"` - Decode and display the image inline
3. For `format: "ascii"` or `format: "table"` - Display the text representation in a code block
## Prerequisites
1. Ensure Superset is running locally on http://localhost:8088
2. Have at least one dataset available (e.g., "examples.births_2008" or any dataset ID)
3. Have some existing charts in your Superset instance
4. Verify MCP service is running on port 5008
## Important Schema Notes
- **Filter operator field**: Use `op` not `operator` in filter objects
- **Data format**: Use `excel` not `xlsx` for Excel export
- **Preview formats**: Only `url`, `ascii`, and `table` are supported (NOT `base64`, `interactive`, or `vega_lite`)
- **Column selection**: The `url` field is not in default columns - must be explicitly requested
- **Sort parameters**: Use `order_column` and `order_direction`, not `sort_columns`
## Test Coverage Overview
| Tool | Basic | Advanced | Error Cases | Performance |
|------|-------|----------|-------------|-------------|
| list_charts | ✓ | ✓ | ✓ | ✓ |
| get_chart_info | ✓ | ✓ | ✓ | ✓ |
| get_chart_available_filters | ✓ | ✓ | ✓ | - |
| generate_chart | ✓ | ✓ | ✓ | ✓ |
| update_chart | ✓ | ✓ | ✓ | - |
| update_chart_preview | ✓ | ✓ | ✓ | - |
| get_chart_data | ✓ | ✓ | ✓ | ✓ |
| get_chart_preview | ✓ | ✓ | ✓ | ✓ |
## 1. Test list_charts
### Basic Listing
```
Test: List all charts with default pagination
Expected: Returns first 20 charts with metadata including URLs
Action: Display the URL for at least one chart
```
### Pagination Tests
```
Test: List charts with page=2, page_size=5
Expected: Returns charts 6-10
Test: List charts with page_size=50
Expected: Returns up to 50 charts on first page
Test: List with cache control use_cache=false
Expected: Fresh data with cache_status showing cache_hit=false
```
### Search Tests
```
Test: Search for charts with search="sales"
Expected: Returns charts with "sales" in name or description
Test: Search with UUID/slug search="abc-123-def"
Expected: Searches across UUID and slug fields
Test: Search with no results search="xyz123nonexistent"
Expected: Returns empty list with count=0
```
### Filter Tests
```
Test: Filter by viz_type with filters=[{"col": "viz_type", "opr": "eq", "value": "table"}]
Expected: Returns only table charts
Test: Filter by multiple conditions
filters=[
{"col": "viz_type", "opr": "eq", "value": "line"},
{"col": "datasource_name", "opr": "sw", "value": "births"}
]
Expected: Returns line charts from births dataset
Test: Filter with IN operator
filters=[{"col": "viz_type", "opr": "in", "value": ["line", "bar", "area"]}]
Expected: Returns charts matching any of the specified types
```
### Column Selection
```
Test: Select specific columns with select_columns=["id", "slice_name", "viz_type", "url"]
Expected: Returns only requested fields - DISPLAY THE URL
Test: Include UUID with select_columns=["id", "slice_name", "uuid", "url"]
Expected: Returns charts with UUID field populated - DISPLAY THE URL
```
### Sort Options
```
Test: Sort by name ascending sort_columns=[{"col": "slice_name", "order": "asc"}]
Expected: Charts ordered alphabetically
Test: Sort by updated date sort_columns=[{"col": "changed_on", "order": "desc"}]
Expected: Most recently updated charts first
```
## 2. Test get_chart_info
### Valid Chart Lookup
```
Test: Get info for existing chart by numeric ID (e.g., 1)
Expected: Returns full chart details including form_data and URLs
Action: DISPLAY the chart URL
Test: Get info for chart by UUID (if you have one)
Expected: Returns same chart info using UUID identifier
Action: DISPLAY the chart URL
```
### Error Cases
```
Test: Get info for non-existent chart ID 99999
Expected: Returns error with type "NotFound"
Test: Get info with invalid identifier "not-a-valid-id"
Expected: Returns appropriate validation error
```
## 3. Test get_chart_available_filters
### Basic Filter Discovery
```
Test: Get available filters for a chart
Request: {"identifier": 1}
Expected: Returns filterable columns with operators and current values
```
### With Current Filters
```
Test: See interaction with existing filters
Request: {"identifier": 1, "include_filter_values": true}
Expected: Shows columns, operators, and any applied filter values
```
## 4. Test generate_chart
### Table Chart Generation
#### Basic Table
```
Test: Generate simple table chart
Request:
{
"dataset_id": 1, // Use your dataset ID
"config": {
"chart_type": "table",
"columns": [
{"name": "region", "label": "Region"},
{"name": "sales", "label": "Sales"}
]
}
}
Expected: Creates table chart with selected columns
Action: DISPLAY THE CHART URL from response
```
#### Table with Aggregation
```
Test: Generate table with aggregated metrics
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [
{"name": "region", "label": "Region"},
{"name": "sales", "label": "Total Sales", "aggregate": "SUM"},
{"name": "quantity", "label": "Avg Quantity", "aggregate": "AVG"}
],
"filters": [{"column": "year", "operator": "==", "value": 2024}],
"time_range": "Last quarter"
}
}
Expected: Creates table with aggregated data, filtered and time-scoped
Action: DISPLAY THE CHART URL
```
#### Table with All Options
```
Test: Comprehensive table configuration
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [
{"name": "date", "label": "Date"},
{"name": "category", "label": "Category"},
{"name": "sales", "label": "Sales", "aggregate": "SUM"},
{"name": "profit", "label": "Profit %", "aggregate": "AVG"}
],
"filters": [
{"column": "region", "operator": "IN", "value": ["East", "West"]},
{"column": "sales", "operator": ">", "value": 1000}
],
"order_by": [
{"column": "sales", "desc": true}
],
"row_limit": 100,
"show_totals": true,
"conditional_formatting": [
{
"column": "profit",
"operator": "<",
"value": 0,
"color": "#FF0000"
}
]
},
"save_chart": true,
"slice_name": "Regional Sales Analysis"
}
Expected: Creates fully configured table
Action: DISPLAY THE CHART URL
```
### Line Chart Generation
#### Time Series Line Chart
```
Test: Generate time series line chart
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "line",
"x": {"name": "date"},
"y": [{"name": "sales", "aggregate": "SUM"}],
"time_grain": "P1D",
"time_range": "Last 30 days"
}
}
Expected: Creates line chart with daily granularity
Action: DISPLAY THE CHART URL
```
#### Multi-Metric Line Chart
```
Test: Generate chart with multiple metrics
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "line",
"x": {"name": "date"},
"y": [
{"name": "sales", "aggregate": "SUM", "label": "Total Sales"},
{"name": "profit", "aggregate": "SUM", "label": "Total Profit"},
{"name": "orders", "aggregate": "COUNT", "label": "Order Count"}
],
"group_by": ["region"],
"show_legend": true,
"y_axis_format": ",.0f"
}
}
Expected: Creates multi-line chart with grouping
Action: DISPLAY THE CHART URL
```
### Bar Chart Generation
#### Simple Bar Chart
```
Test: Generate bar chart
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "bar",
"x": {"name": "category"},
"y": [{"name": "sales", "aggregate": "SUM"}]
}
}
Expected: Creates vertical bar chart
Action: DISPLAY THE CHART URL
```
#### Stacked Bar Chart
```
Test: Generate stacked bar chart
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "bar",
"x": {"name": "month"},
"y": [{"name": "sales", "aggregate": "SUM"}],
"group_by": ["product_line"],
"stack": true,
"show_values": true
}
}
Expected: Creates stacked bar chart with values
Action: DISPLAY THE CHART URL
```
### Area Chart Generation
```
Test: Generate area chart
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "area",
"x": {"name": "date"},
"y": [{"name": "revenue", "aggregate": "SUM"}],
"group_by": ["segment"],
"opacity": 0.7,
"show_brush": true
}
}
Expected: Creates area chart with brush selection
Action: DISPLAY THE CHART URL
```
### Scatter Plot Generation
```
Test: Generate scatter plot
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "scatter",
"x": {"name": "price", "label": "Price"},
"y": [{"name": "quantity", "label": "Quantity Sold"}],
"size": {"name": "profit", "aggregate": "SUM"},
"color": {"name": "category"},
"max_bubble_size": 50
}
}
Expected: Creates scatter plot (limited to 50 data points in ASCII preview)
Action: DISPLAY THE CHART URL
```
### Preview Without Saving
```
Test: Generate chart without saving (preview only)
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [{"name": "region"}, {"name": "sales", "aggregate": "SUM"}]
},
"save_chart": false
}
Expected: Returns preview data without saving
Action: Note that no permanent URL is created
```
### Error Cases
#### Invalid Dataset
```
Test: Generate chart with non-existent dataset
Request:
{
"dataset_id": 99999,
"config": {
"chart_type": "table",
"columns": [{"name": "col1"}]
}
}
Expected: Returns error with type "DatasetNotFound"
```
#### Invalid Column
```
Test: Generate chart with non-existent column
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [{"name": "nonexistent_column"}]
}
}
Expected: Returns validation error with column suggestions
Action: Note the suggested column names for next test
```
#### Invalid Aggregation
```
Test: Use SUM on text column
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [
{"name": "region", "aggregate": "SUM"} // Text column with numeric aggregate
]
}
}
Expected: Returns validation error about aggregate type mismatch
```
#### Missing Required Fields
```
Test: Generate chart without required x axis
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "line",
"y": [{"name": "sales", "aggregate": "SUM"}]
// Missing x field
}
}
Expected: Returns validation error about missing x axis
```
## 5. Test update_chart
### Basic Update
```
Test: Update chart name and description
Request:
{
"identifier": 1, // Use existing chart ID
"updates": {
"slice_name": "Updated Chart Name",
"description": "This chart has been updated via MCP"
}
}
Expected: Updates chart metadata
Action: DISPLAY THE UPDATED CHART URL
```
### Update Visualization
```
Test: Change chart type from bar to line
Request:
{
"identifier": 1,
"updates": {
"viz_type": "line",
"params": {
"viz_type": "line",
"line_interpolation": "smooth"
}
}
}
Expected: Changes chart visualization type
Action: DISPLAY THE URL to see the change
```
### Update with Cache Refresh
```
Test: Update and force cache refresh
Request:
{
"identifier": 1,
"updates": {
"slice_name": "Fresh Data Chart"
},
"force_refresh": true
}
Expected: Updates chart and refreshes cache
```
## 6. Test update_chart_preview
### Update Existing Preview
```
Test: Refresh chart preview
Request:
{
"identifier": 1,
"force_refresh": true
}
Expected: Regenerates preview with fresh data
Action: Note cache_status in response
```
### Update Preview Format
```
Test: Change preview settings
Request:
{
"identifier": 1,
"width": 1200,
"height": 800,
"force_refresh": true
}
Expected: Updates preview with new dimensions
```
## 7. Test get_chart_preview
### URL Preview (Screenshot)
```
Test: Get chart preview as URL
Request:
{
"identifier": 1, // Use existing chart ID
"format": "url",
"width": 800,
"height": 600
}
Expected: Returns preview_url
Action: DISPLAY THE PREVIEW URL and attempt to embed the image:
![Chart Preview](returned_preview_url_here)
```
### Base64 Preview
```
Test: Get chart as base64 image
Request:
{
"identifier": 1,
"format": "base64"
}
Expected: Returns base64 encoded image
Action: Display decoded image inline if possible
```
### ASCII Preview
```
Test: Get chart as ASCII art
Request:
{
"identifier": 1,
"format": "ascii",
"ascii_width": 80,
"ascii_height": 20
}
Expected: Returns ASCII representation (limited to 50 rows)
Action: Display in a code block:
```
[ASCII art will appear here]
```
### Table Preview
```
Test: Get chart data as formatted table
Request:
{
"identifier": 1,
"format": "table",
"max_rows": 10
}
Expected: Returns tabular data (limited to 20 rows)
Action: Display the table in a formatted code block
```
### Cache Control in Preview
```
Test: Force fresh preview
Request:
{
"identifier": 1,
"format": "url",
"force_refresh": true,
"cache_timeout": 0
}
Expected: Returns fresh preview with cache_hit=false
Action: DISPLAY THE PREVIEW URL
```
### Error Cases
```
Test: Get preview for non-existent chart
Request:
{
"identifier": 99999,
"format": "url"
}
Expected: Returns error with type "NotFound"
Test: Invalid format
Request:
{
"identifier": 1,
"format": "invalid_format"
}
Expected: Returns error "Unsupported preview format: invalid_format"
Test: Unsupported format (base64)
Request:
{
"identifier": 1,
"format": "base64"
}
Expected: Returns error "Unsupported preview format: base64"
```
## 8. Test get_chart_data
### Basic Data Retrieval
```
Test: Get data for existing chart
Request:
{
"identifier": 1,
"format": "json",
"limit": 100
}
Expected: Returns chart data with metadata
Action: Display sample of data and note total_rows
```
### CSV Export
```
Test: Get data as CSV
Request:
{
"identifier": 1,
"format": "csv"
}
Expected: Returns CSV formatted data
Action: Display first few lines of CSV
```
### Excel Export
```
Test: Get data as Excel
Request:
{
"identifier": 1,
"format": "excel" // Note: use "excel" not "xlsx"
}
Expected: Returns base64 encoded Excel file
Action: Note that Excel file was generated
```
### With Additional Processing
```
Test: Get data with insights
Request:
{
"identifier": 1,
"format": "json",
"include_column_metadata": true,
"generate_insights": true,
"limit": 50
}
Expected: Returns data with column analysis and insights
Action: Display the insights and column metadata
```
### Cache Control
```
Test: Force fresh data
Request:
{
"identifier": 1,
"format": "json",
"force_refresh": true,
"use_cache": false
}
Expected: Returns fresh data with cache_hit=false
Action: Note the cache_status details
```
### Big Number Chart Handling
```
Test: Get data for big_number chart type
Request:
{
"identifier": [ID of a big_number chart],
"format": "json"
}
Expected: Should handle appropriately or return specific error
```
## 9. Integration Test Scenarios
### Complete Chart Lifecycle
```
1. Generate a new chart with save_chart=true
- DISPLAY THE CHART URL
2. Use returned chart_id to get_chart_info
- Verify all details match
3. Update the chart with update_chart
- DISPLAY THE UPDATED URL
4. Get preview in multiple formats
- DISPLAY URL preview and embed image
- Show ASCII preview in code block
5. Get chart data in JSON and CSV formats
- Display sample data
6. Update chart preview with new dimensions
- DISPLAY new preview URL
```
### Error Recovery Flow
```
1. Try to generate chart with invalid column
- Note the error and suggestions
2. Use list_datasets to find correct dataset
3. Use get_dataset_info to see columns
4. Generate chart with correct column names
- DISPLAY THE SUCCESSFUL CHART URL
```
### Cache Testing Flow
```
1. Get chart data with use_cache=true
- Note cache_hit status
2. Get same data again
- Verify cache_hit=true
3. Get data with force_refresh=true
- Verify cache_hit=false
4. Check cache_age_seconds values
```
### Multi-Format Export
```
1. Create a complex chart with multiple metrics
- DISPLAY THE CHART URL
2. Export as:
- JSON (display sample)
- CSV (display headers)
- Excel (note generation)
3. Get preview as:
- URL (embed image)
- ASCII (show in code block)
- Table (display formatted)
```
## 10. Performance and Load Tests
### Large Dataset Handling
```
Test: Generate chart with row_limit=10000
Expected: Handles gracefully, returns data or appropriate limit
Test: Get data with limit=50000
Expected: Returns data or indicates maximum allowed
```
### Concurrent Operations
```
Test: Generate 5 charts rapidly in sequence
Expected: All succeed without conflicts
Action: DISPLAY ALL CHART URLs
```
### Complex Aggregations
```
Test: Chart with multiple groupings and aggregations
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [
{"name": "region"},
{"name": "category"},
{"name": "sales", "aggregate": "SUM"},
{"name": "sales", "aggregate": "AVG", "label": "Avg Sale"},
{"name": "sales", "aggregate": "MAX", "label": "Max Sale"},
{"name": "sales", "aggregate": "MIN", "label": "Min Sale"},
{"name": "sales", "aggregate": "COUNT", "label": "Sale Count"}
],
"order_by": [{"column": "sales", "desc": true}],
"row_limit": 500
}
}
Expected: Handles complex aggregations efficiently
Action: DISPLAY THE CHART URL
```
## 11. Special Cases and Edge Cases
### Unicode and Special Characters
```
Test: Chart with unicode in name
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [{"name": "region"}]
},
"slice_name": "Sales 销售 🌏 Report"
}
Expected: Handles unicode correctly
Action: DISPLAY THE CHART URL with unicode name
```
### Very Long Names
```
Test: Chart with very long name
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [{"name": "region"}]
},
"slice_name": "This is a very long chart name that exceeds typical length limits and should be handled gracefully by the system without causing any errors or truncation issues"
}
Expected: Handles or truncates appropriately
```
### SQL Injection Prevention
```
Test: Attempt SQL injection in filter
Request:
{
"dataset_id": 1,
"config": {
"chart_type": "table",
"columns": [{"name": "region"}],
"filters": [{"column": "region", "operator": "==", "value": "'; DROP TABLE users; --"}]
}
}
Expected: Safely handles without executing SQL
```
## Expected Response Patterns
### Successful Chart Creation
```json
{
"chart": {
"id": 123,
"slice_name": "My Chart",
"viz_type": "table",
"url": "http://localhost:8088/explore/?slice_id=123",
"uuid": "abc-123-def",
"saved": true
},
"success": true
}
```
**Action: ALWAYS DISPLAY THE URL**
### Successful Preview
```json
{
"chart_id": 123,
"format": "url",
"content": {
"type": "url",
"preview_url": "http://localhost:8088/api/v1/chart/123/screenshot/...",
"expires_at": "2024-01-01T12:00:00Z"
}
}
```
**Action: DISPLAY AND EMBED THE preview_url**
### Validation Error
```json
{
"error": "validation_error",
"message": "Chart configuration validation failed",
"validation_errors": [
{
"field": "columns[0]",
"error_type": "column_not_found",
"message": "Column 'nonexistent' not found",
"suggestions": ["region", "sales", "profit"]
}
]
}
```
### Data Response with Cache Info
```json
{
"chart_id": 123,
"data": [...],
"row_count": 100,
"total_rows": 5000,
"cache_status": {
"cache_hit": true,
"cache_type": "query",
"cache_age_seconds": 300
},
"insights": ["Data served from cache", "Large dataset - consider filtering"]
}
```
## Test Execution Checklist
- [ ] Environment setup verified
- [ ] Basic CRUD operations tested
- [ ] All chart types tested
- [ ] Error handling verified
- [ ] Cache behavior confirmed
- [ ] Preview formats working
- [ ] URLs displayed for all operations
- [ ] Images embedded where possible
- [ ] Performance acceptable
- [ ] Edge cases handled
## Debugging Tips
1. **Always display returned URLs** - They're crucial for verification
2. **For image previews** - Try to embed using markdown: `![Preview](url)`
3. **For errors** - Show the complete error response
4. **For data** - Show a representative sample, not everything
5. **Check cache_status** - Helps understand performance
6. **Save successful IDs** - Reuse for subsequent tests
7. **Note patterns** - Errors often reveal API patterns
## Summary Report Template
After running tests, summarize:
```
Test Summary for MCP Chart Tools
================================
Total Tests Run: X
Passed: X
Failed: X
Working Features:
- ✅ Feature 1 (with URL: ...)
- ✅ Feature 2 (with preview: ...)
Issues Found:
- ❌ Issue 1: Description
- ❌ Issue 2: Description
Performance Notes:
- Average response time: Xs
- Cache hit rate: X%
Recommendations:
- ...
```

View File

@@ -0,0 +1,688 @@
# Superset MCP Service
The Superset Model Context Protocol (MCP) service provides a modular, schema-driven interface for programmatic access to Superset dashboards, charts, datasets, and instance metadata. It is designed for LLM agents and automation tools, and is built on the FastMCP protocol.
**✅ Phase 1 Complete. Core functionality stable, authentication production-ready, comprehensive testing coverage.**
## 🚀 Quickstart
### 1. Install Superset Locally
```bash
# Clone the repository
git clone https://github.com/apache/superset.git
cd superset
# Create virtual environment and install (Python 3.10 or 3.11 required)
make venv
source venv/bin/activate
make install
# Start Superset (in a separate terminal)
source venv/bin/activate
superset run -p 8088 --with-threads --reload --debugger
```
For alternative installation methods, see the [official Superset development guide](https://superset.apache.org/docs/contributing/development).
### 2. Run the MCP Service
The MCP service runs as an HTTP server (not stdout) and requires a proxy for Claude Desktop:
```bash
# In a new terminal, with your virtual environment activated
source venv/bin/activate # if using make venv
# OR
# pyenv activate superset-mcp # if using pyenv
# Run the MCP service
superset mcp run --port 5008 --debug
```
The service will start on http://localhost:5008
### 3. Connect to Claude Desktop
Since the MCP service runs on HTTP (not stdout), you need to use the FastMCP proxy:
**Step 1: Configure the existing proxy script**
The proxy script `superset/mcp_service/run_proxy.sh` is already provided. Update the paths in it if needed for your environment.
**Step 2: Configure Claude Desktop**
Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
```json
{
"mcpServers": {
"Superset MCP Proxy": {
"command": "/path/to/your/superset/superset/mcp_service/run_proxy.sh",
"args": [],
"env": {}
}
}
}
```
**Step 3: Restart Claude Desktop**
- Quit Claude Desktop completely
- Start it again
- The Superset MCP tools should now be available
### 4. Install Browser Dependencies (Optional - for chart screenshots)
The chart preview functionality requires Firefox and geckodriver. See the [Superset documentation](https://superset.apache.org/docs/contributing/development) for installation instructions.
**Quick install on macOS:**
```bash
brew install --cask firefox
brew install geckodriver
```
### 5. Verify Your Setup
**Check that Superset is running:**
```bash
curl http://localhost:8088/health
# Should return {"status": "OK"}
```
**Check that MCP service is running:**
```bash
# Check if the MCP service port is listening
lsof -i :5008
# Should show the superset mcp process listening on port 5008
# Or check the process directly
ps aux | grep "superset mcp"
```
**Test in Claude Desktop:**
- Ask Claude to "list dashboards" or "get superset instance info"
- Claude should be able to use the MCP tools to query your Superset instance
### 6. Run Tests (Optional)
Run the unit tests to verify your environment:
```bash
# Unit tests
pytest tests/unit_tests/mcp_service/ --maxfail=1 -v
```
## Troubleshooting
**If Claude Desktop can't connect:**
1. Ensure both Superset (port 8088) and MCP service (port 5008) are running
2. Check the proxy script has the correct path to your virtual environment
3. Look at Claude Desktop logs: `tail -f ~/Library/Logs/Claude/mcp-server-Superset MCP Proxy.log`
4. Verify the proxy works manually: `./run_proxy.sh` (should show MCP protocol messages)
**If screenshots don't work:**
1. Ensure Firefox and geckodriver are installed and in PATH
2. Check `which geckodriver` returns a valid path
3. Try running Firefox manually to ensure it works
## Available Tools
**16 MCP tools** with Pydantic v2 schemas and comprehensive field documentation for LLM compatibility.
### 📊 Dashboard Tools (5)
- **`list_dashboards`** - List with search/filters/pagination, UUID/slug support
- **`get_dashboard_info`** - Get by ID/UUID/slug with metadata
- **`get_dashboard_available_filters`** - Discover filterable columns
- **`generate_dashboard`** - Create dashboards with multiple charts
- **`add_chart_to_existing_dashboard`** - Add charts to existing dashboards
### 📈 Chart Tools (8)
- **`list_charts`** - List with search/filters/pagination, UUID support
- **`get_chart_info`** - Get by ID/UUID with full metadata
- **`get_chart_available_filters`** - Discover filterable columns
- **`generate_chart`** - Create charts (table, line, bar, area, scatter)
- **`update_chart`** - Update existing saved charts
- **`update_chart_preview`** - Update cached chart previews
- **`get_chart_data`** - Export data (JSON/CSV/Excel)
- **`get_chart_preview`** - Screenshots, ASCII art, table previews
### 🗂️ Dataset Tools (3)
- **`list_datasets`** - List with columns/metrics, UUID support
- **`get_dataset_info`** - Get by ID/UUID with columns/metrics metadata
- **`get_dataset_available_filters`** - Discover filterable columns
### 🖥️ System Tools (2)
- **`get_superset_instance_info`** - Instance statistics and version info
- **`generate_explore_link`** - Generate chart exploration URLs
### 🧪 SQL Lab Tools (1)
- **`open_sql_lab_with_context`** - Pre-configured SQL Lab sessions
## Available Operations
### ✅ Read Operations (All entities)
- **List**: Paginated lists with filtering, search, and UUID/slug support
- **Get Info**: Detailed information by ID, UUID, or slug
- **Get Filters**: Discover available filter columns and operators
- **Get Data**: Export chart data in multiple formats
- **Get Previews**: Chart screenshots, ASCII art, and table representations
### ✅ Create Operations
- **Charts**: Create charts with 5 visualization types (table, line, bar, area, scatter)
- **Dashboards**: Generate dashboards with multiple charts and automatic layout
- **Add to Dashboard**: Add existing charts to dashboards with smart positioning
### ✅ Update Operations
- **Charts**: Update saved charts and cached chart previews
- **Navigation**: Generate explore links and SQL Lab sessions
### ❌ Not Available (Future phases)
- **Update/Delete**: Dashboard and dataset modifications
- **SQL Execution**: Query execution in SQL Lab (opens sessions only)
## 📖 Complete Documentation
The MCP service is fully documented on the **[official Superset documentation site](https://superset.apache.org/docs/mcp-service/intro)**:
### Quick Access
- **[🚀 MCP Service Overview](https://superset.apache.org/docs/mcp-service/intro)** - Complete introduction and features
- **[📚 API Reference](https://superset.apache.org/docs/mcp-service/api-reference)** - All 16 tools with examples
- **[🔧 Development Guide](https://superset.apache.org/docs/mcp-service/development)** - Adding new tools and architecture
- **[🔐 Authentication](https://superset.apache.org/docs/mcp-service/authentication)** - Production security setup
### By Role
**👩‍💻 Developers & Integrators:** [Overview](https://superset.apache.org/docs/mcp-service/overview) → [API Reference](https://superset.apache.org/docs/mcp-service/api-reference) → [Development Guide](https://superset.apache.org/docs/mcp-service/development)
**🔒 DevOps & Production:** [Authentication](https://superset.apache.org/docs/mcp-service/authentication) → [Architecture](https://superset.apache.org/docs/mcp-service/architecture)
**🏢 Enterprise Teams:** [Preset Integration](https://superset.apache.org/docs/mcp-service/preset-integration)
> 💡 **Local Development?** See the [local docs folder](./docs/) for markdown versions during development.
## Enhanced Parameter Handling
All MCP tools now use the **FastMCP Complex Inputs Pattern** to eliminate LLM parameter validation issues:
### Request Schema Pattern
Instead of individual parameters, tools use structured request objects:
```python
# New approach (current)
get_dataset_info(request={"identifier": 123}) # ID
get_dataset_info(request={"identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}) # UUID
# Old approach (replaced)
get_dataset_info(dataset_id=123)
```
### Multi-Identifier Support
All `get_*_info` tools now support multiple identifier types:
- **Datasets/Charts**: ID (numeric) or UUID (string)
- **Dashboards**: ID (numeric), UUID (string), or slug (string)
### Filtering & Search
All `list_*` tools support:
- **Filters**: Structured filter objects with validation to prevent conflicts
- **Search**: Free-text search across key fields (including UUID and slug)
- **Validation**: Cannot use both `search` and `filters` simultaneously
Example:
```python
# Using request schema with filters
list_dashboards(request={
"search": "sales",
"page": 1,
"page_size": 20
})
# Or with filters (but not both)
list_dashboards(request={
"filters": [{"col": "published", "opr": "eq", "value": True}],
"page": 1,
"page_size": 20
})
```
## Chart Creation
The `generate_chart` tool supports chart creation with:
### Supported Chart Types
- **Table charts** — Simple column display with filters and sorting
- **Line charts** — Time series line charts
- **Bar charts** — Time series bar charts
- **Area charts** — Time series area charts
- **Scatter charts** — Time series scatter charts
### Chart Creation
The tool creates and saves permanent charts in Superset with automatically generated explore URLs.
### Intelligent Metric Handling
The tool automatically handles two metric formats:
1. **Simple metrics** (like `["count"]`) — Passed as simple strings
2. **Complex metrics** (like column names) — Converted to full Superset metric objects with SQL aggregators (SUM, COUNT, AVG, MIN, MAX)
### Example Usage
```python
# Create a line chart with SQL aggregators
config = XYChartConfig(
chart_type="xy",
x=ColumnRef(name="date"),
y=[
ColumnRef(name="sales", aggregate="SUM", label="Total Sales"),
ColumnRef(name="orders", aggregate="COUNT", label="Order Count")
],
kind="line"
)
request = GenerateChartRequest(dataset_id="1", config=config)
# Create a table chart
table_config = TableChartConfig(
chart_type="table",
columns=[
ColumnRef(name="region", label="Region"),
ColumnRef(name="sales", label="Sales")
]
)
table_request = GenerateChartRequest(dataset_id="1", config=table_config)
```
## Dashboard Generation & Management
New dashboard management tools provide dashboard creation and chart addition capabilities:
### Dashboard Creation
```python
# Generate a dashboard with multiple charts
generate_dashboard(request={
"chart_ids": [1, 2, 3, 4],
"dashboard_title": "Sales Analytics Dashboard",
"description": "Sales performance metrics dashboard",
"published": True
})
```
### Chart Addition to Existing Dashboards
```python
# Add a chart to an existing dashboard
add_chart_to_existing_dashboard(request={
"dashboard_id": 123,
"chart_id": 456,
"target_tab": "Overview" # Optional
})
```
## SQL Lab Integration
Direct integration with Superset's SQL Lab for seamless development workflows:
```python
# Open SQL Lab with context
open_sql_lab_with_context(request={
"database_connection_id": 1,
"schema": "public",
"dataset_in_context": "sales_data",
"sql": "SELECT * FROM sales_data WHERE region = 'US'",
"title": "US Sales Analysis"
})
```
**Features:**
- Pre-selected database and schema
- Contextual SQL templates
- Dataset-aware query generation
- Proper URL parameter handling (`dbid` for compatibility)
## Chart Data & Preview System
Advanced chart preview and data extraction capabilities:
### Chart Data Retrieval
```python
# Get chart data in multiple formats
get_chart_data(request={
"identifier": "chart-uuid-or-id",
"format": "json", # json, csv, excel
"row_count": 1000,
"row_offset": 0
})
```
### Chart Preview Generation
```python
# Generate chart previews
get_chart_preview(request={
"identifier": "chart-uuid-or-id",
"format": "url", # url, base64, ascii, table
"width": 800,
"height": 600
})
```
**Preview Formats:**
- **URL**: Screenshot URLs served by MCP service
- **Base64**: Embedded image data for direct display
- **ASCII**: Text-based charts for terminal/chat display
- **Table**: Structured data representation
## Modular Structure & Best Practices
- Tools are organized by domain: `dashboard/`, `dataset/`, `chart/`, `system/`.
- All input/output is validated with Pydantic v2.
- Shared schemas live in `schemas/`.
- All tool calls are logged and RBAC/auth hooks are pluggable.
- **All tool functions must be decorated with `@mcp.tool` and `@mcp_auth_hook`.**
- **All Superset DAOs, command classes, and most Superset modules must be imported inside the function body, not at the top of the file.** This ensures proper app context and avoids initialization errors.
## Current Status
### ✅ Phase 1 Complete
- **FastMCP Server**: CLI with `superset mcp run`, HTTP service on port 5008
- **Authentication**: Production-ready JWT Bearer with configurable factory pattern
- **16 Core Tools**: All list/info/filter tools, chart creation, dashboard generation
- **Request Schema Pattern**: Eliminates LLM parameter validation issues
- **Cache Control**: Comprehensive control over Superset's existing cache layers
- **Audit Logging**: MCP context tracking with impersonation and payload sanitization
- **Testing**: 194+ unit tests with full pre-commit compliance
### 🎯 Future Enhancements
- Demo notebooks and interactive examples
- OAuth integration for user impersonation
- Enhanced chart rendering formats (Vega-Lite, Plotly JSON)
- Advanced security features and tool poisoning prevention
## Security & Authentication
The MCP service supports **configurable JWT Bearer authentication** following Superset's factory pattern. Authentication is **disabled by default** for development convenience.
### Configuration Options
**Option 1: Simple Configuration** (Add to `superset_config.py`):
```python
# Enable authentication
MCP_AUTH_ENABLED = True
# JWT settings
MCP_JWKS_URI = "https://auth.company.com/.well-known/jwks.json"
MCP_JWT_ISSUER = "https://auth.company.com/"
MCP_JWT_AUDIENCE = "superset-mcp-api"
MCP_REQUIRED_SCOPES = ["dashboard:read", "chart:read"]
```
**Option 2: Custom Factory** (Advanced):
```python
def create_custom_mcp_auth(app):
"""Custom auth logic for your environment."""
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
return BearerAuthProvider(
jwks_uri=app.config["MCP_JWKS_URI"],
issuer=app.config["MCP_JWT_ISSUER"],
audience=app.config["MCP_JWT_AUDIENCE"],
)
MCP_AUTH_FACTORY = create_custom_mcp_auth
```
**Option 3: Environment Variables** (Legacy):
```bash
MCP_AUTH_ENABLED=true
MCP_JWKS_URI=https://auth.company.com/.well-known/jwks.json
MCP_JWT_ISSUER=https://auth.company.com/
MCP_JWT_AUDIENCE=superset-mcp-api
MCP_REQUIRED_SCOPES=dashboard:read,chart:read
```
### Security Features
**JWT Authentication**: RS256 tokens validated against JWKS or public key
**User Context**: JWT claims mapped to Superset users for proper permissions
**Scope-Based Authorization**:
| Tool | Required Scope |
|------|----------------|
| `list_dashboards`, `get_dashboard_info` | `dashboard:read` |
| `list_charts`, `get_chart_info` | `chart:read` |
| `generate_chart` | `chart:write` |
| `list_datasets`, `get_dataset_info` | `dataset:read` |
| `get_superset_instance_info` | `instance:read` |
**MCP Audit Logging**: All operations logged with MCP-specific context including impersonation tracking, source identification, and sanitized payloads
**Flexible User Resolution**: Configurable JWT claim extraction
### For Testing & Development
Generate test credentials using FastMCP's built-in utilities:
```python
from fastmcp.server.auth.providers.bearer import RSAKeyPair
# Generate test keypair
keypair = RSAKeyPair.generate()
print("Public key:", keypair.public_key)
# Create test token
token = keypair.create_token(
subject="john.doe",
issuer="https://test.example.com",
audience="superset-mcp-api",
scopes=["dashboard:read", "chart:read", "dataset:read"]
)
print("Test token:", token)
```
### Integration with Identity Providers
This authentication works with any JWT-compatible identity provider:
- **Auth0**: Use your tenant's JWKS URL
- **Okta**: Configure with your Okta domain JWKS endpoint
- **AWS Cognito**: Use your user pool's JWKS URL
- **Azure AD**: Configure with Microsoft identity platform
- **Custom JWT**: Use your own public key for validation
The MCP service extracts user identity from standard JWT claims and doesn't require complex integration - just valid JWT tokens with appropriate scopes.
## MCP Audit Logging
The MCP service implements comprehensive audit logging to distinguish MCP requests from regular user requests in audit trails:
### Required Context Fields
- **`log_source`**: Always set to "mcp" to identify MCP requests
- **`impersonation`**: Username of the authenticated user making the MCP request
- **`mcp_tool`**: Name of the specific MCP tool being executed
### Optional Enhanced Fields
- **`model_info`**: LLM model information from User-Agent header
- **`session_info`**: Session tracking from X-Session-ID header
- **`whitelisted_payload`**: Sanitized tool parameters (sensitive data redacted)
### Payload Sanitization
- **Sensitive keys redacted**: password, token, secret, key, auth fields
- **Large content truncated**: Strings over 1000 characters truncated
- **Security-first approach**: Better to over-redact than expose sensitive data
### Usage
All MCP tools automatically include audit context via the `@mcp_auth_hook` decorator:
```python
@mcp.tool
@mcp_auth_hook # Automatically adds MCP audit context
def my_tool(request: MyRequest) -> Dict[str, Any]:
# Tool implementation
pass
```
This enables enterprise audit compliance and helps distinguish automated MCP requests from interactive user sessions.
## Cache Control & Performance
The MCP service provides comprehensive cache control that leverages Superset's existing cache infrastructure for optimal performance:
### Superset Cache Layers
Superset has multiple cache layers that the MCP service leverages:
1. **Query Result Cache** - Caches actual query results from customer databases
2. **Metadata Cache** - Caches table schemas, column info, etc.
3. **Form Data Cache** - Caches chart configurations for explore URLs
4. **Dashboard Cache** - Caches rendered dashboard components
### Cache Control Parameters
All MCP tools support cache control through request parameters:
#### Query Cache Control
For tools that execute SQL queries (`get_chart_data`, `get_chart_data_cached`, `generate_chart`, `update_chart`):
```python
{
"use_cache": true, # Whether to use Superset's cache layers
"force_refresh": false, # Force refresh cached data
"cache_timeout": 3600 # Override cache timeout for this query (seconds)
}
```
#### Metadata Cache Control
For tools that fetch metadata (`list_dashboards`, `list_charts`, `list_datasets`, `get_*_info`):
```python
{
"use_cache": true, # Whether to use metadata cache
"refresh_metadata": false # Force refresh metadata for datasets/tables
}
```
#### Form Data Cache Control
For tools that work with chart configurations (`generate_explore_link`, `update_chart_preview`):
```python
{
"cache_form_data": true # Whether to cache form data configurations
}
```
### Cache Status Information
Tools return detailed cache status to help understand data freshness:
```python
{
"cache_status": {
"cache_hit": true, # Whether data was served from cache
"cache_type": "query", # Type of cache used (query, metadata, form_data)
"cache_age_seconds": 300, # Age of cached data in seconds
"refreshed": false # Whether cache was refreshed in this request
}
}
```
### Usage Examples
```python
# Get fresh data, bypassing cache
get_chart_data({
"identifier": 123,
"use_cache": false,
"force_refresh": true
})
# Use cache but with custom timeout
get_chart_data({
"identifier": 123,
"cache_timeout": 1800, # 30 minutes
"use_cache": true
})
# Refresh metadata for datasets
list_datasets({
"refresh_metadata": true,
"use_cache": false
})
# Fast metadata queries from cache
list_charts({
"use_cache": true,
"refresh_metadata": false
})
```
### Performance Benefits
- **Faster Response Times**: Cached queries return instantly without database execution
- **Reduced Database Load**: Identical queries hit cache regardless of how they were created (UI vs MCP)
- **Smart Cache Keys**: Cache based on query hash, so identical SQL queries share cache entries
- **Configurable TTL**: Per-dataset and global cache timeout configuration
- **Cache Transparency**: Clear cache status reporting helps users understand data freshness
### Cache Iteration Support
Chart iterations can effectively utilize the cache layer:
- When you modify a chart through MCP tools, if the underlying SQL query hasn't changed (same metrics, filters, time range), Superset serves from its query result cache
- The cache key is based on query hash, so identical queries hit the cache regardless of how they were created (UI vs MCP)
- This enables rapid chart iteration and preview generation
## Configuration & Deployment
### URL Configuration
The MCP service now uses centralized URL configuration for consistency across all tools:
```python
# In superset_config.py
SUPERSET_WEBSERVER_ADDRESS = "http://localhost:8088" # Development
SUPERSET_WEBSERVER_ADDRESS = "https://superset.company.com" # Production
```
**Key Features:**
- **Centralized URL management**: All tools use `get_superset_base_url()` utility
- **Environment flexibility**: Fallback to `SUPERSET_WEBSERVER_SCHEME`/`SUPERSET_WEBSERVER_HOST`/`SUPERSET_WEBSERVER_PORT`
- **Screenshot service integration**: MCP service serves screenshots on same port as WSGI endpoint
- **Configuration hierarchy**: `SUPERSET_WEBSERVER_ADDRESS` → component URLs → localhost:8088 fallback
### Agent Integration Options
1. **Claude Agent SDK**: Create cloud agent connecting to local/deployed MCP service
2. **LangChain Toolkit**: Use `langchain-mcp` for chatbot integration
3. **Direct MCP Connection**: Connect any MCP-compatible client to service
## Future Milestones
### 🔒 Enterprise Security (Future Phase)
- **Advanced Security Hooks**: Tool poisoning attack prevention and rate limiting
- **Comprehensive Audit Logging**: Enhanced logging and monitoring for enterprise environments
- **RBAC Extensions**: Advanced permission models and user role management
- **Multi-tenant Support**: Isolated environments for enterprise deployments
### 📊 Advanced Analytics (Future Phase)
- **Smart Cache Management**: Intelligent caching strategies with force refresh capabilities
- **Dashboard Creation**: Automated dashboard generation with multiple related charts
- **Advanced Chart Types**: Support for complex visualizations (maps, 3D, custom viz)
- **Business Intelligence**: Natural language to SQL query generation
- **End-to-End Testing**: Synthetic environments with example database integration
## Recent Major Improvements
### 🔧 **BaseDAO Type Safety & Performance**
- **UUID Type Conversion**: Centralized `_convert_value_for_column()` method for type-safe UUID handling
- **Flexible Column Support**: Enhanced `find_by_id()` and `find_by_ids()` with customizable column lookups
- **Test Coverage**: 185+ passing unit tests including edge cases and error scenarios
- **Code Quality**: Eliminated code duplication and hardcoded string checks
### 🚀 **MCP Service Consistency & Reliability**
- **Async Pattern Cleanup**: Removed unnecessary async declarations for better performance
- **SQL Lab Integration**: Fixed parameter naming (`dbid`) for proper frontend compatibility
- **Error Handling**: Robust UUID conversion with graceful fallbacks for malformed data
- **Type Validation**: Enhanced SQLAlchemy column type inspection for safer operations
### 🆕 **New Dashboard & SQL Lab Tools**
- **Dashboard Generation**: Dashboard creation with automatic chart layout
- **Chart Management**: Add charts to existing dashboards with intelligent positioning
- **SQL Lab Context**: Pre-configured SQL Lab sessions with database/schema selection
- **Preview System**: Chart screenshots, ASCII art, and data extraction capabilities
### 📊 **Enhanced Chart & Data Capabilities**
- **Multi-format Data Export**: JSON, CSV, Excel export with pagination support
- **Preview Generation**: URL screenshots, base64 images, ASCII charts, and table data
- **Smart Layout**: Automatic 2-column dashboard layouts with optimized positioning
- **Context Preservation**: Seamless navigation between Superset interfaces
### 🔒 **Production-Ready Architecture**
- **Configurable Auth Factory**: Enterprise JWT authentication following Superset patterns
- **Request Schema Pattern**: Structured inputs eliminating LLM parameter validation issues
- **Multi-identifier Support**: ID, UUID, and slug lookups across all tools with type safety
- **Professional Testing**: Integration tests, mocking patterns, and edge case coverage
- **MCP Audit Logging**: Comprehensive audit trails with MCP context, payload sanitization, and impersonation tracking

View File

@@ -0,0 +1,175 @@
# Superset MCP Service Phase 1 Status Update
## Background
The Model Context Protocol (MCP) is a new protocol for exposing high-level, structured actions in Superset, designed for LLM agents and automation. Phase 1 delivers a foundational, extensible MCP service in Superset, leveraging internal APIs (DAOs/commands) and providing a versioned, developer-friendly interface for both Apache and Preset use cases. ([SIP-171](https://github.com/apache/superset/issues/33870))
## Phase 1 Objectives (from SoW/SIP-171)
- Standalone MCP service, config flag, CLI, modular, stateless
- Strong typing: all actions use DAOs/commands and Pydantic schemas
- Clear extension points for Preset-specific auth, RBAC, and logging
- 3+ high-value MCP actions (list, info, mutation)
- Developer experience: easy to run, clear docs, tests
- Auth/RBAC/logging hooks stubbed, ready for enterprise
- Out of scope: full RBAC, impersonation, logging, external identity provider integration
## What's Delivered (Phase 1)
### ✅ Completed Epics
| Epic ID | Name | Status | Key Deliverables |
|---------|------|--------|-----------------|
| 90298 | **Implement Standalone MCP Service CLI** | ✅ Complete | ASGI-based FastMCP server, config flag, CLI (`superset mcp run`) |
| 90301 | **Add Auth/RBAC Hooks** | ✅ Complete | JWT Bearer authentication, configurable factory pattern, scope-based authorization |
### ✅ Recently Completed
| Epic ID | Name | Status | Progress |
|---------|------|--------|---------|
| 90300 | **Implement list/info tools for dataset, dashboard, chart** | ✅ Completed | All tools with multi-identifier support, enhanced search/filtering |
| 90299 | **Define Modular, Typed Schemas** | ✅ Completed | Pydantic v2 schemas, FastMCP Complex Inputs Pattern |
| 90302 | **Write Dev Guide and Docs** | 🔧 QA | Comprehensive documentation integrated into Superset Docusaurus |
| 90304 | **Implement Chart Creation Mutation** | 🔧 In Review | Chart creation, dashboard generation, update operations |
| 90305 | **Implement Navigation Actions** | 🔧 In Review | `generate_explore_link` and `open_sql_lab_with_context` |
| 90303 | **Document Preset Extension Points** | 🔧 In Review | RBAC, OIDC integration design for enterprise |
| 90511 | **Backend Chart Rendering** | 🔧 QA | Chart data/preview with screenshots, ASCII, table formats |
| 90509 | **Support for Bearer Authentication** | 🔧 QA | JWT Bearer authentication with configurable factory |
| 90510 | **Caching and Refresh** | 🔧 QA | Cache control parameters leveraging Superset infrastructure |
| 90548 | **Audit Logging** | 🔧 In Review | MCP context tracking with impersonation support |
### 🔧 Technical Achievements
- **Service Infrastructure**: ASGI-based FastMCP server, stateless design, professional CLI
- **Production Auth**: JWT Bearer authentication with configurable factory pattern (per @dpgaspar's design)
- **Code Quality**: 149 passing unit tests, full pre-commit compliance, professional error handling
- **Strong Typing**: All input/output uses Pydantic v2 with detailed field descriptions
- **Modular Architecture**: Domain-grouped tools (`dashboard/`, `dataset/`, `chart/`, `system/`)
- **Request Schema Pattern**: Eliminates LLM parameter validation issues with structured requests
- **Multi-Identifier Support**: ID/UUID/slug lookups across all get_*_info tools
- **Enhanced Search**: UUID/slug fields included in search and default response columns
- **Cache Control**: Comprehensive cache control parameters across all tools leveraging Superset's existing cache layers
### 🛠️ Core Tools Implemented (18 Total)
- **Dashboard Tools**: `list_dashboards`, `get_dashboard_info`, `get_dashboard_available_filters`, `generate_dashboard`, `add_chart_to_existing_dashboard`
- **Chart Tools**: `list_charts`, `get_chart_info`, `get_chart_available_filters`, `generate_chart`, `update_chart`, `update_chart_preview`, `get_chart_data`, `get_chart_preview`
- **Dataset Tools**: `list_datasets`, `get_dataset_info`, `get_dataset_available_filters`
- **System Tools**: `get_superset_instance_info`, `generate_explore_link`
- **SQL Lab Tools**: `open_sql_lab_with_context`
## Phase 1 Completion Status
**Overall Progress: 95% Complete** (All core epics complete, finalization tasks remaining)
**Phase 1 Status**: Core features complete, demo and testing needed for finalization
### ✅ Recent Technical Completions
- **BaseDAO Type Safety**: Enhanced UUID handling with extensive test coverage ✅
- **URL Configuration**: `SUPERSET_WEBSERVER_ADDRESS` support with centralized URL management ✅
- **MCP Audit Logging**: Comprehensive audit trails with impersonation tracking and payload sanitization ✅
- **Chart Update Operations**: `update_chart` and `update_chart_preview` for modifying saved and cached charts ✅
- **Schema Optimization**: Optional fields, minimal columns, null value handling ✅
- **Chart Embedding**: Screenshot URLs and backend rendering with Firefox WebDriver for LLM chat integration ✅
- **SQL Lab Integration**: Pre-configured SQL Lab sessions with database/schema selection ✅
- **Dashboard Management**: Dashboard creation and chart addition capabilities ✅
- **Cache Control Implementation**: Integrated cache control parameters across all tools with schema inheritance pattern ✅
- Query cache control for chart data and generation tools
- Metadata cache control for list and get_info tools
- Form data cache control for explore link and preview tools
- Cache status reporting in tool responses
### 🎯 Phase 1 Finalization Remaining
| Epic ID | Task | Status | Description |
|---------|------|--------|-------------|
| 90306 | **Create Demo Script/Notebook** | 📋 Procurement | Interactive demo showing bot capabilities |
| 90527 | **End-to-End Prompt Testing** | 🔧 In Development | At least one complete LLM agent workflow test |
### 🚫 Out of Scope Items
| Epic ID | Name | Status | Reason |
|---------|------|--------|--------|
| 90508 | **LLM/Chat Friendly Backend Rendered Charts** | 🔧 QA | Vega-Lite/Plotly JSON for enhanced LLM integration |
| 90398 | **Security Hooks for Tool Poisoning Attacks** | 📋 Procurement | Advanced security feature for future phase |
| 90397 | **In-Preset Hosted Demo (OAuth, impersonation)** | 📋 Procurement | Cloud deployment with proper authentication |
## Phase 1 Finalization Tasks
**Remaining work to complete Phase 1:**
1. **Demo Video/Script** - Create comprehensive demonstration
- Video walkthrough of all 16 MCP tools working end-to-end
- Claude Desktop integration examples
- Complete workflow from data exploration to chart creation
2. **End-to-End Prompt Test** - Validate complete LLM workflow
- At least one complete multi-step agent interaction
- Test real-world use case: "Create a sales dashboard with 3 charts"
- Verify all tools work together seamlessly
## Team Meeting Notes - Future Considerations
### LangChain Integration Ideas
1. **Create a chat bot with LangChain**
2. **Tool Discovery**: When user chats, append/prepend message saying "hey you have these tools you can use"
3. **Tool Mapping**: Map tools to what people want to do
4. **Diego's Note**: This works well for lots of tools when we don't know which one to use, but for our cases we might get away without the mapping. Later we can use custom prompts to figure out exact tools
### Max's Priority Areas
1. **Reference**: https://context7.com/
2. **Playwright MCP** - Could it be leveraged for chart generation?
3. **Easy Setup** - Making it easy for anyone to pull branch and get going
4. **Focus on Quality over Coverage** - Instead of coverage, focus on getting the tools we have already right
- **Communication Layer**:
- Error handling improvements
- Ensure LLM gives proper JSON/object format
- Return clear/direct messages: "hey you can't pass it with quotes you need to pass it this way"
5. **Next 20 Tools** - Define the semantics and schemas for these tools
6. **GitHub Codespaces** - Uses docker compose lite.yaml
7. **Next Step**: Build UI chat in Superset
### Diego's Agentic System Questions
1. Right now we just get Claude to basically figure out what to call for us
2. Do you think having a proper agentic system that does multiple passes on the user input and is specialized would help?
3. Do you think it's an MCP service level thing?
4. Would this be a middle layer? (let's work together on how this would work out)
5. Make sure README is updated
## Future Development (Post-Phase 1)
### Enterprise & Security
- **Advanced Security Hooks**: Tool poisoning prevention, rate limiting
- **Enhanced RBAC**: Advanced permission models, multi-tenant support
- **Audit**: Enterprise logging and monitoring
### Advanced Features
- **Dashboard Creation**: Multi-chart dashboard generation
- **Advanced Chart Types**: Maps, 3D visualizations, custom components
- **Business Intelligence**: Natural language to SQL query generation
## Summary Table
| Epic/Deliverable | Epic ID | Status | Completion |
|------------------|---------|--------|-----------|
| **Standalone MCP Service CLI** | 90298 | ✅ Complete | 100% |
| **Add Auth/RBAC Hooks** | 90301 | ✅ Complete | 100% |
| **List/Info Tools** | 90300 | 🟡 In QA | 95% |
| **Define Modular Schemas** | 90299 | 🟡 In Review | 90% |
| **Write Dev Guide and Docs** | 90302 | 🟡 In Review | 90% |
| **Chart Creation Mutation** | 90304 | 🟡 In Review | 85% |
| **Navigation Actions** | 90305 | 🟡 In Review | 75% |
| **Document Preset Extensions** | 90303 | 🟡 In Review | 80% |
| **Backend Chart Rendering** | 90511 | 🔧 In Development | 20% |
| **Bearer Authentication** | 90509 | 🔧 In Development | 60% |
| **Demo Script/Notebook** | 90306 | 📋 Stretch Goal | 0% |
| **In-Preset OAuth Demo** | 90397 | 📋 Stretch Goal | 0% |
| **LLM-Friendly Rendering** | 90508 | 📋 Stretch Goal | 0% |
| **Security Hooks** | 90398 | 🚫 Out of Scope | 0% |
**Phase 1 Core: 95% Complete** | **Stretch Goals: Available for additional polish**
## Key Metrics
- **194+ Unit Tests**: All passing with extensive coverage including URL utils and audit logging
- **18 Core Tools**: List/read/update operations for all entities, chart creation/updates, dashboard generation, SQL Lab integration
- **Production Auth**: JWT Bearer with configurable factory pattern and MCP audit logging
- **Zero Breaking Changes**: Stable API ready for Phase 2 enhancements
- **Developer Experience**: Single command setup, detailed docs, clear extension points
- **Type Safety**: Enhanced BaseDAO with UUID handling and robust error handling
- **Enterprise Audit**: MCP-specific audit logging with impersonation tracking and payload sanitization
## Reference
- [SIP-171: MCP Service Proposal](https://github.com/apache/superset/issues/33870)
- [Epic Tracking CSV](project-epic-status.csv) - Updated July 28, 2025

View File

@@ -0,0 +1,444 @@
# Superset MCP Service: Tool Schemas Reference
This document provides a reference for the input and output schemas of all MCP tools in the Superset MCP service. All schemas are Pydantic v2 models with field descriptions for LLM/OpenAPI compatibility.
**Status**: Phase 1 Complete (95% done). All core schemas stable and production-ready with extensive testing coverage.
## FastMCP Complex Inputs Pattern
All MCP tools use **structured request objects** instead of individual parameters to eliminate LLM validation issues:
```python
# All list tools use request objects
list_dashboards(request=ListDashboardsRequest(...))
list_datasets(request=ListDatasetsRequest(...))
list_charts(request=ListChartsRequest(...))
# All get_info tools use request objects with multi-identifier support
get_dashboard_info(request=GetDashboardInfoRequest(identifier="123")) # ID
get_dashboard_info(request=GetDashboardInfoRequest(identifier="uuid-string")) # UUID
get_dashboard_info(request=GetDashboardInfoRequest(identifier="slug-string")) # Slug
# Chart creation with detailed config
generate_chart(request=GenerateChartRequest(
dataset_id="1",
config=XYChartConfig(
chart_type="xy",
x=ColumnRef(name="date"),
y=[ColumnRef(name="sales", aggregate="SUM")],
kind="line"
)
))
```
### Key Benefits
- **No parameter ambiguity**: Filters are always arrays, never strings
- **Clear validation**: Cannot use both search and filters simultaneously
- **Multi-identifier support**: ID, UUID, and slug (where applicable) in single interface
- **LLM-friendly**: Unambiguous types prevent common LLM validation errors
- **Production-ready**: 185+ unit tests ensure schema reliability
## Cache Control Schemas
All MCP tools support cache control through schema inheritance:
### CacheControlMixin
Base mixin for all cache control:
- `use_cache`: `bool = True` — Whether to use Superset's cache layers
- `force_refresh`: `bool = False` — Whether to force refresh cached data
### QueryCacheControl
For tools that execute SQL queries (`get_chart_data`, `generate_chart`, `update_chart`):
- Inherits: `CacheControlMixin`
- `cache_timeout`: `Optional[int]` — Override cache timeout for this query (seconds)
### MetadataCacheControl
For tools that fetch metadata (`list_*`, `get_*_info` tools):
- Inherits: `CacheControlMixin`
- `refresh_metadata`: `bool = False` — Force refresh metadata from database
### FormDataCacheControl
For tools working with chart configurations (`generate_explore_link`, `update_chart_preview`):
- Inherits: `CacheControlMixin`
- `cache_form_data`: `bool = True` — Whether to cache form data configurations
### CacheStatus
Returned by tools to indicate cache usage:
- `cache_hit`: `bool` — Whether data was served from cache
- `cache_type`: `Literal["query", "metadata", "form_data", "none"]` — Type of cache used
- `cache_age_seconds`: `Optional[int]` — Age of cached data in seconds
- `refreshed`: `bool` — Whether cache was refreshed in this request
## Dashboards
### list_dashboards
**Input:** `ListDashboardsRequest` (inherits `MetadataCacheControl`)
- `filters`: `List[DashboardFilter]` — List of filter objects (cannot be used with search)
- `search`: `Optional[str]` — Free-text search string (cannot be used with filters)
- `select_columns`: `List[str]` — Columns to select (defaults include id, dashboard_title, slug, uuid)
- `order_column`: `Optional[str]` — Column to order results by (valid: id, dashboard_title, slug, published, changed_on, created_on)
- `order_direction`: `Optional[Literal['asc', 'desc']]` — Order direction
- `page`: `int` — Page number (0-based)
- `page_size`: `int` — Number of items per page (default 100)
- `use_cache`: `bool = True` — Whether to use metadata cache
- `refresh_metadata`: `bool = False` — Force refresh metadata from database
**Returns:** `DashboardList`
- `dashboards`: `List[DashboardListItem]`
- `count`: `int`
- `total_count`: `int`
- `page`: `int`
- `page_size`: `int`
- `total_pages`: `int`
- `has_previous`: `bool`
- `has_next`: `bool`
- `columns_requested`: `List[str]`
- `columns_loaded`: `List[str]`
- `filters_applied`: `List[Any]`
- `pagination`: `PaginationInfo`
- `timestamp`: `datetime`
### get_dashboard_info
**Input:** `GetDashboardInfoRequest`
- `identifier`: `Union[int, str]` — Dashboard identifier (supports ID, UUID, or slug)
**Returns:** `DashboardInfo` or `DashboardError`
**Multi-Identifier Support:**
- **ID**: Numeric dashboard ID (e.g., `123`)
- **UUID**: Dashboard UUID string (e.g., `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"`)
- **Slug**: Dashboard slug string (e.g., `"sales-dashboard"`)
### get_dashboard_available_filters
**Input:** `GetDashboardAvailableFiltersRequest` (API consistency)
- No parameters required (empty request object for consistent API design)
**Returns:** `DashboardAvailableFilters`
- `column_operators`: `Dict[str, Any]` — Available filter operators and metadata for each column
## Datasets
### list_datasets
**Input:** `ListDatasetsRequest` (inherits `MetadataCacheControl`)
- `filters`: `List[DatasetFilter]` — List of filter objects (cannot be used with search)
- `search`: `Optional[str]` — Free-text search string (cannot be used with filters)
- `select_columns`: `List[str]` — Columns to select (defaults include id, table_name, uuid)
- `order_column`: `Optional[str]` — Column to order results by (valid: id, table_name, schema, changed_on, created_on)
- `order_direction`: `Optional[Literal['asc', 'desc']]` — Order direction
- `page`: `int` — Page number (0-based)
- `page_size`: `int` — Number of items per page (default 100)
- `use_cache`: `bool = True` — Whether to use metadata cache
- `refresh_metadata`: `bool = False` — Force refresh metadata from database
**Returns:** `DatasetList`
- `datasets`: `List[DatasetListItem]` (each includes columns and metrics)
- `count`: `int`
- `total_count`: `int`
- `page`: `int`
- `page_size`: `int`
- `total_pages`: `int`
- `has_previous`: `bool`
- `has_next`: `bool`
- `columns_requested`: `List[str]`
- `columns_loaded`: `List[str]`
- `filters_applied`: `List[Any]`
- `pagination`: `PaginationInfo`
- `timestamp`: `datetime`
### get_dataset_info
**Input:** `GetDatasetInfoRequest`
- `identifier`: `Union[int, str]` — Dataset identifier (supports ID or UUID)
**Returns:** `DatasetInfo` or `DatasetError` (now includes columns and metrics)
**Multi-Identifier Support:**
- **ID**: Numeric dataset ID (e.g., `123`)
- **UUID**: Dataset UUID string (e.g., `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"`)
#### DatasetInfo fields (new):
- `columns`: `List[TableColumnInfo]` — List of columns with name, type, verbose name, etc.
- `metrics`: `List[SqlMetricInfo]` — List of metrics with name, expression, verbose name, etc.
#### TableColumnInfo
- `column_name`: `str` — Column name
- `verbose_name`: `Optional[str]` — Verbose name
- `type`: `Optional[str]` — Column type
- `is_dttm`: `Optional[bool]` — Is datetime column
- `groupby`: `Optional[bool]` — Is groupable
- `filterable`: `Optional[bool]` — Is filterable
- `description`: `Optional[str]` — Column description
#### SqlMetricInfo
- `metric_name`: `str` — Metric name
- `verbose_name`: `Optional[str]` — Verbose name
- `expression`: `Optional[str]` — SQL expression
- `description`: `Optional[str]` — Metric description
> **Note:** All dataset list/info responses now include full column and metric metadata for each dataset.
### get_dataset_available_filters
**Input:** `GetDatasetAvailableFiltersRequest` (API consistency)
- No parameters required (empty request object for consistent API design)
**Returns:** `DatasetAvailableFilters`
- `column_operators`: `Dict[str, Any]` — Available filter operators and metadata for each column
## Charts
### list_charts
**Input:** `ListChartsRequest` (inherits `MetadataCacheControl`)
- `filters`: `List[ChartFilter]` — List of filter objects (cannot be used with search)
- `search`: `Optional[str]` — Free-text search string (cannot be used with filters)
- `select_columns`: `List[str]` — Columns to select (defaults include id, slice_name, uuid)
- `order_column`: `Optional[str]` — Column to order results by (valid: id, slice_name, viz_type, datasource_name, description, changed_on, created_on)
- `order_direction`: `Optional[Literal['asc', 'desc']]` — Order direction
- `page`: `int` — Page number (0-based)
- `page_size`: `int` — Number of items per page (default 100)
- `use_cache`: `bool = True` — Whether to use metadata cache
- `refresh_metadata`: `bool = False` — Force refresh metadata from database
**Returns:** `ChartList`
- `charts`: `List[ChartListItem]`
- `count`: `int`
- `total_count`: `int`
- `page`: `int`
- `page_size`: `int`
- `total_pages`: `int`
- `has_previous`: `bool`
- `has_next`: `bool`
- `columns_requested`: `List[str]`
- `columns_loaded`: `List[str]`
- `filters_applied`: `List[Any]`
- `pagination`: `PaginationInfo`
- `timestamp`: `datetime`
### get_chart_info
**Input:** `GetChartInfoRequest`
- `identifier`: `Union[int, str]` — Chart identifier (supports ID or UUID)
**Returns:** `ChartInfo` or `ChartError`
**Multi-Identifier Support:**
- **ID**: Numeric chart ID (e.g., `123`)
- **UUID**: Chart UUID string (e.g., `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"`)
### get_chart_available_filters
**Input:** `GetChartAvailableFiltersRequest` (API consistency)
- No parameters required (empty request object for consistent API design)
**Returns:** `ChartAvailableFiltersResponse`
- `column_operators`: `Dict[str, Any]` — Available filter operators and metadata for each column
### generate_chart
**Input:** `GenerateChartRequest`
- `dataset_id`: `str` — ID of the dataset to use
- `config`: `ChartConfig` — Chart configuration (supports table and XY charts)
**Returns:** `Dict[str, Any]`
- `chart`: `Optional[Dict]` — The created chart info with id, slice_name, viz_type, and url
- `error`: `Optional[str]` — Error message, if creation failed
#### ChartConfig (Union of TableChartConfig and XYChartConfig)
#### TableChartConfig
- `chart_type`: `Literal["table"]` — Chart type
- `columns`: `List[ColumnRef]` — Columns to display
- `filters`: `Optional[List[FilterConfig]]` — Filters to apply
- `sort_by`: `Optional[List[str]]` — Columns to sort by
#### XYChartConfig
- `chart_type`: `Literal["xy"]` — Chart type
- `x`: `ColumnRef` — X-axis column
- `y`: `List[ColumnRef]` — Y-axis columns
- `kind`: `Literal["line", "bar", "area", "scatter"]` — Chart visualization type
- `group_by`: `Optional[ColumnRef]` — Column to group by
- `x_axis`: `Optional[AxisConfig]` — X-axis configuration
- `y_axis`: `Optional[AxisConfig]` — Y-axis configuration
- `legend`: `Optional[LegendConfig]` — Legend configuration
- `filters`: `Optional[List[FilterConfig]]` — Filters to apply
#### ColumnRef
- `name`: `str` — Column name
- `label`: `Optional[str]` — Display label for the column
- `dtype`: `Optional[str]` — Data type hint
- `aggregate`: `Optional[str]` — SQL aggregation function (SUM, COUNT, AVG, MIN, MAX, etc.)
#### AxisConfig
- `title`: `Optional[str]` — Axis title
- `scale`: `Optional[Literal["linear", "log"]]` — Axis scale type
- `format`: `Optional[str]` — Format string (e.g. '$,.2f')
#### LegendConfig
- `show`: `bool` — Whether to show legend
- `position`: `Optional[Literal["top", "bottom", "left", "right"]]` — Legend position
#### FilterConfig
- `column`: `str` — Column to filter on
- `op`: `Literal["=", ">", "<", ">=", "<=", "!="]` — Filter operator
- `value`: `Union[str, int, float, bool]` — Filter value
#### Supported Chart Types
- **Table charts** (`table`) — Simple column display with filters and sorting
- **Line charts** (`echarts_timeseries_line`) — Time series line charts
- **Bar charts** (`echarts_timeseries_bar`) — Time series bar charts
- **Area charts** (`echarts_area`) — Time series area charts
- **Scatter charts** (`echarts_timeseries_scatter`) — Time series scatter charts
#### Metric Handling
The tool intelligently handles two metric formats:
1. **Simple metrics** (like `["count"]`) — Passed as simple strings
2. **Complex metrics** (like column names) — Converted to full Superset metric objects with SQL aggregators (SUM, COUNT, AVG, MIN, MAX)
#### Chart Creation Output
```python
{
"chart": {
"id": 123,
"slice_name": "Sales Over Time",
"viz_type": "echarts_timeseries_line",
"url": "/explore/?form_data=...",
"explore_url": "http://localhost:8088/explore/?form_data=..."
},
"error": None
}
```
## System Tools
### get_superset_instance_info
**Input:** `GetSupersetInstanceInfoRequest` (API consistency)
- No parameters required (empty request object for consistent API design)
**Returns:** `SupersetInstanceInfo`
- `version`: `str` — Superset version
- `build_number`: `Optional[str]` — Build identifier
- `instance_id`: `str` — Unique instance identifier
- `mcp_service_version`: `str` — MCP service version
- `authentication_enabled`: `bool` — Whether JWT authentication is enabled
- `available_tools`: `List[str]` — List of available MCP tools
- `supported_chart_types`: `List[str]` — Supported chart types for creation
### generate_explore_link
**Input:** `GenerateExploreLinkRequest`
- `dataset_id`: `str` — Dataset ID to explore
- `config`: `ChartConfig` — Chart configuration (same as generate_chart)
**Returns:** `ExploreLinkResponse`
- `explore_url`: `str` — Full URL to Superset explore interface with chart configuration
- `form_data`: `Dict[str, Any]` — Serialized form data for the chart
## Authentication Context
When authentication is enabled, all tools receive additional context:
### JWT Authentication
- **User Extraction**: JWT claims (subject, client_id, email, username) mapped to Superset users
- **Scope Validation**: Each tool validates required scopes before execution
- **Audit Logging**: All operations logged with user context and JWT metadata
- **Impersonation**: Optional `run_as` parameter for user impersonation (where permitted)
### Error Responses
When authentication fails or permissions are insufficient:
```python
{
"error": "Access denied: user lacks permission for tool_name",
"error_type": "PermissionError",
"required_scopes": ["chart:read"],
"user_scopes": ["dashboard:read"]
}
```
## Model Relationships
```mermaid
flowchart TD
subgraph Schema Types
A["DashboardListItem"]
B["DatasetListItem"]
C["ChartListItem"]
D["UserInfo"]
E["TagInfo"]
F["RoleInfo"]
G["TableColumnInfo"]
H["SqlMetricInfo"]
I["ChartConfig"]
J["GenerateChartRequest"]
end
A -- owners --> D
A -- tags --> E
A -- roles --> F
B -- owners --> D
B -- tags --> E
B -- columns --> G
B -- metrics --> H
C -- owners --> D
C -- tags --> E
J -- config --> I
I -- columns --> G
```
## Request Schema Pattern Benefits
All tools using the FastMCP Complex Inputs Pattern provide:
### For List Tools (`list_*`)
- **Clear array types**: `filters` is always `List[Filter]`, never a string
- **Mutual exclusion**: Cannot use both `search` and `filters` simultaneously
- **Default columns**: Include UUID/slug in default responses for better searchability
- **Validation messages**: Clear error messages guide LLM usage
### For Get Info Tools (`get_*_info`)
- **Multi-identifier support**: Single interface for ID, UUID, and slug lookup
- **Intelligent detection**: Automatically determines identifier type based on format
- **Enhanced flexibility**: Works with LLM-generated identifiers of any supported type
- **Rich metadata**: Full object details including relationships (columns, metrics, owners)
- **Error handling**: Clear error responses when objects not found or access denied
### ModelListTool and Schema Consistency
All list tools use the `ModelListTool` abstraction, which enforces:
- Consistent parameter order and types via request schemas
- Strongly-typed Pydantic input/output models
- LLM/OpenAPI-friendly field names
- Validation logic preventing parameter conflicts
- Enhanced search including UUID/slug fields
- Detailed metadata in responses (columns_requested, columns_loaded, etc.)
## Schema Validation & Testing
All schemas are thoroughly tested with:
- **194+ unit tests** covering all input/output combinations including URL utils and audit logging
- **Multi-identifier testing** for all get_*_info tools (ID, UUID, slug)
- **Request schema validation** preventing parameter conflicts
- **Authentication integration** testing with JWT contexts
- **Error response validation** for permission and authentication failures
- **Chart creation and update testing** covering all supported chart types and aggregators
- **Dashboard generation testing** for workflow validation
- **SQL Lab integration testing** with proper parameter handling
## Future Schema Enhancements
### Phase 1 Recently Completed ✅
- **Backend rendering schemas**: Chart screenshot and image response formats ✅
- **SQL Lab schemas**: Context-aware query session parameters ✅
- **Dashboard generation schemas**: Complete dashboard creation and chart addition ✅
- **Chart data/preview schemas**: Multi-format data export and preview generation ✅
- **Enhanced error responses**: More detailed validation and permission error details ✅
### Future Phases
- **Advanced chart types**: Maps, 3D visualizations, custom components
- **Vega-Lite/Plotly output**: LLM-friendly chart rendering formats
- **Advanced dashboard layouts**: Custom positioning and grid configurations
- **Business intelligence schemas**: Natural language to SQL query generation

View File

@@ -0,0 +1,261 @@
# Table Chart Configuration Guide
This guide explains how table charts work in the Superset MCP service, including the improved aggregation behavior and formatting.
## Table Chart Behavior
### Column Types
Table charts support two types of columns:
#### 1. Raw Columns (No Aggregation)
```python
ColumnRef(name="customer_name") # No aggregate specified
ColumnRef(name="order_date") # Raw date values
```
- Shows individual row values
- No grouping applied
- Displays data as-is from the dataset
#### 2. Aggregated Columns (With Aggregation)
```python
ColumnRef(name="revenue", aggregate="SUM") # Sum of revenue
ColumnRef(name="orders", aggregate="COUNT") # Count of orders
ColumnRef(name="price", aggregate="AVG") # Average price
```
- Applies specified aggregation function
- Groups data when mixed with raw columns
- Supported aggregates: SUM, COUNT, AVG, MIN, MAX, COUNT_DISTINCT
### Mixed Column Behavior
When you mix raw and aggregated columns, the table automatically groups by the raw columns:
#### Example 1: Pure Raw Columns
```python
TableChartConfig(
chart_type="table",
columns=[
ColumnRef(name="customer_name"),
ColumnRef(name="order_date"),
ColumnRef(name="product_name")
]
)
```
**Result**: Shows individual rows, no grouping
```
customer_name | order_date | product_name
John Smith | 2024-01-15 | Widget A
Jane Doe | 2024-01-16 | Widget B
John Smith | 2024-01-17 | Widget C
```
#### Example 2: Pure Aggregated Columns
```python
TableChartConfig(
chart_type="table",
columns=[
ColumnRef(name="revenue", aggregate="SUM"),
ColumnRef(name="orders", aggregate="COUNT")
]
)
```
**Result**: Single row with aggregated totals
```
SUM(revenue) | COUNT(orders)
45,250.00 | 1,247
```
#### Example 3: Mixed Raw + Aggregated (Recommended)
```python
TableChartConfig(
chart_type="table",
columns=[
ColumnRef(name="customer_name"), # Raw (becomes GROUP BY)
ColumnRef(name="revenue", aggregate="SUM"), # Aggregated
ColumnRef(name="orders", aggregate="COUNT") # Aggregated
]
)
```
**Result**: Groups by customer_name, aggregates metrics
```
customer_name | SUM(revenue) | COUNT(orders)
John Smith | 15,750.00 | 8
Jane Doe | 29,500.00 | 12
```
## Aggregation Functions
### Supported Aggregates
| Function | Description | Works With |
|----------|-------------|------------|
| `SUM` | Sum of values | Numeric columns |
| `COUNT` | Count of rows | All column types |
| `COUNT_DISTINCT` | Count unique values | All column types |
| `AVG` | Average value | Numeric columns |
| `MIN` | Minimum value | Numeric, date columns |
| `MAX` | Maximum value | Numeric, date columns |
### Type Compatibility
The validation system prevents incompatible aggregations:
-`SUM(revenue)` - numeric column
-`COUNT(customer_name)` - text column
-`SUM(customer_name)` - invalid (text column)
-`MIN(order_date)` - date column
-`AVG(customer_name)` - invalid (text column)
## Improved Table Preview
### Enhanced Formatting Features
1. **Dynamic Column Widths**: Columns adjust width based on content
2. **Better Number Formatting**:
- Thousands separators: `1,234.56`
- Scientific notation for large numbers: `1.23e+06`
- Proper decimal places for floats
3. **More Columns Shown**: Up to 8 columns (was 5)
4. **More Rows Shown**: Up to 15 rows (was 10)
5. **Smart Truncation**: Uses `..` to indicate truncated content
6. **NULL Handling**: Shows `NULL` for null values
### Example Enhanced Preview
```
Table Preview
================================================================================
customer_name | region | SUM(revenue) | COUNT(orders) | AVG(rating)
------------------+-----------+--------------+---------------+-------------
John Smith | North | 15,750.00 | 8 | 4.25
Jane Doe | South | 29,500.00 | 12 | 4.67
Mike Johnson | West | 8,900.00 | 5 | 3.80
Sarah Wilson | East | 22,100.00 | 9 | 4.44
... and 146 more rows
... and 3 more columns
Total: 150 rows × 11 columns
```
## Configuration Examples
### Basic Customer Report
```python
TableChartConfig(
chart_type="table",
columns=[
ColumnRef(name="customer_name"),
ColumnRef(name="total_orders", aggregate="COUNT"),
ColumnRef(name="total_revenue", aggregate="SUM"),
ColumnRef(name="avg_order_value", aggregate="AVG")
],
sort_by=["total_revenue"] # Sort by revenue descending
)
```
### Regional Sales Summary
```python
TableChartConfig(
chart_type="table",
columns=[
ColumnRef(name="region"),
ColumnRef(name="sales_rep"),
ColumnRef(name="revenue", aggregate="SUM"),
ColumnRef(name="deals_closed", aggregate="COUNT")
],
filters=[
FilterConfig(column="region", op="!=", value="Unknown")
]
)
```
### Product Performance Analysis
```python
TableChartConfig(
chart_type="table",
columns=[
ColumnRef(name="product_category"),
ColumnRef(name="product_name"),
ColumnRef(name="units_sold", aggregate="SUM"),
ColumnRef(name="revenue", aggregate="SUM"),
ColumnRef(name="profit_margin", aggregate="AVG")
],
sort_by=["revenue", "units_sold"]
)
```
## Migration from Previous Behavior
### Before (Problematic)
- All columns were forced to have aggregation (defaulted to SUM)
- Mixed raw and aggregated behavior was unclear
- Headers were truncated to 15 characters
- Only 5 columns and 10 rows shown
### After (Fixed)
- Raw columns stay raw, aggregated columns stay aggregated
- Clear grouping behavior when mixing column types
- Dynamic column widths with smart truncation
- Better number formatting and more data shown
- Detailed preview with summary statistics
## Best Practices
### 1. Choose Column Types Intentionally
- Use raw columns for dimensional data (names, categories, dates)
- Use aggregated columns for metrics (revenue, counts, averages)
### 2. Meaningful Grouping
```python
# Good: Groups customers by region, shows metrics
columns=[
ColumnRef(name="region"), # GROUP BY
ColumnRef(name="revenue", aggregate="SUM")
]
# Bad: Mixing unrelated raw columns
columns=[
ColumnRef(name="customer_name"), # Will group by this
ColumnRef(name="product_name"), # And this (Cartesian product!)
ColumnRef(name="revenue", aggregate="SUM")
]
```
### 3. Use Appropriate Aggregates
```python
# Good
ColumnRef(name="price", aggregate="AVG") # Average price
ColumnRef(name="order_count", aggregate="COUNT") # Count orders
# Bad
ColumnRef(name="customer_name", aggregate="SUM") # Invalid!
```
### 4. Sort by Important Metrics
```python
TableChartConfig(
# ... columns ...
sort_by=["revenue", "customer_name"] # Sort by revenue desc, then name asc
)
```
## Troubleshooting
### Issue: Too Many Grouped Rows
**Problem**: When mixing many raw columns, you get too many groups
**Solution**: Reduce raw columns or use filters to limit data
### Issue: Unexpected Aggregation
**Problem**: Getting aggregated data when you want raw rows
**Solution**: Remove `aggregate` parameter from column definitions
### Issue: Missing Data in Groups
**Problem**: Some combinations showing NULL
**Solution**: This is normal - not all combinations exist in your data
### Issue: Performance Problems
**Problem**: Table takes too long to load
**Solution**: Add filters to reduce data volume, or use fewer grouping columns
---
This improved table chart implementation provides clearer behavior, better formatting, and more predictable results for users.

View File

@@ -0,0 +1,247 @@
# Table Chart Aggregation Fixes - Test Plan (UPDATED)
This test plan validates the table chart aggregation improvements including **CRITICAL FIXES** for raw columns and numeric type validation. Run these tests to verify the fixes work correctly.
## Test Overview
**Purpose**: Verify table chart aggregation behavior and preview formatting improvements
**Time**: ~10-15 minutes
**Prerequisites**: Superset MCP service running with sample data
**Updated**: Includes fixes for raw column empty queries and DOUBLE PRECISION aggregation support
## Test Cases
### Test 1: Raw Columns Only (No Aggregation)
**Expected**: Show individual rows without grouping
```python
# Generate a table with only raw columns
{
"dataset_id": [your_dataset_id],
"config": {
"chart_type": "table",
"columns": [
{"name": "customer_name"},
{"name": "order_date"},
{"name": "product_name"}
]
},
"generate_preview": true,
"preview_formats": ["table"]
}
```
**✅ Expected Results** (CRITICAL FIX):
- Shows individual rows (not aggregated)
- All customer names appear (no grouping)
- **Table preview contains actual data** (not empty)
- **No "Empty query?" errors**
- Preview shows multiple rows with different customers
- Column headers are not truncated
- **Form data includes query_mode="raw" and row_limit**
### Test 2: Numeric Aggregates (CRITICAL FIX)
**Expected**: SUM/AVG work on DOUBLE PRECISION, BIGINT, etc.
```python
# Test SUM on DOUBLE PRECISION (previously failing)
{
"dataset_id": [your_dataset_id],
"config": {
"chart_type": "table",
"columns": [
{"name": "sales", "aggregate": "SUM"}, # DOUBLE PRECISION type
{"name": "price_each", "aggregate": "AVG"} # DOUBLE PRECISION type
]
},
"generate_preview": true,
"preview_formats": ["table"]
}
```
**✅ Expected Results** (CRITICAL FIX):
- **No validation errors** (previously rejected DOUBLE PRECISION)
- Shows single row with aggregated totals
- Column headers show "SUM(sales)", "AVG(price_each)"
- **Numeric types properly recognized**: DOUBLE PRECISION, BIGINT, INTEGER, FLOAT all work
- Values are properly aggregated across all data
### Test 3: Aggregated Columns Only
**Expected**: Show single summary row with totals
```python
# Generate a table with only aggregated columns
{
"dataset_id": [your_dataset_id],
"config": {
"chart_type": "table",
"columns": [
{"name": "revenue", "aggregate": "SUM"},
{"name": "order_id", "aggregate": "COUNT"}
]
},
"generate_preview": true,
"preview_formats": ["table"]
}
```
**✅ Expected Results**:
- Shows single row with totals
- Column headers show "SUM(revenue)", "COUNT(order_id)"
- Values are properly aggregated across all data
### Test 3: Mixed Columns (Raw + Aggregated)
**Expected**: Group by raw columns, aggregate metrics
```python
# Generate a table mixing raw and aggregated columns
{
"dataset_id": [your_dataset_id],
"config": {
"chart_type": "table",
"columns": [
{"name": "customer_name"},
{"name": "revenue", "aggregate": "SUM"},
{"name": "order_id", "aggregate": "COUNT"}
]
},
"generate_preview": true,
"preview_formats": ["table"]
}
```
**✅ Expected Results**:
- One row per customer (grouped by customer_name)
- Revenue and order counts aggregated per customer
- Multiple customers visible in preview
- Clear grouping behavior
### Test 4: Enhanced Table Preview Formatting
**Expected**: Better formatting and more information
```python
# Generate table with various data types to test formatting
{
"dataset_id": [your_dataset_id],
"config": {
"chart_type": "table",
"columns": [
{"name": "customer_name"},
{"name": "revenue", "aggregate": "SUM"},
{"name": "avg_rating", "aggregate": "AVG"}
]
},
"generate_preview": true,
"preview_formats": ["table"]
}
```
**✅ Expected Results**:
- Column widths adjust to content (not fixed 15 chars)
- Numbers show thousands separators (e.g., "1,234.56")
- Large numbers use scientific notation if needed
- Table shows "Total: X rows × Y columns" at bottom
- No harsh truncation of column names
### Test 5: Error Validation (Enhanced Error Messages)
**Expected**: Helpful error messages with suggestions
```python
# Try invalid column name to test error handling
{
"dataset_id": [your_dataset_id],
"config": {
"chart_type": "table",
"columns": [
{"name": "invalid_column_name"}
]
}
}
```
**✅ Expected Results**:
- Clear error message about invalid column
- Suggestions for similar column names (fuzzy matching)
- List of available columns
- Helpful context about the dataset
### Test 6: Invalid Aggregation (Type Checking)
**Expected**: Prevent incompatible aggregations
```python
# Try invalid aggregation (SUM on text column)
{
"dataset_id": [your_dataset_id],
"config": {
"chart_type": "table",
"columns": [
{"name": "customer_name", "aggregate": "SUM"}
]
}
}
```
**✅ Expected Results**:
- Error about incompatible aggregation
- Suggestion to use COUNT/COUNT_DISTINCT for text columns
- Clear explanation of what went wrong
## Success Criteria
### ✅ Core Functionality
- [ ] Raw columns show individual rows (no forced aggregation)
- [ ] Aggregated columns show proper totals
- [ ] Mixed columns group correctly
- [ ] No unexpected SUM() wrapping of text columns
### ✅ Preview Quality
- [ ] Table previews show more than 5 columns
- [ ] Column headers not truncated at 15 characters
- [ ] Numbers formatted with thousands separators
- [ ] Dynamic column widths based on content
- [ ] Summary information at bottom
### ✅ Error Handling
- [ ] Invalid columns give helpful suggestions
- [ ] Invalid aggregations provide clear guidance
- [ ] Error messages include available options
- [ ] Fuzzy matching suggests corrections
## Quick Validation Commands
Use these MCP tool calls to quickly test the fixes:
```bash
# Test 1: Raw columns
generate_chart with dataset_id and raw columns only
# Test 2: Aggregated columns
generate_chart with dataset_id and aggregated columns only
# Test 3: Mixed columns
generate_chart with dataset_id mixing raw and aggregated
# Test 4: Invalid column
generate_chart with invalid column name (expect helpful error)
```
## Regression Tests
Ensure these still work:
- [ ] Chart screenshots still generate
- [ ] Explore page screenshots still work
- [ ] Other chart types (XY charts) unaffected
- [ ] Filters still work with table charts
## Notes for Testing
1. **Use a dataset with multiple rows and columns** for best results
2. **Check both preview and actual chart generation**
3. **Test with different data types** (text, numbers, dates)
4. **Verify error messages are user-friendly**
5. **Confirm no performance regression**
---
**Expected Test Duration**: 10-15 minutes
**Pass Criteria**: All ✅ checkboxes completed successfully

View File

@@ -0,0 +1,262 @@
# WebDriver Connection Pooling for Screenshot Performance
This document describes the WebDriver connection pooling implementation that significantly improves screenshot generation performance in the Superset MCP service.
## Problem Statement
Previously, each screenshot request would:
1. Create a new WebDriver instance (browser startup: 2-5 seconds)
2. Navigate to the URL and take screenshot
3. Destroy the WebDriver instance (browser shutdown: 1-2 seconds)
This resulted in **3-7 seconds overhead per screenshot**, making the service slow and resource-intensive.
## Solution: WebDriver Connection Pooling
The pooling solution reuses WebDriver instances across requests, reducing screenshot generation time by **80-90%**.
### Key Components
#### 1. WebDriverPool (`webdriver_pool.py`)
- **Thread-safe** connection pool for WebDriver instances
- **Automatic health checking** and recovery of browser instances
- **TTL-based expiration** to prevent memory leaks
- **Usage-based rotation** to prevent browser degradation
- **Configurable pool size** and behavior
#### 2. PooledScreenshot Classes (`pooled_screenshot.py`)
- `PooledBaseScreenshot` - Base class with pooling logic
- `PooledChartScreenshot` - Drop-in replacement for `ChartScreenshot`
- `PooledExploreScreenshot` - Enhanced explore page screenshots with UI hiding
- `PooledDashboardScreenshot` - Dashboard screenshot support
#### 3. Configuration (`webdriver_config.py`)
- Pre-configured settings for different traffic levels
- Environment-specific optimizations
- Monitoring and debugging utilities
## Performance Improvements
| Metric | Before (No Pool) | After (With Pool) | Improvement |
|--------|------------------|-------------------|-------------|
| First screenshot | 5-7 seconds | 5-7 seconds | Same (cold start) |
| Subsequent screenshots | 5-7 seconds | 0.5-1 second | **85-90% faster** |
| Resource usage | High (constant browser startup/shutdown) | Low (reused browsers) | **70-80% reduction** |
| Concurrent requests | Limited by startup time | Higher throughput | **3-5x improvement** |
## Configuration Options
### Basic Configuration
```python
# In superset_config.py
WEBDRIVER_POOL = {
"MAX_POOL_SIZE": 5, # Maximum browsers in pool
"MAX_AGE_SECONDS": 3600, # Browser lifetime (1 hour)
"MAX_USAGE_COUNT": 50, # Max reuses before recreation
"IDLE_TIMEOUT_SECONDS": 300, # Idle timeout (5 minutes)
"HEALTH_CHECK_INTERVAL": 60, # Health check frequency
}
```
### Environment-Specific Configurations
#### Development
```python
from superset.mcp_service.webdriver_config import configure_for_environment
configure_for_environment(config, "development")
```
- Small pool size (2 browsers)
- Short lifetimes for faster iteration
- Frequent health checks
#### Production - Low Traffic
```python
configure_for_environment(config, "low_traffic")
```
- Conservative resource usage
- Longer idle timeouts
- 2-3 browsers maximum
#### Production - High Traffic
```python
configure_for_environment(config, "high_traffic")
```
- Larger pool (10 browsers)
- Extended lifetimes
- Optimized for throughput
## Usage Examples
### Before (Original Implementation)
```python
from superset.utils.screenshots import ChartScreenshot
# Creates new browser, takes screenshot, destroys browser (slow)
screenshot = ChartScreenshot(chart_url, chart.digest)
image_data = screenshot.get_screenshot(user=g.user)
```
### After (Pooled Implementation)
```python
from superset.mcp_service.pooled_screenshot import PooledChartScreenshot
# Reuses browser from pool (fast)
screenshot = PooledChartScreenshot(chart_url, chart.digest)
image_data = screenshot.get_screenshot(user=g.user)
```
### Context Manager Usage (Advanced)
```python
from superset.mcp_service.webdriver_pool import get_webdriver_pool
pool = get_webdriver_pool()
with pool.get_driver((800, 600), user_id=user.id) as driver:
# Driver is authenticated and ready to use
driver.get(url)
screenshot = driver.get_screenshot_as_png()
# Driver automatically returned to pool
```
## Monitoring and Debugging
### Pool Statistics
```python
from superset.mcp_service.webdriver_pool import get_webdriver_pool
pool = get_webdriver_pool()
stats = pool.get_stats()
print(stats)
# Output:
# {
# "pool_size": 3,
# "active_count": 1,
# "created": 15,
# "destroyed": 12,
# "borrowed": 150,
# "returned": 149,
# "health_check_failures": 2,
# "evictions": 5
# }
```
### Health Monitoring
The pool automatically:
- **Health checks** browsers every minute
- **Evicts** unhealthy or expired browsers
- **Recreates** browsers as needed
- **Logs** all pool operations for debugging
### Debug Endpoint (Optional)
```python
from superset.mcp_service.webdriver_config import get_pool_stats_endpoint
# Register debug endpoint
app.route('/debug/webdriver-pool')(get_pool_stats_endpoint())
```
## Architecture Integration
### MCP Service Integration
The pooled screenshots are integrated into:
- `serve_chart_screenshot()` - Chart screenshot endpoint
- `serve_explore_screenshot()` - Explore screenshot endpoint
- `get_chart_preview` tool - Chart preview generation
- `generate_chart` tool - Chart creation with previews
### Backward Compatibility
- **Drop-in replacement** for existing screenshot classes
- **Same API** as original implementations
- **No breaking changes** to existing code
## Resource Management
### Memory Management
- **Automatic cleanup** of expired browsers
- **Configurable limits** on pool size
- **Usage tracking** to prevent memory leaks
### Error Handling
- **Graceful degradation** if pool is unavailable
- **Automatic recovery** from browser crashes
- **Fallback** to single-use browsers if needed
### Shutdown Handling
```python
from superset.mcp_service.webdriver_pool import shutdown_webdriver_pool
# Clean shutdown (call during app teardown)
shutdown_webdriver_pool()
```
## Best Practices
### Pool Sizing
- **Start small** (2-3 browsers) and monitor
- **Scale up** based on concurrent screenshot requests
- **Consider memory** (each browser uses ~100-200MB)
### Health Monitoring
- **Monitor pool statistics** regularly
- **Watch for** high eviction rates (indicates configuration issues)
- **Alert on** health check failures
### Configuration Tuning
- **Development**: Use short lifetimes for faster iteration
- **Low traffic**: Conservative settings to save resources
- **High traffic**: Larger pools and longer lifetimes
- **Debugging**: Enable more frequent health checks
## Security Considerations
### User Isolation
- WebDriver instances are **not shared between users**
- Each request gets a **fresh authentication**
- **No cross-user data leakage** through browser state
### Resource Limits
- **Pool size limits** prevent resource exhaustion
- **TTL limits** prevent indefinite resource holding
- **Health checks** detect and remove compromised browsers
## Troubleshooting
### Common Issues
#### Pool Not Creating Browsers
- Check WebDriver configuration (`WEBDRIVER_TYPE`, etc.)
- Verify browser binaries are installed
- Check system resources (memory, CPU)
#### High Eviction Rates
- Increase `MAX_AGE_SECONDS` or `MAX_USAGE_COUNT`
- Check for memory pressure
- Monitor browser health
#### Performance Not Improving
- Verify pooled classes are being used
- Check pool statistics for reuse rates
- Ensure adequate pool size for load
### Debug Steps
1. **Check pool stats** to see activity
2. **Enable debug logging** for WebDriver operations
3. **Monitor system resources** during operation
4. **Test with single browser** to isolate issues
## Future Enhancements
### Potential Improvements
- **Multi-window support** for parallel screenshots
- **Browser-specific pools** (Chrome vs Firefox)
- **Dynamic scaling** based on load
- **Persistent pools** across service restarts
- **Integration with container orchestration**
### Metrics Integration
- **Prometheus metrics** for pool statistics
- **Performance tracking** for screenshot timing
- **Alert integration** for pool health
---
This WebDriver pooling implementation provides significant performance improvements while maintaining reliability and security. The modular design allows for easy configuration and monitoring in production environments.

View File

@@ -0,0 +1,32 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# superset/mcp_service/__init__.py
"""
Model Context Protocol (MCP) service for Apache Superset.
This service provides a structured interface for AI agents to interact with Superset's
core functionality through well-defined, high-level actions.
The service runs as a standalone server.
To start the service, run:
superset mcp run
"""
__version__ = "0.1.0"

Some files were not shown because too many files have changed in this diff Show More