Compare commits

..
Author SHA1 Message Date
Vitor Avila d40a5cad5d fix(OAuth2): Re-query the OAuth2 token to avoid stale reference (#40071) 2026-05-18 13:07:54 -03:00
Evan RusackasandClaude 38546d7a3d chore(deps): coordinated bump ag-grid-community + ag-grid-react 35.2.1→35.3.0 (#40205)
Co-authored-by: Claude <claude@anthropic.com>
2026-05-18 22:18:37 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6e5dfa0dd4 chore(deps): bump baseline-browser-mapping from 2.10.29 to 2.10.30 in /docs (#40211)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:14:27 +07:00
SkinnyPigeon 70419e9d8f feat: Allow specific mcp tools to be disabled (#39835) 2026-05-18 07:22:02 -07:00
Evan RusackasandClaude Code 34281f54a6 test(prophet): pin yhat_lower can be negative for negative series (#21734) (#40141)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-18 07:21:04 -07:00
Evan RusackasandClaude Code 53d5c41a72 test(security): regression test for session cookie after logout (#24713) (#40201)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-18 07:20:51 -07:00
Evan RusackasandClaude Code 453f49ce33 test(api): regression test for Admin empty dashboard/chart list (#25890) (#40202)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-18 07:20:37 -07:00
b66c104fde fix(sqllab): execute prequeries on streaming connection to fix PostgreSQL CSV export (#40194)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:43:06 -04:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Claude
61b77fa35d chore(deps-dev): bump ip-address from 10.1.0 to 10.2.0 in /superset-frontend (#40199)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-18 06:29:05 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0da0767780 chore(deps-dev): bump eslint from 10.3.0 to 10.4.0 in /superset-websocket (#40208)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 06:28:43 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e2ff2d5d41 chore(deps): bump reselect from 5.1.1 to 5.2.0 in /docs (#40209)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 06:28:25 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6a6be4c385 chore(deps): bump antd from 6.4.2 to 6.4.3 in /docs (#40210)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 06:28:00 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cf831388d8 chore(deps): bump caniuse-lite from 1.0.30001792 to 1.0.30001793 in /docs (#40212)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 06:27:36 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 684a66aee6 chore(deps): update zod requirement from ^4.4.1 to ^4.4.3 in /superset-frontend/plugins/plugin-chart-echarts (#40215)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 06:27:09 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 80a200820c chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40217)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 06:26:50 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f47300102c chore(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#40218)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 06:26:20 -07:00
Alejandro Solares dd523c1a7b fix(deps): patch fast-xml-parser CVE-2026-33036 and CVE-2026-33349 (#40118) 2026-05-18 08:30:17 +01:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Claude
02a8196a6d chore(deps): update dompurify requirement from ^3.4.1 to ^3.4.2 in /superset-frontend/packages/superset-ui-core (#39808)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-17 20:16:45 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>ClaudeClaude Sonnet 4.6
4e13512ed8 chore(deps-dev): update jest requirement from ^30.3.0 to ^30.4.2 in /superset-frontend/plugins/plugin-chart-handlebars (#40015)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:16:14 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>ClaudeClaude Sonnet 4.6
268dadbb5b chore(deps-dev): update jest requirement from ^30.3.0 to ^30.4.2 in /superset-frontend/plugins/plugin-chart-pivot-table (#40018)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:15:58 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>ClaudeClaude Sonnet 4.6
427e7e53cd chore(deps-dev): update jest requirement from ^30.3.0 to ^30.4.2 in /superset-frontend/packages/generator-superset (#40019)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:15:44 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Claude
78f54b68ac chore(deps): update dompurify requirement from ^3.4.1 to ^3.4.3 in /superset-frontend/plugins/legacy-preset-chart-nvd3 (#40106)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-17 20:15:07 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6c4c3dc71c chore(deps): bump serialize-javascript and terser-webpack-plugin in /superset-frontend/cypress-base (#40174)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 20:13:36 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 26925af9ed chore(deps): bump minimatch from 3.1.3 to 3.1.5 in /superset-frontend/cypress-base (#40198)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 19:59:44 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Claude
fdb62d8f35 chore(deps): bump yeoman-generator from 8.1.2 to 8.2.2 in /superset-frontend (#40154)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-17 19:59:29 -07:00
Evan RusackasandClaude Code 3a9c54a672 fix(date_parser): suppress noisy parsedatetime DEBUG logs (#33365) (#40144)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-17 19:58:08 -07:00
Evan RusackasandClaude Code e6755d508d fix(rls): align view permission name with REST API canonical name (#33744) (#40145)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-17 19:57:57 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b09ef7a406 chore(deps): bump minimatch from 3.1.2 to 3.1.5 in /superset-embedded-sdk (#40176)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:27:28 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9eecc5a2a6 chore(deps): bump axios from 1.15.0 to 1.16.1 in /docs (#40177)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:26:44 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d308649a65 chore(deps-dev): bump @types/node from 25.7.0 to 25.8.0 in /superset-frontend (#40157)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:25:08 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dd4e2e2e44 chore(deps-dev): update sqlalchemy-exasol requirement from <3.0,>=2.4.0 to >=2.4.0,<8.0 (#40182)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:24:53 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6165a2531f chore(deps): bump fast-uri from 3.0.6 to 3.1.2 in /superset-frontend (#40175)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:24:24 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9439d4db09 chore(deps-dev): update clickhouse-connect requirement from <1.0,>=0.13.0 to >=0.13.0,<2.0 (#40184)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:24:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6b425ab559 chore(deps-dev): bump hdbcli from 2.4.162 to 2.28.20 (#40185)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:23:48 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4ded665495 chore(deps): bump flask-migrate from 3.1.0 to 4.1.0 (#40187)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:23:23 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 37638e750d chore(deps): bump greenlet from 3.1.1 to 3.5.0 (#40188)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 10:23:06 -07:00
Evan RusackasandClaude Code 8a86ab7319 chore(docs): rename default docs plugin to user_docs for consistent versioned dir naming (#40171)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-15 22:26:43 -07:00
81 changed files with 5107 additions and 358 deletions
+2 -2
View File
@@ -41,7 +41,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,6 +53,6 @@ jobs:
- name: Perform CodeQL Analysis
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
category: "/language:${{matrix.language}}"
+12 -9
View File
@@ -52,11 +52,11 @@ yarn serve # Serve built site locally
# For maximum-detail databases.json, drop the `database-diagnostics`
# artifact from Python-Integration CI at src/data/databases.json before
# cutting. See README.md "Before You Cut".
yarn version:add:docs <version> # Add new docs version
yarn version:add:user_docs <version> # Add new docs version
yarn version:add:admin_docs <version> # Add admin docs version
yarn version:add:developer_docs <version> # Add developer docs version
yarn version:add:components <version> # Add components version
yarn version:remove:docs <version> # Remove docs version
yarn version:remove:user_docs <version> # Remove docs version
yarn version:remove:admin_docs <version> # Remove admin docs version
yarn version:remove:developer_docs <version> # Remove developer docs version
yarn version:remove:components <version> # Remove components version
@@ -228,17 +228,20 @@ Versions are managed through `versions-config.json`:
```bash
# ✅ CORRECT - Updates both Docusaurus and versions-config.json
yarn version:add:docs 6.1.0
yarn version:add:user_docs 6.1.0
# ❌ WRONG - Only updates Docusaurus, breaks version dropdown
yarn docusaurus docs:version 6.1.0
```
### Version Files Created
When versioning, these files are created:
- `versioned_docs/version-X.X.X/` - Snapshot of current docs
- `versioned_sidebars/version-X.X.X-sidebars.json` - Sidebar config
- `versions.json` - List of all versions
When versioning, these files are created (per section, with the
section's plugin id as prefix):
- `<section>_versioned_docs/version-X.X.X/` - Snapshot of current docs
- `<section>_versioned_sidebars/version-X.X.X-sidebars.json` - Sidebar config
- `<section>_versions.json` - List of all versions
Section plugin ids: `user_docs`, `admin_docs`, `developer_docs`, `components`.
## 🎨 Styling and Theming
@@ -386,7 +389,7 @@ Docusaurus includes Algolia DocSearch integration configured in `docusaurus.conf
## 🚫 Common Pitfalls to Avoid
1. **Never use `yarn docusaurus docs:version`** - Use `yarn version:add:docs` instead
1. **Never use `yarn docusaurus docs:version`** - Use `yarn version:add:user_docs` instead
2. **Don't edit versioned docs directly** - Edit current docs and create new version
3. **Avoid absolute paths in links** - Use relative paths for maintainability
4. **Don't forget frontmatter** - Every doc needs title and description
@@ -416,7 +419,7 @@ yarn eslint
### Version Issues
If versions don't appear in dropdown:
1. Check `versions-config.json` includes the version
2. Verify version files exist in `versioned_docs/`
2. Verify version files exist in `<section>_versioned_docs/`
3. Restart dev server
## 📚 Resources
+8 -8
View File
@@ -53,7 +53,7 @@ Also: confirm `master` CI is green, and that your local checkout matches the SHA
```bash
# Main Documentation
yarn version:add:docs 1.2.0
yarn version:add:user_docs 1.2.0
# Admin Docs
yarn version:add:admin_docs 1.2.0
@@ -98,7 +98,7 @@ If creating versions manually, you'll need to:
- **Versioned sidebars**: `[section]_versioned_sidebars/version-X.X.X-sidebars.json`
- **Versions list**: `[section]_versions.json`
Note: For main docs, the prefix is omitted (e.g., `versioned_docs/` instead of `docs_versioned_docs/`)
All four sections (`user_docs`, `admin_docs`, `developer_docs`, `components`) follow this naming pattern uniformly.
3. **Important**: After adding a version, restart the development server to see changes:
```bash
@@ -111,7 +111,7 @@ If creating versions manually, you'll need to:
#### Using Automated Scripts (Recommended)
```bash
# Main Documentation
yarn version:remove:docs 1.0.0
yarn version:remove:user_docs 1.0.0
# Admin Docs
yarn version:remove:admin_docs 1.0.0
@@ -127,19 +127,19 @@ yarn version:remove:components 1.0.0
To manually remove a version:
1. **Delete the version folder** from the appropriate location:
- Main docs: `versioned_docs/version-X.X.X/` (no prefix for main)
- User Docs: `user_docs_versioned_docs/version-X.X.X/`
- Admin Docs: `admin_docs_versioned_docs/version-X.X.X/`
- Developer Docs: `developer_docs_versioned_docs/version-X.X.X/`
- Components: `components_versioned_docs/version-X.X.X/`
2. **Delete the version metadata file**:
- Main docs: `versioned_sidebars/version-X.X.X-sidebars.json` (no prefix)
- User Docs: `user_docs_versioned_sidebars/version-X.X.X-sidebars.json`
- Admin Docs: `admin_docs_versioned_sidebars/version-X.X.X-sidebars.json`
- Developer Docs: `developer_docs_versioned_sidebars/version-X.X.X-sidebars.json`
- Components: `components_versioned_sidebars/version-X.X.X-sidebars.json`
3. **Update the versions list file**:
- Main docs: `versions.json`
- User Docs: `user_docs_versions.json`
- Admin Docs: `admin_docs_versions.json`
- Developer Docs: `developer_docs_versions.json`
- Components: `components_versions.json`
@@ -212,8 +212,8 @@ docs: {
If you accidentally used `yarn docusaurus docs:version` instead of `yarn version:add`:
1. **Problem**: The version files were created but `versions-config.json` wasn't updated
2. **Solution**: Either:
- Revert the changes: `git restore versions.json && rm -rf versioned_docs/ versioned_sidebars/`
- Then use the correct command: `yarn version:add:docs <version>`
- Revert the changes: `git restore user_docs_versions.json && rm -rf user_docs_versioned_docs/ user_docs_versioned_sidebars/`
- Then use the correct command: `yarn version:add:user_docs <version>`
For other issues:
- **Restart the server**: Changes to version configuration require a server restart
@@ -502,6 +502,7 @@ All MCP settings go in `superset_config.py`. Defaults are defined in `superset/m
| `MCP_DEBUG` | `False` | Enable debug logging |
| `MCP_DEV_USERNAME` | -- | Superset username for development mode (no auth) |
| `MCP_RBAC_ENABLED` | `True` | Enforce Superset's role-based access control on MCP tool calls. When `True`, each tool checks that the authenticated user has the required FAB permission before executing. Disable only for testing or trusted-network deployments. |
| `MCP_DISABLED_TOOLS` | `set()` | Set of tool names to remove from the MCP server at startup. Disabled tools are never advertised to AI clients during tool discovery. Useful when a custom extension tool should replace a built-in Superset tool. See [Disabling built-in tools](#disabling-built-in-tools). |
### Authentication
@@ -825,6 +826,32 @@ while True:
page += 1
```
## Disabling built-in tools
If you have deployed a custom tool via a Superset extension that supersedes one of the built-in Superset tools, you can suppress the built-in version so AI clients only discover your replacement. Disabled tools are removed from the server at startup and are never advertised during tool discovery.
Set `MCP_DISABLED_TOOLS` in your `superset_config.py` to a set of tool names:
```python
# superset_config.py
# Disable one tool
MCP_DISABLED_TOOLS = {"execute_sql"}
# Disable multiple tools
MCP_DISABLED_TOOLS = {"execute_sql", "health_check"}
```
Tool names match the function name used in the `@tool` decorator (e.g., `execute_sql`, `list_charts`, `health_check`). Extension-prefixed tools can also be disabled using their full prefixed name:
```python
MCP_DISABLED_TOOLS = {"extensions.myorg.myextension.some_tool"}
```
:::note
Specifying a tool name that does not exist logs a warning at startup and is otherwise ignored — it will not prevent the server from starting.
:::
## Security Best Practices
- **Use TLS** for all production MCP endpoints -- place the server behind a reverse proxy with HTTPS
+42 -23
View File
@@ -36,6 +36,42 @@ const versionsConfig = JSON.parse(fs.readFileSync(versionsConfigPath, 'utf8'));
// Build plugins array dynamically based on disabled flags
const dynamicPlugins = [];
// Add user_docs (formerly the preset-classic default docs instance) as an
// explicit plugin instance, so its versioned dirs follow the same
// `<id>_versioned_docs` / `<id>_versioned_sidebars` / `<id>_versions.json`
// naming as the other sections instead of the bare `versioned_*` prefix
// Docusaurus uses for the default plugin id.
if (!versionsConfig.user_docs.disabled) {
dynamicPlugins.push([
'@docusaurus/plugin-content-docs',
{
id: 'user_docs',
path: 'docs',
routeBasePath: 'user-docs',
sidebarPath: require.resolve('./sidebars.js'),
editUrl: ({ versionDocsDirPath, docPath }: { versionDocsDirPath: string; docPath: string }) => {
if (docPath === 'intro.md') {
return 'https://github.com/apache/superset/edit/master/README.md';
}
return `https://github.com/apache/superset/edit/master/docs/${versionDocsDirPath}/${docPath}`;
},
remarkPlugins: [remarkImportPartial, remarkLocalizeBadges, remarkTechArticleSchema],
admonitions: {
keywords: ['note', 'tip', 'info', 'warning', 'danger', 'resources'],
extendDefaults: true,
},
docItemComponent: '@theme/DocItem',
includeCurrentVersion: versionsConfig.user_docs.includeCurrentVersion,
lastVersion: versionsConfig.user_docs.lastVersion,
onlyIncludeVersions: versionsConfig.user_docs.onlyIncludeVersions,
versions: versionsConfig.user_docs.versions,
disableVersioning: false,
showLastUpdateAuthor: true,
showLastUpdateTime: true,
},
]);
}
// Add components plugin if not disabled
if (!versionsConfig.components.disabled) {
dynamicPlugins.push([
@@ -703,29 +739,12 @@ const config: Config = {
[
'@docusaurus/preset-classic',
{
docs: {
routeBasePath: 'user-docs',
sidebarPath: require.resolve('./sidebars.js'),
editUrl: ({ versionDocsDirPath, docPath }) => {
if (docPath === 'intro.md') {
return 'https://github.com/apache/superset/edit/master/README.md';
}
return `https://github.com/apache/superset/edit/master/docs/${versionDocsDirPath}/${docPath}`;
},
remarkPlugins: [remarkImportPartial, remarkLocalizeBadges, remarkTechArticleSchema],
admonitions: {
keywords: ['note', 'tip', 'info', 'warning', 'danger', 'resources'],
extendDefaults: true,
},
includeCurrentVersion: versionsConfig.docs.includeCurrentVersion,
lastVersion: versionsConfig.docs.lastVersion, // Make 'next' the default
onlyIncludeVersions: versionsConfig.docs.onlyIncludeVersions,
versions: versionsConfig.docs.versions,
disableVersioning: false,
showLastUpdateAuthor: true,
showLastUpdateTime: true,
docItemComponent: '@theme/DocItem',
},
// The user-docs section is configured as an explicit plugin
// instance above (id: 'user_docs') rather than the preset's
// default docs slot, so that all four sections use parallel
// `<id>_versioned_docs` / `<id>_versioned_sidebars` /
// `<id>_versions.json` naming on disk.
docs: false,
blog: {
showReadingTime: true,
// Please change this to your repo.
+6 -6
View File
@@ -33,11 +33,11 @@
"lint:docs-links": "node scripts/lint-docs-links.mjs",
"version:add": "node scripts/manage-versions.mjs add",
"version:remove": "node scripts/manage-versions.mjs remove",
"version:add:docs": "node scripts/manage-versions.mjs add docs",
"version:add:user_docs": "node scripts/manage-versions.mjs add user_docs",
"version:add:admin_docs": "node scripts/manage-versions.mjs add admin_docs",
"version:add:developer_docs": "node scripts/manage-versions.mjs add developer_docs",
"version:add:components": "node scripts/manage-versions.mjs add components",
"version:remove:docs": "node scripts/manage-versions.mjs remove docs",
"version:remove:user_docs": "node scripts/manage-versions.mjs remove user_docs",
"version:remove:admin_docs": "node scripts/manage-versions.mjs remove admin_docs",
"version:remove:developer_docs": "node scripts/manage-versions.mjs remove developer_docs",
"version:remove:components": "node scripts/manage-versions.mjs remove components"
@@ -71,9 +71,9 @@
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.33",
"antd": "^6.4.2",
"baseline-browser-mapping": "^2.10.29",
"caniuse-lite": "^1.0.30001792",
"antd": "^6.4.3",
"baseline-browser-mapping": "^2.10.30",
"caniuse-lite": "^1.0.30001793",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
"js-yaml": "^4.1.1",
@@ -87,7 +87,7 @@
"react-svg-pan-zoom": "^3.13.1",
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.1.1",
"reselect": "^5.2.0",
"storybook": "^8.6.18",
"swagger-ui-react": "^5.32.5",
"swc-loader": "^0.2.7",
+15 -27
View File
@@ -34,7 +34,7 @@ const rawArgs = process.argv.slice(2);
const skipGenerate = rawArgs.includes('--skip-generate');
const args = rawArgs.filter((a) => a !== '--skip-generate');
const command = args[0]; // 'add' or 'remove'
const section = args[1]; // 'docs', 'admin_docs', 'developer_docs', or 'components'
const section = args[1]; // 'user_docs', 'admin_docs', 'developer_docs', or 'components'
const version = args[2]; // version string like '1.2.0'
function loadConfig() {
@@ -54,13 +54,13 @@ function freezeDataImports(section, version) {
// historical version's content silently changes whenever the data file
// is updated. Copy each escaping data import into a snapshot-local
// `_versioned_data/` dir and rewrite the import to point there.
const sectionRoot = section === 'docs'
? path.join(__dirname, '..', 'docs')
: path.join(__dirname, '..', section);
// The user_docs section's source content lives in `docs/docs/` (the
// historical folder name), while admin_docs / developer_docs /
// components match their plugin id 1:1.
const sectionDir = section === 'user_docs' ? 'docs' : section;
const sectionRoot = path.join(__dirname, '..', sectionDir);
const docsRoot = path.join(__dirname, '..');
const versionedDocsDir = section === 'docs'
? `versioned_docs/version-${version}`
: `${section}_versioned_docs/version-${version}`;
const versionedDocsDir = `${section}_versioned_docs/version-${version}`;
const versionedDocsPath = path.join(__dirname, '..', versionedDocsDir);
const frozenDataDir = path.join(versionedDocsPath, '_versioned_data');
@@ -148,9 +148,7 @@ function fixVersionedImports(section, version) {
// Versioned content lands one directory deeper than the source content,
// so any `../../src/` or `../../data/` imports in .md/.mdx files need
// an extra `../` to keep reaching docs/src and docs/data.
const versionedDocsDir = section === 'docs'
? `versioned_docs/version-${version}`
: `${section}_versioned_docs/version-${version}`;
const versionedDocsDir = `${section}_versioned_docs/version-${version}`;
const versionedDocsPath = path.join(__dirname, '..', versionedDocsDir);
if (!fs.existsSync(versionedDocsPath)) {
@@ -238,9 +236,7 @@ function addVersion(section, version) {
}
// Run Docusaurus version command
const docusaurusCommand = section === 'docs'
? `yarn docusaurus docs:version ${version}`
: `yarn docusaurus docs:version:${section} ${version}`;
const docusaurusCommand = `yarn docusaurus docs:version:${section} ${version}`;
try {
execSync(docusaurusCommand, { stdio: 'inherit' });
@@ -262,10 +258,9 @@ function addVersion(section, version) {
config[section].onlyIncludeVersions.splice(versionIndex, 0, version);
// Add version metadata
const versionPath = section === 'docs' ? version : version;
config[section].versions[version] = {
label: version,
path: versionPath,
path: version,
banner: 'none'
};
@@ -305,13 +300,8 @@ function removeVersion(section, version) {
console.log(`Removing version ${version} from ${section}...`);
// Determine file paths based on section
const versionedDocsDir = section === 'docs'
? `versioned_docs/version-${version}`
: `${section}_versioned_docs/version-${version}`;
const versionedSidebarsFile = section === 'docs'
? `versioned_sidebars/version-${version}-sidebars.json`
: `${section}_versioned_sidebars/version-${version}-sidebars.json`;
const versionedDocsDir = `${section}_versioned_docs/version-${version}`;
const versionedSidebarsFile = `${section}_versioned_sidebars/version-${version}-sidebars.json`;
// Remove versioned files
const docsPath = path.join(__dirname, '..', versionedDocsDir);
@@ -328,9 +318,7 @@ function removeVersion(section, version) {
}
// Update versions.json file
const versionsJsonFile = section === 'docs'
? 'versions.json'
: `${section}_versions.json`;
const versionsJsonFile = `${section}_versions.json`;
const versionsJsonPath = path.join(__dirname, '..', versionsJsonFile);
if (fs.existsSync(versionsJsonPath)) {
@@ -377,14 +365,14 @@ Usage:
node scripts/manage-versions.mjs remove <section> <version>
Where:
- section: 'docs', 'developer_docs', 'admin_docs', or 'components'
- section: 'user_docs', 'admin_docs', 'developer_docs', or 'components'
- version: version string (e.g., '1.2.0', '2.0.0')
- --skip-generate: skip refreshing auto-generated docs before snapshotting
(use when you've already placed a fresh databases.json
from CI and want to preserve it)
Examples:
node scripts/manage-versions.mjs add docs 2.0.0
node scripts/manage-versions.mjs add user_docs 2.0.0
node scripts/manage-versions.mjs add developer_docs 1.3.0
node scripts/manage-versions.mjs remove components 1.0.0
`);
+6 -8
View File
@@ -31,17 +31,15 @@ import { DownOutlined } from '@ant-design/icons';
import styles from './styles.module.css';
// Map each versioned plugin id to the URL prefix it actually serves
// content from. Three of the four routeBasePath values differ from
// their pluginId — the default preset-classic docs plugin lives at
// `/user-docs`, and admin_docs / developer_docs use hyphens in their
// URLs even though the plugin ids use underscores. Without this map
// the basePath derivation below would mis-split the pathname for
// those sections and the version dropdown would jump to the section
// root instead of preserving the current page.
// content from. The plugin ids use underscores while several
// routeBasePath values use hyphens (and `user_docs` → `/user-docs`),
// so without this map the basePath derivation below would mis-split
// the pathname for those sections and the version dropdown would
// jump to the section root instead of preserving the current page.
//
// Keep in sync with the `routeBasePath` values in docusaurus.config.ts.
const PLUGIN_ID_TO_BASE_PATH = {
default: '/user-docs',
user_docs: '/user-docs',
components: '/components',
admin_docs: '/admin-docs',
developer_docs: '/developer-docs',
+1 -1
View File
@@ -1,5 +1,5 @@
{
"docs": {
"user_docs": {
"disabled": false,
"lastVersion": "current",
"includeCurrentVersion": true,
+40 -24
View File
@@ -3078,7 +3078,7 @@
dependencies:
"@rc-component/util" "^1.2.0"
"@rc-component/notification@~2.0.6":
"@rc-component/notification@~2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@rc-component/notification/-/notification-2.0.7.tgz#f2450a482f87e4698285833c4a8efcac169acabb"
integrity sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==
@@ -5342,6 +5342,13 @@ address@^1.0.1:
resolved "https://registry.npmjs.org/address/-/address-1.2.2.tgz"
integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==
agent-base@6:
version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
dependencies:
debug "4"
aggregate-error@^3.0.0:
version "3.1.0"
resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz"
@@ -5491,10 +5498,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
antd@^6.4.2:
version "6.4.2"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.4.2.tgz#9fc0fee455a5c56e7ec27855495eefadc8df636a"
integrity sha512-PNJz8Vxc/mC3EsOg/h3e2YuaZduJ1RDp4RmySDuDmKPCxVgyp4Da4kB36o87p9hbLbOWdAWCKQlnyopsN8utKQ==
antd@^6.4.3:
version "6.4.3"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.4.3.tgz#80a7aab9c13c35daa0e0e7eea80585ba57cb7203"
integrity sha512-6H2avkxCGfxcF67r3J2mwm9Ck50el1pks/73vfM1wDsPL/tPtj5vHuauMgJFnrqmq7CH3g8aoZ0VBQbt+jpAsw==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -5518,7 +5525,7 @@ antd@^6.4.2:
"@rc-component/menu" "~1.3.0"
"@rc-component/motion" "^1.3.2"
"@rc-component/mutate-observer" "^2.0.1"
"@rc-component/notification" "~2.0.6"
"@rc-component/notification" "~2.0.7"
"@rc-component/pagination" "~1.2.0"
"@rc-component/picker" "~1.10.0"
"@rc-component/progress" "~1.0.2"
@@ -5538,7 +5545,7 @@ antd@^6.4.2:
"@rc-component/tree-select" "~1.9.0"
"@rc-component/trigger" "^3.9.0"
"@rc-component/upload" "~1.1.0"
"@rc-component/util" "^1.10.1"
"@rc-component/util" "^1.11.0"
clsx "^2.1.1"
dayjs "^1.11.11"
scroll-into-view-if-needed "^3.1.0"
@@ -5726,12 +5733,13 @@ available-typed-arrays@^1.0.7:
possible-typed-array-names "^1.0.0"
axios@^1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f"
integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==
version "1.16.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.1.tgz#517e29291d19d6e8cf919ff264f4fe157261ba12"
integrity sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==
dependencies:
follow-redirects "^1.15.11"
follow-redirects "^1.16.0"
form-data "^4.0.5"
https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0"
babel-loader@^9.2.1:
@@ -5802,10 +5810,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.29, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.29"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz#47bdc13027af28d341f367a4f35a07ce872e27b4"
integrity sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==
baseline-browser-mapping@^2.10.30, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.30"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz#58915c74388b05f3b3504026194ea9fa98f6e6b6"
integrity sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==
batch@0.6.1:
version "0.6.1"
@@ -6043,10 +6051,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001792:
version "1.0.30001792"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5"
integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001793:
version "1.0.30001793"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz#238887ddf5fcfc8c36d872394d0a78a517312a72"
integrity sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==
ccount@^2.0.0:
version "2.0.1"
@@ -8245,7 +8253,7 @@ flatted@^3.2.9:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
follow-redirects@^1.0.0, follow-redirects@^1.15.11:
follow-redirects@^1.0.0, follow-redirects@^1.16.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
@@ -8939,6 +8947,14 @@ http2-wrapper@^2.1.10:
quick-lru "^5.1.1"
resolve-alpn "^1.2.0"
https-proxy-agent@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
dependencies:
agent-base "6"
debug "4"
human-signals@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
@@ -13275,10 +13291,10 @@ reselect@^4.0.0:
resolved "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz"
integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==
reselect@^5.1.0, reselect@^5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz"
integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==
reselect@^5.1.0, reselect@^5.1.1, reselect@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1"
integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==
resize-observer-polyfill@1.5.1:
version "1.5.1"
+5 -5
View File
@@ -53,11 +53,11 @@ dependencies = [
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
"flask-migrate>=3.1.0, <4.0",
"flask-migrate>=3.1.0, <5.0",
"flask-session>=0.4.0, <1.0",
"flask-wtf>=1.1.0, <2.0",
"geopy",
"greenlet>=3.0.3, <=3.1.1",
"greenlet>=3.0.3, <=3.5.0",
"gunicorn>=22.0.0; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
@@ -121,7 +121,7 @@ bigquery = [
"sqlalchemy-bigquery>=1.15.0",
"google-cloud-bigquery>=3.10.0",
]
clickhouse = ["clickhouse-connect>=0.13.0, <1.0"]
clickhouse = ["clickhouse-connect>=0.13.0, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.40.1, <1"]
d1 = [
@@ -143,7 +143,7 @@ duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
exasol = ["sqlalchemy-exasol >= 2.4.0, < 8.0"]
excel = ["xlrd>=1.2.0, <1.3"]
fastmcp = [
"fastmcp>=3.2.4,<4.0",
@@ -156,7 +156,7 @@ firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
hana = ["hdbcli==2.4.162", "sqlalchemy_hana==0.4.0"]
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",
+6 -6
View File
@@ -6756,9 +6756,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -12811,9 +12811,9 @@
"dev": true
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
+39 -50
View File
@@ -6523,9 +6523,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
"integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -7076,15 +7076,6 @@
}
]
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"peer": true,
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/react": {
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
@@ -7554,15 +7545,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"peer": true,
"dependencies": {
"randombytes": "^2.1.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@@ -7924,15 +7906,14 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz",
"integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==",
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
"schema-utils": "^4.3.0",
"serialize-javascript": "^6.0.2",
"terser": "^5.31.1"
},
"engines": {
@@ -7946,12 +7927,39 @@
"webpack": "^5.1.0"
},
"peerDependenciesMeta": {
"@minify-html/node": {
"optional": true
},
"@swc/core": {
"optional": true
},
"@swc/css": {
"optional": true
},
"@swc/html": {
"optional": true
},
"clean-css": {
"optional": true
},
"cssnano": {
"optional": true
},
"csso": {
"optional": true
},
"esbuild": {
"optional": true
},
"html-minifier-terser": {
"optional": true
},
"lightningcss": {
"optional": true
},
"postcss": {
"optional": true
},
"uglify-js": {
"optional": true
}
@@ -13499,9 +13507,9 @@
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
},
"minimatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
"integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -13902,15 +13910,6 @@
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"peer": true,
"requires": {
"safe-buffer": "^5.1.0"
}
},
"react": {
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
@@ -14273,15 +14272,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="
},
"serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"peer": true,
"requires": {
"randombytes": "^2.1.0"
}
},
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@@ -14566,15 +14556,14 @@
}
},
"terser-webpack-plugin": {
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz",
"integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==",
"peer": true,
"requires": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
"schema-utils": "^4.3.0",
"serialize-javascript": "^6.0.2",
"terser": "^5.31.1"
}
},
+3792 -74
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -161,8 +161,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "35.2.1",
"ag-grid-react": "35.2.1",
"ag-grid-community": "35.3.0",
"ag-grid-react": "35.3.0",
"antd": "^5.26.0",
"chrono-node": "^2.9.1",
"classnames": "^2.2.5",
@@ -289,7 +289,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.6.0",
"@types/node": "^25.8.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
@@ -411,7 +411,8 @@
"@luma.gl/engine": "~9.2.5",
"@luma.gl/gltf": "~9.2.5",
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5"
"@luma.gl/webgl": "~9.2.5",
"fast-xml-parser": "^5.8.0"
},
"readme": "ERROR: No README data found!",
"scarfSettings": {
@@ -30,13 +30,13 @@
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.2.2",
"yeoman-generator": "^8.1.2",
"yosay": "^3.0.0"
},
"devDependencies": {
"cross-env": "^10.1.0",
"fs-extra": "^11.3.5",
"jest": "^30.3.0",
"jest": "^30.4.2",
"yeoman-test": "^11.5.2"
},
"engines": {
@@ -30,8 +30,8 @@
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.44.0",
"ag-grid-community": "35.2.1",
"ag-grid-react": "35.2.1",
"ag-grid-community": "35.3.0",
"ag-grid-react": "35.3.0",
"brace": "^0.11.1",
"classnames": "^2.5.1",
"core-js": "^3.49.0",
@@ -42,7 +42,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.20",
"dompurify": "^3.4.1",
"dompurify": "^3.4.2",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
@@ -76,7 +76,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.0",
"@types/lodash": "^4.17.24",
"@types/node": "^25.7.0",
"@types/node": "^25.8.0",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -34,7 +34,7 @@
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.4.1",
"dompurify": "^3.4.3",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},
@@ -29,7 +29,7 @@
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"zod": "^4.4.1"
"zod": "^4.4.3"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -46,6 +46,6 @@
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.24",
"jest": "^30.3.0"
"jest": "^30.4.2"
}
}
@@ -39,6 +39,6 @@
"devDependencies": {
"@babel/types": "^7.29.0",
"@types/jest": "^30.0.0",
"jest": "^30.3.0"
"jest": "^30.4.2"
}
}
@@ -29,7 +29,7 @@
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.23.1",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
"react-map-gl": "^8.1.0",
"supercluster": "^8.0.1"
},
"peerDependencies": {
+15 -15
View File
@@ -27,7 +27,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"eslint": "^10.3.0",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^17.6.0",
@@ -802,9 +802,9 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
"integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2794,16 +2794,16 @@
}
},
"node_modules/eslint": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
"integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.5.5",
"@eslint/config-helpers": "^0.6.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.1",
"@humanfs/node": "^0.16.6",
@@ -7081,9 +7081,9 @@
}
},
"@eslint/config-helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
"integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
"dev": true,
"requires": {
"@eslint/core": "^1.2.1"
@@ -8578,15 +8578,15 @@
"dev": true
},
"eslint": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
"integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.5.5",
"@eslint/config-helpers": "^0.6.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.1",
"@humanfs/node": "^0.16.6",
+1 -1
View File
@@ -35,7 +35,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"eslint": "^10.3.0",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^17.6.0",
@@ -55,20 +55,23 @@ class StreamingCSVExportCommand(BaseStreamingCSVExportCommand):
"""Validate permissions and query context."""
self._query_context.raise_for_access()
def _get_sql_and_database(self) -> tuple[str, Any]:
def _get_sql_and_database(self) -> tuple[str, Any, str | None, str | None]:
"""
Get the SQL query and database for chart export.
Get the SQL query, database, catalog, and schema for chart export.
Returns:
Tuple of (sql_query, database_object)
Tuple of (sql_query, database_object, catalog, schema)
"""
# Get datasource and generate SQL query
# Note: datasource should already be attached to a session from query_context
datasource = self._query_context.datasource
query_obj = self._query_context.queries[0]
sql_query = datasource.get_query_str(query_obj.to_dict())
database = getattr(datasource, "database", None)
catalog = getattr(datasource, "catalog", None)
schema = getattr(datasource, "schema", None)
return sql_query, getattr(datasource, "database", None)
return sql_query, database, catalog, schema
def _get_row_limit(self) -> int | None:
"""
@@ -87,12 +87,12 @@ class StreamingSqlResultExportCommand(BaseStreamingCSVExportCommand):
status=403,
) from ex
def _get_sql_and_database(self) -> tuple[str, Any]:
def _get_sql_and_database(self) -> tuple[str, Any, str | None, str | None]:
"""
Get the SQL query and database for SQL Lab export.
Get the SQL query, database, catalog, and schema for SQL Lab export.
Returns:
Tuple of (sql_query, database_object)
Tuple of (sql_query, database_object, catalog, schema)
"""
assert self._query is not None
@@ -103,7 +103,7 @@ class StreamingSqlResultExportCommand(BaseStreamingCSVExportCommand):
# Get the SQL query
sql = select_sql or executed_sql
return sql, database
return sql, database, self._query.catalog, self._query.schema
def _get_row_limit(self) -> int | None:
"""
+16 -8
View File
@@ -79,12 +79,12 @@ class BaseStreamingCSVExportCommand(BaseCommand):
self._current_app = app._get_current_object()
@abstractmethod
def _get_sql_and_database(self) -> tuple[str, Any]:
def _get_sql_and_database(self) -> tuple[str, Any, str | None, str | None]:
"""
Get the SQL query and database for execution.
Get the SQL query, database, catalog, and schema for execution.
Returns:
Tuple of (sql_query, database_object)
Tuple of (sql_query, database_object, catalog, schema)
"""
@abstractmethod
@@ -150,7 +150,12 @@ class BaseStreamingCSVExportCommand(BaseCommand):
yield remaining_data, row_count, data_bytes
def _execute_query_and_stream(
self, sql: str, database: Any, limit: int | None
self,
sql: str,
database: Any,
limit: int | None,
catalog: str | None = None,
schema: str | None = None,
) -> Generator[str, None, None]:
"""Execute query with streaming and yield CSV chunks."""
start_time = time.time()
@@ -160,8 +165,9 @@ class BaseStreamingCSVExportCommand(BaseCommand):
# Merge database to prevent DetachedInstanceError
merged_database = session.merge(database)
# Execute query with streaming
with merged_database.get_sqla_engine() as engine:
with merged_database.get_sqla_engine(
catalog=catalog, schema=schema
) as engine:
with engine.connect() as connection:
result_proxy = connection.execution_options(
stream_results=True
@@ -209,7 +215,7 @@ class BaseStreamingCSVExportCommand(BaseCommand):
"""
# Load all needed data while session is still active
# to avoid DetachedInstanceError
sql, database = self._get_sql_and_database()
sql, database, catalog, schema = self._get_sql_and_database()
limit = self._get_row_limit()
# Capture flask.g attributes to preserve request-scoped data
# when the streaming generator runs in a new app context.
@@ -222,7 +228,9 @@ class BaseStreamingCSVExportCommand(BaseCommand):
with self._current_app.app_context():
with preserve_g_context(captured_g):
try:
yield from self._execute_query_and_stream(sql, database, limit)
yield from self._execute_query_and_stream(
sql, database, limit, catalog, schema
)
except Exception as e:
logger.error("Error in streaming CSV generator: %s", e)
import traceback
+111 -18
View File
@@ -30,18 +30,68 @@ from fastmcp.server.middleware import Middleware
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Prose snippets that reference get_instance_info.
# These are included in the generated instructions only when that tool is
# enabled; each snippet is a plain string constant so they can be read
# independently of the filtering logic in get_default_instructions().
# ---------------------------------------------------------------------------
_SNIPPET_FEATURE_AVAILABILITY = (
"Feature Availability:\n"
"- Call get_instance_info to discover accessible menus for the current user.\n"
"- Do NOT assume features exist; always check get_instance_info first.\n"
"\n"
)
_SNIPPET_INSTANCE_INFO_ROLE_BULLET = (
"- get_instance_info returns current_user.roles"
' (e.g., ["Admin"], ["Alpha"], ["Viewer"]).\n'
)
_SNIPPET_ACCESSIBLE_MENUS_BULLET = (
"- If you are unsure about a user's capabilities,"
" check their accessible_menus in\n"
" feature_availability from get_instance_info.\n"
)
_SNIPPET_UNSURE_GUIDANCE = (
"\nIf you are unsure which tool to use, start with get_instance_info\n"
"or use the quickstart prompt for an interactive guide.\n"
)
_SNIPPET_CONNECT_GUIDANCE = (
"\nWhen you first connect, call get_instance_info to learn the user's identity.\n"
"Greet them by their first name (from current_user) and offer to help.\n"
)
def get_default_instructions(branding: str = "Apache Superset") -> str:
def get_default_instructions(
branding: str = "Apache Superset",
disabled_tools: set[str] | None = None,
) -> str:
"""Get default instructions with configurable branding.
Tool bullet-point lines for any tool name in ``disabled_tools`` are
omitted so that LLM clients are never told to call a tool that has been
suppressed via ``MCP_DISABLED_TOOLS``.
Args:
branding: Product name to use in instructions
(e.g., "ACME Analytics", "Apache Superset")
disabled_tools: Set of tool names to omit from the tool listing.
When ``None`` (default) all tools are included.
Returns:
Formatted instructions string with branding applied
"""
return f"""
_disabled = disabled_tools or set()
# Prose sections that reference get_instance_info are omitted when that
# tool is disabled so the LLM is never directed to call a removed tool.
_show = "get_instance_info" not in _disabled
_feature_availability = _SNIPPET_FEATURE_AVAILABILITY if _show else ""
_instance_info_role_bullet = _SNIPPET_INSTANCE_INFO_ROLE_BULLET if _show else ""
_accessible_menus_bullet = _SNIPPET_ACCESSIBLE_MENUS_BULLET if _show else ""
_unsure_guidance = _SNIPPET_UNSURE_GUIDANCE if _show else ""
_connect_guidance = _SNIPPET_CONNECT_GUIDANCE if _show else ""
instructions = f"""
You are connected to the {branding} MCP (Model Context Protocol) service.
This service provides programmatic access to {branding} dashboards, charts, datasets,
SQL Lab, and instance metadata via a comprehensive set of tools.
@@ -302,13 +352,8 @@ Input format:
- Tool request parameters accept structured objects (dicts/JSON)
- FastMCP 3.1+ handles Pydantic BaseModel parameters natively
Feature Availability:
- Call get_instance_info to discover accessible menus for the current user.
- Do NOT assume features exist; always check get_instance_info first.
Permission Awareness:
- get_instance_info returns current_user.roles (e.g., ["Admin"], ["Alpha"], ["Viewer"]).
- ALWAYS check the user's roles BEFORE suggesting write operations (creating datasets,
{_feature_availability}Permission Awareness:
{_instance_info_role_bullet}- ALWAYS check the user's roles BEFORE suggesting write operations (creating datasets,
charts, dashboards, or running SQL).
- Do NOT disclose dashboard access lists, dashboard owners, chart owners, dataset
owners, workspace admins, or other users' names, usernames, email addresses,
@@ -332,15 +377,38 @@ Permission Awareness:
1. Explain that they may not have access to the requested resources
2. Suggest they ask a workspace admin to grant them access or share content with them
3. Offer to help with what they CAN do (e.g., viewing dashboards they have access to)
- If you are unsure about a user's capabilities, check their accessible_menus in
feature_availability from get_instance_info.
{_accessible_menus_bullet}{_unsure_guidance}{_connect_guidance}"""
if not _disabled:
return instructions
If you are unsure which tool to use, start with get_instance_info
or use the quickstart prompt for an interactive guide.
When you first connect, call get_instance_info to learn the user's identity.
Greet them by their first name (from current_user) and offer to help.
"""
# Strip any line that mentions a disabled tool — this covers both the
# "- tool_name: ..." bullet entries and all prose/workflow references
# (request wrapper examples, workflow steps, CRITICAL RULES, etc.).
# Tool names are specific enough (e.g. execute_sql, generate_chart) that
# false positives are not a practical concern.
#
# Bullet continuation lines (indented lines belonging to a disabled bullet)
# are also dropped via the skip_continuation flag.
filtered_lines = []
skip_continuation = False
for line in instructions.splitlines(keepends=True):
stripped = line.lstrip()
if stripped.startswith("- "):
tool_part = stripped[2:].split(":")[0].strip()
if tool_part in _disabled:
skip_continuation = True
continue
skip_continuation = False
elif skip_continuation and stripped and not stripped.startswith("- "):
# Indented continuation line of the previous disabled bullet — skip
continue
else:
skip_continuation = False
# Drop any prose line that names a disabled tool
if any(tool in line for tool in _disabled):
continue
filtered_lines.append(line)
return "".join(filtered_lines)
# For backwards compatibility, keep DEFAULT_INSTRUCTIONS pointing to default branding
@@ -569,6 +637,25 @@ from superset.mcp_service.system.tool import ( # noqa: F401, E402
)
def _remove_disabled_tools(disabled_tools: set[str]) -> None:
"""Remove tools listed in MCP_DISABLED_TOOLS from the global MCP instance.
Disabled tools are removed before the server starts serving requests so they
are never advertised to AI clients during tool discovery. Users configure
this via MCP_DISABLED_TOOLS in superset_config.py.
"""
for tool_name in disabled_tools:
try:
mcp.local_provider.remove_tool(tool_name)
logger.info("Disabled MCP tool: %s (MCP_DISABLED_TOOLS)", tool_name)
except KeyError:
logger.warning(
"MCP_DISABLED_TOOLS: tool %r not found — "
"check the tool name is correct",
tool_name,
)
def init_fastmcp_server(
name: str | None = None,
instructions: str | None = None,
@@ -608,8 +695,14 @@ def init_fastmcp_server(
# Apply branding defaults if not explicitly provided
if name is None:
name = default_name
# Remove disabled tools BEFORE generating instructions so that the
# instructions never advertise tools that clients cannot actually call.
disabled_tools: set[str] = flask_app.config.get("MCP_DISABLED_TOOLS", set())
_remove_disabled_tools(disabled_tools)
if instructions is None:
instructions = get_default_instructions(branding)
instructions = get_default_instructions(branding, disabled_tools)
# Configure the global mcp instance with provided settings.
# Tools are already registered on this instance via @tool decorator imports above.
+14
View File
@@ -56,6 +56,19 @@ MCP_DEBUG = False
# against the FAB security_manager before execution.
MCP_RBAC_ENABLED = True
# MCP Disabled Tools - a set of tool names to remove from the MCP server at
# startup. Disabled tools are silently omitted from tool discovery, so AI
# clients never see them. Use this when a Superset-provided tool conflicts with
# a custom tool added via an extension and you want to suppress the built-in
# version.
#
# Example:
# MCP_DISABLED_TOOLS = {"execute_sql", "health_check"}
#
# Extension-prefixed tools can also be disabled using their full name:
# MCP_DISABLED_TOOLS = {"extensions.myorg.myext.some_tool"}
MCP_DISABLED_TOOLS: set[str] = set()
# MCP JWT Debug Errors - controls server-side JWT debug logging.
# When False (default), uses the default JWTVerifier with minimal logging.
# When True, uses DetailedJWTVerifier with tiered logging:
@@ -402,6 +415,7 @@ def get_mcp_config(app_config: Dict[str, Any] | None = None) -> Dict[str, Any]:
"MCP_SERVICE_PORT": MCP_SERVICE_PORT,
"MCP_DEBUG": MCP_DEBUG,
"MCP_RBAC_ENABLED": MCP_RBAC_ENABLED,
"MCP_DISABLED_TOOLS": set(MCP_DISABLED_TOOLS),
**MCP_SESSION_CONFIG,
**MCP_CSRF_CONFIG,
}
+22 -10
View File
@@ -468,13 +468,34 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
engine_context_manager = app.config["ENGINE_CONTEXT_MANAGER"]
with engine_context_manager(self, catalog, schema):
with check_for_oauth2(self):
yield self._get_sqla_engine(
engine = self._get_sqla_engine(
catalog=catalog,
schema=schema,
nullpool=nullpool,
source=source,
sqlalchemy_uri=sqlalchemy_uri,
)
prequeries = self.db_engine_spec.get_prequeries(
database=self,
catalog=catalog,
schema=schema,
)
if prequeries:
# SQLAlchemy connect event: runs prequeries on every new
# DBAPI connection (e.g. SET search_path for PostgreSQL).
def run_prequeries(
dbapi_connection: Any,
connection_record: Any, # pylint: disable=unused-argument
) -> None:
cursor = dbapi_connection.cursor()
try:
for prequery in prequeries:
cursor.execute(prequery)
finally:
cursor.close()
sqla.event.listen(engine, "connect", run_prequeries)
yield engine
def _get_sqla_engine( # pylint: disable=too-many-locals # noqa: C901
self,
@@ -583,15 +604,6 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
) as engine:
with check_for_oauth2(self):
with closing(engine.raw_connection()) as conn:
# pre-session queries are used to set the selected catalog/schema
for prequery in self.db_engine_spec.get_prequeries(
database=self,
catalog=catalog,
schema=schema,
):
cursor = conn.cursor()
cursor.execute(prequery)
yield conn
def get_default_catalog(self) -> str | None:
+7
View File
@@ -51,6 +51,13 @@ from superset.constants import InstantTimeComparison, LRU_CACHE_MAX_SIZE, NO_TIM
ParserElement.enable_packrat()
# parsedatetime emits a noisy DEBUG record ("eval now with context - False, False")
# on every relative-date evaluation. Superset has no actionable use for that
# internal trace, and it floods production logs whenever the root logger is at
# DEBUG. Suppress the library's own logger to WARNING; real failures still
# surface, just not the per-call chatter.
logging.getLogger("parsedatetime").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# Mapping of ordinal words to their numeric values for date expressions
+21 -3
View File
@@ -38,7 +38,7 @@ from superset.superset_typing import OAuth2ClientConfig, OAuth2State
if TYPE_CHECKING:
from superset.db_engine_specs.base import BaseEngineSpec
from superset.models.core import Database, DatabaseUserOAuth2Tokens
from superset.models.core import Database
JWT_EXPIRATION = timedelta(minutes=5)
@@ -116,7 +116,7 @@ def get_oauth2_access_token(
return token.access_token
if token.refresh_token:
return refresh_oauth2_token(config, database_id, user_id, db_engine_spec, token)
return refresh_oauth2_token(config, database_id, user_id, db_engine_spec)
# since the access token is expired and there's no refresh token, delete the entry
db.session.delete(token)
@@ -129,8 +129,10 @@ def refresh_oauth2_token(
database_id: int,
user_id: int,
db_engine_spec: type[BaseEngineSpec],
token: DatabaseUserOAuth2Tokens,
) -> str | None:
# pylint: disable=import-outside-toplevel
from superset.models.core import DatabaseUserOAuth2Tokens
# Use longer TTL for OAuth2 token refresh (may involve network calls)
with DistributedLock(
namespace="refresh_oauth2_token",
@@ -138,6 +140,22 @@ def refresh_oauth2_token(
user_id=user_id,
database_id=database_id,
):
# Short circuit in case another request already deleted the token
token = (
db.session.query(DatabaseUserOAuth2Tokens)
.filter_by(user_id=user_id, database_id=database_id)
.one_or_none()
)
if token is None:
return None
if token.access_token and datetime.now() < token.access_token_expiration:
return token.access_token
if not token.refresh_token:
db.session.delete(token)
return None
try:
token_response = db_engine_spec.get_oauth2_fresh_token(
config,
+6 -1
View File
@@ -29,7 +29,12 @@ from superset.views.base import BaseSupersetView
class RowLevelSecurityView(BaseSupersetView):
route_base = "/rowlevelsecurity"
class_permission_name = "RowLevelSecurity"
# Use the canonical, spaced form used by the REST API and the security
# manager allow-list (see SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS).
# Prior to this, the view's class-derived name "RowLevelSecurity"
# produced a second, duplicate permission alongside "Row Level Security"
# in the admin UI — operators had to grant both for a role to work.
class_permission_name = "Row Level Security"
@expose("/list/")
@has_access
@@ -760,6 +760,54 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
db.session.delete(dashboard)
db.session.commit()
def test_get_dashboards_admin_sees_existing_dashboards(self):
"""Regression for #25890: GET /api/v1/dashboard/ as an Admin user should
return existing dashboards, not an empty list. The original report
showed an Admin getting {"count": 0, "ids": []} despite dashboards
existing in the database."""
admin = self.get_user("admin")
dashboard = self.insert_dashboard(
"regression_25890_dashboard", "regression-25890", [admin.id]
)
try:
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/dashboard/")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] >= 1, (
f"Admin received empty dashboard list despite "
f"{dashboard.dashboard_title!r} existing; see issue #25890"
)
titles = [d["dashboard_title"] for d in data["result"]]
assert dashboard.dashboard_title in titles, (
f"Admin list missing the inserted dashboard. Got titles: {titles}"
)
finally:
db.session.delete(dashboard)
db.session.commit()
def test_get_charts_admin_sees_existing_charts(self):
"""Regression for #25890: GET /api/v1/chart/ as an Admin user should
return existing charts, not an empty list."""
admin = self.get_user("admin")
chart = self.insert_chart("regression_25890_chart", [admin.id], 1, params="{}")
try:
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/chart/")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] >= 1, (
f"Admin received empty chart list despite "
f"{chart.slice_name!r} existing; see issue #25890"
)
names = [c["slice_name"] for c in data["result"]]
assert chart.slice_name in names, (
f"Admin list missing the inserted chart. Got slice_names: {names}"
)
finally:
db.session.delete(chart)
db.session.commit()
def test_get_dashboards_filter(self):
"""
Dashboard API: Test get dashboards filter
@@ -30,6 +30,7 @@ from superset.utils import json
from tests.conftest import with_config
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
from tests.integration_tests.test_app import app
from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
@@ -402,3 +403,42 @@ class TestSecurityRolesApi(SupersetTestCase):
assert sorted(role2_api["user_ids"]) == role2_expected["user_ids"]
assert sorted(role2_api["permission_ids"]) == role2_expected["permission_ids"]
assert role2_api["group_ids"] == role2_expected["group_ids"]
class TestLogoutSessionInvalidation(SupersetTestCase):
"""Regression for #24713: a session cookie captured pre-logout must not grant
access after the user logs out. The original report describes copying the
session cookie out, calling /logout/, and successfully reusing the cookie in
a second browser to bypass authentication."""
def test_session_cookie_invalidated_after_logout(self):
self.login(ADMIN_USERNAME)
resp_authed = self.client.get("api/v1/dashboard/", follow_redirects=False)
assert resp_authed.status_code == 200, (
f"Login did not yield an authenticated session "
f"(got {resp_authed.status_code})"
)
# Werkzeug 2.3+ exposes the test client's cookies on `_cookies` as a
# mapping keyed by (domain, path, key). Snapshot the session cookie
# value — this is what a malicious actor would copy out of a browser.
captured = None
for cookie in self.client._cookies.values():
if cookie.key == "session":
captured = cookie.value
break
assert captured, "expected a session cookie after login"
self.client.get("/logout/", follow_redirects=True)
# Replay the captured cookie in a fresh client (simulates importing
# the cookie into a second browser).
replay_client = app.test_client()
replay_client.set_cookie("session", captured, domain="localhost")
resp_replay = replay_client.get("api/v1/dashboard/", follow_redirects=False)
assert resp_replay.status_code != 200, (
f"Captured session cookie was still accepted after logout "
f"(status={resp_replay.status_code}); see issue #24713"
)
@@ -25,7 +25,10 @@ from superset.commands.chart.data.streaming_export_command import (
def _setup_chart_mocks(
mocker: MockerFixture, sql: str = "SELECT * FROM test"
mocker: MockerFixture,
sql: str = "SELECT * FROM test",
catalog: str | None = None,
schema: str | None = None,
) -> tuple[MockerFixture, MockerFixture, MockerFixture]:
"""Set up common mocks for chart streaming export tests."""
mock_db = mocker.patch("superset.commands.streaming_export.base.db")
@@ -36,6 +39,8 @@ def _setup_chart_mocks(
datasource = mocker.MagicMock()
datasource.get_query_str.return_value = sql
datasource.database = mocker.MagicMock()
datasource.catalog = catalog
datasource.schema = schema
query_context.datasource = datasource
query_context.queries = [mocker.MagicMock()]
mock_session.merge.return_value = datasource.database
@@ -256,3 +261,38 @@ def test_empty_result_set(mocker: MockerFixture) -> None:
lines = [line.strip() for line in csv_data.strip().split("\n")]
assert len(lines) == 1
assert lines[0] == "col1,col2"
def test_catalog_and_schema_passed_to_engine(mocker: MockerFixture) -> None:
"""Test that catalog and schema are forwarded to get_sqla_engine.
Prequeries (e.g. SET search_path for PostgreSQL) are now run automatically
via a connect event listener registered inside get_sqla_engine, not by the
streaming command itself.
"""
mock_db, query_context, datasource = _setup_chart_mocks(
mocker, catalog="my_catalog", schema="my_schema"
)
mock_result = mocker.MagicMock()
mock_result.keys.return_value = ["col1"]
mock_result.fetchmany.side_effect = [[("val",)], []]
mock_connection = mocker.MagicMock()
mock_connection.execution_options.return_value.execute.return_value = mock_result
mock_connection.__enter__.return_value = mock_connection
mock_connection.__exit__.return_value = None
mock_engine = mocker.MagicMock()
mock_engine.connect.return_value = mock_connection
datasource.database.get_sqla_engine.return_value.__enter__.return_value = (
mock_engine
)
command = StreamingCSVExportCommand(query_context)
list(command.run()())
datasource.database.get_sqla_engine.assert_called_once_with(
catalog="my_catalog",
schema="my_schema",
)
@@ -55,6 +55,8 @@ def mock_query():
query.select_sql = None
query.executed_sql = "SELECT * FROM test_table"
query.limiting_factor = LimitingFactor.NOT_LIMITED
query.catalog = None
query.schema = "public"
query.database = MagicMock()
query.database.db_engine_spec = MagicMock()
query.database.db_engine_spec.engine = "postgresql"
@@ -538,3 +540,40 @@ def test_null_values_handling(mocker, mock_query):
assert "1,,100" in csv_data
assert "2,test," in csv_data
assert ",," in csv_data
def test_catalog_and_schema_passed_to_engine(mocker, mock_query, mock_result_proxy):
"""Test that catalog and schema are forwarded to get_sqla_engine.
Prequeries (e.g. SET search_path for PostgreSQL) are now run automatically
via a connect event listener registered inside get_sqla_engine, not by the
streaming command itself.
"""
mock_query.select_sql = "SELECT * FROM test"
mock_query.catalog = "my_catalog"
mock_query.schema = "my_schema"
mock_db, mock_session = _setup_sqllab_mocks(mocker, mock_query)
mock_connection = MagicMock()
mock_connection.execution_options.return_value.execute.return_value = (
mock_result_proxy
)
mock_connection.__enter__.return_value = mock_connection
mock_connection.__exit__.return_value = None
mock_engine = MagicMock()
mock_engine.connect.return_value = mock_connection
mock_query.database.get_sqla_engine.return_value.__enter__.return_value = (
mock_engine
)
command = StreamingSqlResultExportCommand("test_client_123")
command.validate()
list(command.run()())
mock_query.database.get_sqla_engine.assert_called_once_with(
catalog="my_catalog",
schema="my_schema",
)
+38 -14
View File
@@ -105,11 +105,22 @@ def test_get_default_instructions_forbid_disclosing_other_user_access_or_roles()
assert "direct them to their workspace admin" in instructions
def _mock_flask_config(app_name: str) -> MagicMock:
"""Return a Flask app mock whose config.get() returns correct types per key."""
mock = MagicMock()
mock.config.get.side_effect = lambda key, default=None: (
app_name
if key == "APP_NAME"
else set()
if key == "MCP_DISABLED_TOOLS"
else default
)
return mock
def test_init_fastmcp_server_with_default_app_name():
"""Test that default APP_NAME produces Superset branding."""
# Mock Flask app config with default APP_NAME
mock_flask_app = MagicMock()
mock_flask_app.config.get.return_value = "Superset"
mock_flask_app = _mock_flask_config("Superset")
# Patch at the import location to avoid actual Flask app creation
with patch.dict(
@@ -127,9 +138,7 @@ def test_init_fastmcp_server_with_default_app_name():
def test_init_fastmcp_server_with_custom_app_name():
"""Test that custom APP_NAME produces branded instructions."""
custom_app_name = "ACME Analytics"
# Mock Flask app config with custom APP_NAME
mock_flask_app = MagicMock()
mock_flask_app.config.get.return_value = custom_app_name
mock_flask_app = _mock_flask_config(custom_app_name)
# Patch at the import location to avoid actual Flask app creation
with patch.dict(
@@ -149,10 +158,7 @@ def test_init_fastmcp_server_derives_server_name_from_app_name():
"""Test that server name is derived from APP_NAME."""
custom_app_name = "DataViz Platform"
expected_server_name = f"{custom_app_name} MCP Server"
# Mock Flask app config
mock_flask_app = MagicMock()
mock_flask_app.config.get.return_value = custom_app_name
mock_flask_app = _mock_flask_config(custom_app_name)
# Patch at the import location to avoid actual Flask app creation
with patch.dict(
@@ -168,8 +174,7 @@ def test_init_fastmcp_server_derives_server_name_from_app_name():
def test_init_fastmcp_server_applies_auth_to_global_instance():
"""Test that auth is applied to the global mcp instance, not a new one."""
mock_flask_app = MagicMock()
mock_flask_app.config.get.return_value = "Superset"
mock_flask_app = _mock_flask_config("Superset")
mock_auth = MagicMock()
with patch.dict(
@@ -187,8 +192,7 @@ def test_init_fastmcp_server_applies_auth_to_global_instance():
def test_init_fastmcp_server_applies_middleware_to_global_instance():
"""Test that middleware is added to the global mcp instance."""
mock_flask_app = MagicMock()
mock_flask_app.config.get.return_value = "Superset"
mock_flask_app = _mock_flask_config("Superset")
mock_mw = MagicMock()
with patch.dict(
@@ -200,3 +204,23 @@ def test_init_fastmcp_server_applies_middleware_to_global_instance():
# Middleware should be added via add_middleware
mock_mcp.add_middleware.assert_called_once_with(mock_mw)
def test_get_mcp_config_includes_mcp_disabled_tools_key() -> None:
"""get_mcp_config must include MCP_DISABLED_TOOLS in its defaults dict so the
key is available in flask_app.config for the standalone server startup path."""
from superset.mcp_service.mcp_config import get_mcp_config
config = get_mcp_config()
assert "MCP_DISABLED_TOOLS" in config
assert config["MCP_DISABLED_TOOLS"] == set()
def test_get_mcp_config_respects_app_config_override() -> None:
"""When app_config provides MCP_DISABLED_TOOLS, it takes precedence over the
module-level default."""
from superset.mcp_service.mcp_config import get_mcp_config
custom = {"execute_sql", "health_check"}
config = get_mcp_config({"MCP_DISABLED_TOOLS": custom})
assert config["MCP_DISABLED_TOOLS"] == custom
@@ -18,6 +18,10 @@
"""Test MCP app imports and tool/prompt registration."""
import asyncio
import logging
from unittest.mock import MagicMock, patch
from superset.mcp_service.app import get_default_instructions, init_fastmcp_server, mcp
def _run(coro):
@@ -95,3 +99,188 @@ def test_mcp_packages_discoverable_by_setuptools():
f"MCP sub-packages missing __init__.py (will be excluded from "
f"setuptools distributions): {missing}"
)
# ---------------------------------------------------------------------------
# MCP_DISABLED_TOOLS tests
# ---------------------------------------------------------------------------
def _make_flask_app_mock(disabled_tools: set[str]) -> MagicMock:
"""Return a minimal Flask app mock with MCP_DISABLED_TOOLS configured."""
flask_app = MagicMock()
flask_app.config.get.side_effect = lambda key, default=None: (
disabled_tools if key == "MCP_DISABLED_TOOLS" else default
)
return flask_app
def test_disabled_tools_are_removed_from_mcp_server() -> None:
"""Tools listed in MCP_DISABLED_TOOLS are removed before the server starts."""
flask_app = _make_flask_app_mock({"health_check", "list_charts"})
with (
patch(
"superset.mcp_service.flask_singleton.app",
flask_app,
),
patch.object(mcp.local_provider, "remove_tool") as mock_remove,
):
init_fastmcp_server()
removed = {call.args[0] for call in mock_remove.call_args_list}
assert "health_check" in removed
assert "list_charts" in removed
def test_unknown_disabled_tool_logs_warning_not_raises(caplog) -> None:
"""An unknown tool name in MCP_DISABLED_TOOLS logs a warning and does not crash."""
flask_app = _make_flask_app_mock({"nonexistent_tool_xyz"})
with (
patch(
"superset.mcp_service.flask_singleton.app",
flask_app,
),
patch.object(
mcp.local_provider,
"remove_tool",
side_effect=KeyError("nonexistent_tool_xyz"),
),
caplog.at_level(logging.WARNING, logger="superset.mcp_service.app"),
):
# Must not raise
init_fastmcp_server()
assert "nonexistent_tool_xyz" in caplog.text
assert "MCP_DISABLED_TOOLS" in caplog.text
def test_empty_disabled_tools_removes_nothing() -> None:
"""An empty MCP_DISABLED_TOOLS set leaves all tools registered."""
flask_app = _make_flask_app_mock(set())
with (
patch(
"superset.mcp_service.flask_singleton.app",
flask_app,
),
patch.object(mcp.local_provider, "remove_tool") as mock_remove,
):
init_fastmcp_server()
mock_remove.assert_not_called()
def test_disabled_tools_read_from_flask_app_config() -> None:
"""MCP_DISABLED_TOOLS is read from flask_app.config, matching the standard
Superset pattern where users set overrides in superset_config.py, which
create_app() loads into Flask config before any command runs."""
from superset.mcp_service.app import init_fastmcp_server, mcp
flask_app = _make_flask_app_mock({"health_check"})
with (
patch(
"superset.mcp_service.flask_singleton.app",
flask_app,
),
patch.object(mcp.local_provider, "remove_tool") as mock_remove,
):
init_fastmcp_server()
removed = {call.args[0] for call in mock_remove.call_args_list}
assert "health_check" in removed
# ---------------------------------------------------------------------------
# get_default_instructions disabled_tools filtering tests
# ---------------------------------------------------------------------------
def test_disabled_tools_absent_from_instructions() -> None:
"""Tools in disabled_tools must not appear as bullet lines in instructions."""
instructions = get_default_instructions(
disabled_tools={"execute_sql", "health_check"}
)
# The bullet-point entries for disabled tools must be gone
assert "- execute_sql:" not in instructions
assert "- health_check:" not in instructions
# Non-disabled tools must still be present
assert "- list_charts:" in instructions
assert "- list_dashboards:" in instructions
def test_disabling_get_instance_info_removes_all_prose_references() -> None:
"""Disabling get_instance_info must remove ALL prose references to it,
not only the bullet-point entry in the Available tools section."""
instructions = get_default_instructions(disabled_tools={"get_instance_info"})
# Bullet entry must be gone
assert "- get_instance_info:" not in instructions
# Prose directives that instruct the LLM to call the tool must also be gone
assert "start with get_instance_info" not in instructions
assert "call get_instance_info" not in instructions
assert "check their accessible_menus in" not in instructions
assert "Feature Availability" not in instructions
# Instructions for other tools must be unaffected
assert "- list_charts:" in instructions
assert "- execute_sql:" in instructions
def test_disabling_execute_sql_removes_all_prose_references() -> None:
"""Disabling execute_sql must remove all workflow and example lines that
mention it, not only the bullet-point entry."""
instructions = get_default_instructions(disabled_tools={"execute_sql"})
# Bullet entry must be gone
assert "- execute_sql:" not in instructions
# Workflow steps and request wrapper examples must also be gone
assert "execute_sql(" not in instructions
assert "execute_sql" not in instructions
# Instructions for unrelated tools must be unaffected
assert "- list_charts:" in instructions
assert "- get_instance_info:" in instructions
def test_no_disabled_tools_returns_full_instructions() -> None:
"""Passing no disabled_tools (or empty set) returns the full instructions."""
full = get_default_instructions()
also_full = get_default_instructions(disabled_tools=set())
assert "- execute_sql:" in full
assert "- health_check:" in full
assert full == also_full
def test_instructions_generated_after_disabled_tools_removed() -> None:
"""init_fastmcp_server generates instructions AFTER removing disabled tools,
so the instructions never advertise tools that clients cannot call."""
flask_app = _make_flask_app_mock({"execute_sql"})
captured: list[str] = []
def fake_get_instructions(
branding: str = "Apache Superset",
disabled_tools: set[str] | None = None,
) -> str:
captured.append(str(disabled_tools))
return f"instructions for {branding}"
with (
patch("superset.mcp_service.flask_singleton.app", flask_app),
patch.object(mcp.local_provider, "remove_tool"),
patch(
"superset.mcp_service.app.get_default_instructions",
fake_get_instructions,
),
):
init_fastmcp_server()
# get_default_instructions must have been called with the disabled set
assert len(captured) == 1
assert "execute_sql" in captured[0]
+137 -15
View File
@@ -17,6 +17,7 @@
# pylint: disable=import-outside-toplevel
from datetime import datetime
from typing import Any, Callable
import pytest
from flask import current_app
@@ -261,21 +262,6 @@ def test_table_column_database() -> None:
assert TableColumn(database=database).database is database
def test_get_prequeries(mocker: MockerFixture) -> None:
"""
Tests for ``get_prequeries``.
"""
mocker.patch.object(Database, "get_sqla_engine")
db_engine_spec = mocker.patch.object(Database, "db_engine_spec")
db_engine_spec.get_prequeries.return_value = ["set a=1", "set b=2"]
database = Database(database_name="db")
with database.get_raw_connection() as conn:
conn.cursor().execute.assert_has_calls(
[mocker.call("set a=1"), mocker.call("set b=2")]
)
def test_catalog_cache() -> None:
"""
Test the catalog cache.
@@ -634,6 +620,142 @@ def test_get_sqla_engine_user_impersonation_email(mocker: MockerFixture) -> None
)
def test_get_sqla_engine_registers_prequery_event_listener(
app_context: None,
mocker: MockerFixture,
) -> None:
"""
Test that get_sqla_engine registers a connect event listener for prequeries.
Engines returned by get_sqla_engine must automatically execute prequeries
(e.g. SET search_path) on every new connection, so that callers don't need
to remember to call get_prequeries() themselves.
"""
mock_engine = mocker.MagicMock()
mocker.patch.object(Database, "_get_sqla_engine", return_value=mock_engine)
db_engine_spec = mocker.patch.object(Database, "db_engine_spec")
db_engine_spec.get_prequeries.return_value = ['SET search_path = "my_schema"']
event_listen = mocker.patch("superset.models.core.sqla.event.listen")
database = Database(database_name="my_db", sqlalchemy_uri="postgresql://")
with database.get_sqla_engine(catalog="my_catalog", schema="my_schema"):
pass
db_engine_spec.get_prequeries.assert_called_once_with(
database=database,
catalog="my_catalog",
schema="my_schema",
)
event_listen.assert_called_once_with(mock_engine, "connect", mocker.ANY)
# Call the captured closure directly to verify cursor create → execute → close.
captured_fn = event_listen.call_args[0][2]
mock_dbapi_conn = mocker.MagicMock()
mock_cursor = mocker.MagicMock()
mock_dbapi_conn.cursor.return_value = mock_cursor
captured_fn(mock_dbapi_conn, None)
mock_cursor.execute.assert_called_once_with('SET search_path = "my_schema"')
mock_cursor.close.assert_called_once()
def test_get_sqla_engine_prequery_cursor_closed_on_exception(
app_context: None,
mocker: MockerFixture,
) -> None:
"""
Test that the cursor is always closed even when a prequery raises.
"""
mock_engine = mocker.MagicMock()
mocker.patch.object(Database, "_get_sqla_engine", return_value=mock_engine)
db_engine_spec = mocker.patch.object(Database, "db_engine_spec")
db_engine_spec.get_prequeries.return_value = ['SET search_path = "bad_schema"']
event_listen = mocker.patch("superset.models.core.sqla.event.listen")
database = Database(database_name="my_db", sqlalchemy_uri="postgresql://")
with database.get_sqla_engine(catalog=None, schema="bad_schema"):
pass
captured_fn = event_listen.call_args[0][2]
mock_dbapi_conn = mocker.MagicMock()
mock_cursor = mocker.MagicMock()
mock_cursor.execute.side_effect = Exception("invalid schema")
mock_dbapi_conn.cursor.return_value = mock_cursor
with pytest.raises(Exception, match="invalid schema"):
captured_fn(mock_dbapi_conn, None)
mock_cursor.close.assert_called_once()
def test_get_sqla_engine_no_prequeries_no_event_listener(
app_context: None,
mocker: MockerFixture,
) -> None:
"""
Test that get_sqla_engine does not register an event listener when there
are no prequeries.
"""
mock_engine = mocker.MagicMock()
mocker.patch.object(Database, "_get_sqla_engine", return_value=mock_engine)
db_engine_spec = mocker.patch.object(Database, "db_engine_spec")
db_engine_spec.get_prequeries.return_value = []
event_listen = mocker.patch("superset.models.core.sqla.event.listen")
database = Database(database_name="my_db", sqlalchemy_uri="postgresql://")
with database.get_sqla_engine(catalog=None, schema=None):
pass
event_listen.assert_not_called()
def test_get_raw_connection_executes_prequeries_exactly_once(
app_context: None,
mocker: MockerFixture,
) -> None:
"""
Test that get_raw_connection() runs prequeries exactly once through the
connect event listener registered by get_sqla_engine().
Previously get_raw_connection() had its own manual prequery loop AND
called get_sqla_engine() (which registers the listener), so prequeries
ran twice. After removing the manual loop the listener is the sole
execution point this test proves exactly-once semantics.
"""
mock_engine = mocker.MagicMock()
mocker.patch.object(Database, "_get_sqla_engine", return_value=mock_engine)
db_engine_spec = mocker.patch.object(Database, "db_engine_spec")
prequery = 'SET search_path = "my_schema"'
db_engine_spec.get_prequeries.return_value = [prequery]
# Capture the closure registered via sqla.event.listen.
captured_listeners: list[Callable[..., None]] = []
original_listen = mocker.patch("superset.models.core.sqla.event.listen")
original_listen.side_effect = lambda engine, event, fn: captured_listeners.append(
fn
)
# Simulate SQLAlchemy firing the "connect" event when raw_connection() is called.
mock_dbapi_conn = mocker.MagicMock()
mock_cursor = mocker.MagicMock()
mock_dbapi_conn.cursor.return_value = mock_cursor
def raw_connection_side_effect() -> Any:
for listener in captured_listeners:
listener(mock_dbapi_conn, None)
return mock_dbapi_conn
mock_engine.raw_connection.side_effect = raw_connection_side_effect
database = Database(database_name="my_db", sqlalchemy_uri="postgresql://")
with database.get_raw_connection(schema="my_schema"):
pass
# Exactly one prequery, exactly once — not twice, not zero.
mock_cursor.execute.assert_called_once_with(prequery)
mock_cursor.close.assert_called_once()
def test_is_oauth2_enabled() -> None:
"""
Test the `is_oauth2_enabled` method.
@@ -184,3 +184,120 @@ def test_prophet_incorrect_time_grain():
periods=10,
confidence_interval=0.8,
)
def test_prophet_uncertainty_lower_bound_can_be_negative_for_negative_series():
"""
Regression for #21734: when the input series contains negative values,
the forecast's lower confidence bound (``__yhat_lower``) must be allowed
to go below zero. The original bug claimed Superset clipped the lower
bound at 0, hiding the natural shape of the uncertainty interval for
series like temperatures or signed deltas.
Superset's wrapper passes through Prophet's output unchanged (no
clipping in ``superset/utils/pandas_postprocessing/prophet.py``); this
test pins that contract end-to-end. If a future refactor introduces
a ``max(0, lower)`` clamp, this test fails immediately.
"""
if find_spec("prophet") is None:
pytest.skip("prophet not installed")
# All-negative monthly series — any reasonable forecast must predict
# negative values (and therefore negative uncertainty bounds) too.
negative_df = pd.DataFrame(
{
DTTM_ALIAS: [datetime(2020, m, 1) for m in range(1, 13)]
+ [datetime(2021, m, 1) for m in range(1, 13)],
"temperature": [
-5.0,
-7.0,
-3.0,
1.0,
8.0,
14.0,
17.0,
16.0,
11.0,
5.0,
-1.0,
-4.0,
-6.0,
-8.0,
-2.0,
2.0,
9.0,
15.0,
18.0,
17.0,
12.0,
6.0,
0.0,
-3.0,
],
}
)
result = prophet(
df=negative_df,
time_grain="P1M",
periods=3,
confidence_interval=0.9,
)
assert "temperature__yhat_lower" in result.columns
# Restrict to the forecast horizon (the last `periods` rows). The full
# output also contains historical fitted points, which can be negative
# for in-sample data even if a future-only clamp were introduced — so
# asserting on the whole frame would let a future-only clamp slip past.
forecast_periods = 3
forecast_lower = result["temperature__yhat_lower"].iloc[-forecast_periods:]
assert (forecast_lower < 0).any(), (
"Forecast (future) lower bound was non-negative everywhere despite "
"a series with negative actuals — suggests an unexpected clamp at "
"zero was reintroduced (regression of #21734)."
)
def test_prophet_does_not_clamp_yhat_below_zero_for_negative_actuals():
"""
Companion to the lower-bound test above: the central forecast
(``__yhat``) must also be allowed to go negative.
A bug that clamps the central forecast at zero would force the lower
bound non-negative as a side effect, masking the wider issue.
"""
if find_spec("prophet") is None:
pytest.skip("prophet not installed")
negative_df = pd.DataFrame(
{
DTTM_ALIAS: [datetime(2020, m, 1) for m in range(1, 13)],
"balance": [
-100.0,
-110.0,
-95.0,
-120.0,
-130.0,
-125.0,
-140.0,
-135.0,
-150.0,
-145.0,
-160.0,
-155.0,
],
}
)
result = prophet(
df=negative_df,
time_grain="P1M",
periods=2,
confidence_interval=0.8,
)
# Restrict to the forecast horizon — see lower-bound test above for the
# rationale. A future-only clamp on `__yhat` could leave historical
# in-sample fitted points negative and pass an unrestricted assertion.
forecast_periods = 2
forecast_yhat = result["balance__yhat"].iloc[-forecast_periods:]
assert (forecast_yhat < 0).any()
@@ -722,3 +722,41 @@ def test_time_range_bounded_whitespace_regex_invalid(time_range: str) -> None:
"""Reject expressions with 0 or 6+ spaces (fall back to DATETIME wrapping)."""
result = get_since_until(time_range)
assert result[0] is None, f"Expected '{time_range}' to NOT match bounded regex"
def test_datetime_eval_does_not_emit_parsedatetime_debug_logs(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Regression for #33365: ``parsedatetime`` emits a noisy DEBUG record
(``parsedatetime:eval now with context - False, False``) every time
``datetime_eval`` runs against a relative expression. In production
deployments running at DEBUG level this floods the logs the user
report notes "this appears frequently" and a search of the issue
tracker turns up "dozens of places where people have posted a log
with that in it."
The fix is to silence the ``parsedatetime`` logger at module load
in ``superset/utils/date_parser.py`` (e.g.
``logging.getLogger("parsedatetime").setLevel(logging.WARNING)``).
Their own DEBUG output is internal library chatter that Superset
does not surface to operators in any actionable way.
This test captures all log records at DEBUG level during a single
``datetime_eval`` call and asserts that none of them come from the
``parsedatetime`` logger. If the suppression is removed or bypassed,
the test fails immediately.
"""
import logging
with caplog.at_level(logging.DEBUG):
datetime_eval("datetime('now')")
parsedatetime_records = [
r for r in caplog.records if r.name.startswith("parsedatetime")
]
assert not parsedatetime_records, (
"parsedatetime emitted DEBUG records during datetime_eval — these "
"flood production logs. Records: "
+ repr([(r.levelname, r.getMessage()) for r in parsedatetime_records])
)
+125 -6
View File
@@ -131,10 +131,12 @@ def test_refresh_oauth2_token_deletes_token_on_oauth2_exception(
"Token revoked"
)
token = mocker.MagicMock()
token.access_token = None
token.refresh_token = "refresh-token" # noqa: S105
db.session.query().filter_by().one_or_none.return_value = token
with pytest.raises(OAuth2ExceptionError):
refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec, token)
refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
db.session.delete.assert_called_with(token)
db.session.flush.assert_called_once()
@@ -160,10 +162,12 @@ def test_refresh_oauth2_token_keeps_token_on_other_exception(
db_engine_spec.oauth2_exception = OAuth2ExceptionError
db_engine_spec.get_oauth2_fresh_token.side_effect = Exception("Network error")
token = mocker.MagicMock()
token.access_token = None
token.refresh_token = "refresh-token" # noqa: S105
db.session.query().filter_by().one_or_none.return_value = token
with pytest.raises(Exception, match="Network error"):
refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec, token)
refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
db.session.delete.assert_not_called()
@@ -176,16 +180,18 @@ def test_refresh_oauth2_token_no_access_token_in_response(
This can happen when the refresh token was revoked.
"""
mocker.patch("superset.utils.oauth2.db")
db = mocker.patch("superset.utils.oauth2.db")
mocker.patch("superset.utils.oauth2.DistributedLock")
db_engine_spec = mocker.MagicMock()
db_engine_spec.get_oauth2_fresh_token.return_value = {
"error": "invalid_grant",
}
token = mocker.MagicMock()
token.access_token = None
token.refresh_token = "refresh-token" # noqa: S105
db.session.query().filter_by().one_or_none.return_value = token
result = refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec, token)
result = refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
assert result is None
@@ -208,10 +214,12 @@ def test_refresh_oauth2_token_updates_refresh_token(
"refresh_token": "new-refresh-token",
}
token = mocker.MagicMock()
token.access_token = None
token.refresh_token = "old-refresh-token" # noqa: S105
db.session.query().filter_by().one_or_none.return_value = token
with freeze_time("2024-01-01"):
refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec, token)
refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
assert token.access_token == "new-access-token" # noqa: S105
assert token.access_token_expiration == datetime(2024, 1, 1, 1)
@@ -236,16 +244,127 @@ def test_refresh_oauth2_token_keeps_refresh_token(
"expires_in": 3600,
}
token = mocker.MagicMock()
token.access_token = None
token.refresh_token = "original-refresh-token" # noqa: S105
db.session.query().filter_by().one_or_none.return_value = token
with freeze_time("2024-01-01"):
refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec, token)
refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
assert token.access_token == "new-access-token" # noqa: S105
assert token.refresh_token == "original-refresh-token" # noqa: S105
db.session.add.assert_called_with(token)
def test_refresh_oauth2_token_refreshes_when_access_token_expired_under_lock(
mocker: MockerFixture,
) -> None:
"""
Test that refresh_oauth2_token triggers a refresh when the access_token is expired.
When the re-query under the lock returns a token whose access_token has expired
but a refresh_token is available, the function should call the token endpoint
and persist the new access_token.
"""
db = mocker.patch("superset.utils.oauth2.db")
mocker.patch("superset.utils.oauth2.DistributedLock")
db_engine_spec = mocker.MagicMock()
db_engine_spec.get_oauth2_fresh_token.return_value = {
"access_token": "new-access-token",
"expires_in": 3600,
}
token = mocker.MagicMock()
token.access_token = "expired-token" # noqa: S105
token.access_token_expiration = datetime(2024, 1, 1)
token.refresh_token = "refresh-token" # noqa: S105
db.session.query().filter_by().one_or_none.return_value = token
with freeze_time("2024-01-02"):
result = refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
assert result == "new-access-token"
db_engine_spec.get_oauth2_fresh_token.assert_called_once_with(
DUMMY_OAUTH2_CONFIG, "refresh-token"
)
db.session.add.assert_called_with(token)
def test_refresh_oauth2_token_returns_existing_token_when_still_valid_under_lock(
mocker: MockerFixture,
) -> None:
"""
Test that refresh_oauth2_token returns the existing access_token if still valid.
When concurrent requests are triggered and the first one refreshes the token and
releases the lock before the second one gets to `refresh_oauth2_token`, the second
request should pick up the already-refreshed access_token instead of refreshing
it again.
"""
db = mocker.patch("superset.utils.oauth2.db")
mocker.patch("superset.utils.oauth2.DistributedLock")
db_engine_spec = mocker.MagicMock()
token = mocker.MagicMock()
token.access_token = "fresh-access-token" # noqa: S105
token.access_token_expiration = datetime(2024, 1, 2)
token.refresh_token = "refresh-token" # noqa: S105
db.session.query().filter_by().one_or_none.return_value = token
with freeze_time("2024-01-01"):
result = refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
assert result == "fresh-access-token"
db_engine_spec.get_oauth2_fresh_token.assert_not_called()
db.session.delete.assert_not_called()
def test_refresh_oauth2_token_deletes_when_no_refresh_token_under_lock(
mocker: MockerFixture,
) -> None:
"""
Test that refresh_oauth2_token deletes the row when there's no refresh_token.
When the token has expired and the re-query under the lock shows no refresh_token
is available, the row should be deleted and None returned so the caller can
trigger the OAuth2 dance.
"""
db = mocker.patch("superset.utils.oauth2.db")
mocker.patch("superset.utils.oauth2.DistributedLock")
db_engine_spec = mocker.MagicMock()
token = mocker.MagicMock()
token.access_token = "expired-token" # noqa: S105
token.access_token_expiration = datetime(2024, 1, 1)
token.refresh_token = None
db.session.query().filter_by().one_or_none.return_value = token
with freeze_time("2024-01-02"):
result = refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
assert result is None
db.session.delete.assert_called_with(token)
db_engine_spec.get_oauth2_fresh_token.assert_not_called()
def test_refresh_oauth2_token_returns_none_when_row_deleted_under_lock(
mocker: MockerFixture,
) -> None:
"""
Test that refresh_oauth2_token returns None when the row is gone under the lock.
When concurrent requests are triggered and the first one deletes the token row and
releases the lock before the second one gets to `refresh_oauth2_token`, the token
is queried again to avoid a stale reference.
"""
db = mocker.patch("superset.utils.oauth2.db")
mocker.patch("superset.utils.oauth2.DistributedLock")
db_engine_spec = mocker.MagicMock()
db.session.query().filter_by().one_or_none.return_value = None
result = refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec)
assert result is None
db_engine_spec.get_oauth2_fresh_token.assert_not_called()
def test_generate_code_verifier_length() -> None:
"""
Test that generate_code_verifier produces a string of valid length (RFC 7636).
+54
View File
@@ -0,0 +1,54 @@
# 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.
"""Tests for ``superset/views/sqla.py``."""
def test_row_level_security_view_uses_canonical_permission_name() -> None:
"""
Regression for #33744: the RLS view's ``class_permission_name`` must
match the user-facing model name ("Row Level Security") so that only
one permission is registered.
Today the view sets ``class_permission_name = "RowLevelSecurity"``
(no spaces, derived from the Python class name), while the REST API
and the security manager allow-list use the spaced form
("Row Level Security"). This produces two effectively identical
permissions in the admin UI:
- can read on Row Level Security
- can read on RowLevelSecurity
Operators have to grant both for a role to actually work, which is
confusing and error-prone (the second one looks like an
implementation detail leaking into the UI).
The fix is a one-line change in ``superset/views/sqla.py`` to align
``class_permission_name`` with the canonical name used elsewhere in
the codebase. This test pins that contract.
"""
# Cross-check: the security manager's allow-list (the canonical
# source of truth for RLS view-menu naming) uses the spaced form.
from superset.security.manager import SupersetSecurityManager
from superset.views.sqla import RowLevelSecurityView
assert "Row Level Security" in SupersetSecurityManager.ADMIN_ONLY_VIEW_MENUS
assert RowLevelSecurityView.class_permission_name == "Row Level Security", (
"RLS view's class_permission_name diverges from the canonical "
'"Row Level Security" used by the API and security manager — '
"produces a duplicate `can read on RowLevelSecurity` permission "
"alongside `can read on Row Level Security` in the admin UI."
)