Compare commits

...
Author SHA1 Message Date
Amin Ghadersohi 9b6ee2522c feat(mcp): add create_css_template and update_css_template tools
- Add CreateCssTemplateCommand, UpdateCssTemplateCommand with exceptions
- Add create_css_template and update_css_template MCP tools with Pydantic
  schemas, event logging, and structured error responses
- Add sanitize_error_for_llm_context validator on error fields (CWE-79)
- Add unit tests for both tools including schema validation and exception
  re-raise coverage
2026-05-30 04:40:47 +00:00
Evan RusackasandClaude Code 44a8d9d469 chore(deps): pin lodash, lodash-es, and yaml in docs to patched releases (#40535)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-29 21:26:39 -07:00
Amin Ghadersohi d350792d43 feat(mcp): add list and get tools for CSS templates and themes (#40343) 2026-05-30 00:20:48 -04:00
c9136af8b6 fix(ci): trigger python dep check when pyproject.toml changes (#39792)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-29 21:13:56 -07:00
David KopelentandEvan Rusackas f0838353a5 feat(i18n): add Czech (cs) locale support for dates (#40241)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-29 21:13:52 -07:00
16b56873b0 fix(ci): restrict workflow_run jobs to trusted origins and add zizmor audit (#40533)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-30 11:11:40 +07:00
Amin GhadersohiandClaude Sonnet 4.6 62b4ee3d9e feat(mcp): add list and get tools for users and roles (#40345)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 23:46:30 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 43c3c06035 chore(deps): bump vm2 from 3.11.3 to 3.11.5 in /superset-frontend (#40529)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 20:25:32 -07:00
Amin Ghadersohi 40de44f6de feat(mcp): add list and get tools for action log and tasks (#40344) 2026-05-29 23:16:10 -04:00
Đỗ Trọng Hải b8ea4448d6 feat(ci): perform static security analysis for GHA workflows (#40510)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-05-29 20:15:14 -07:00
Amin GhadersohiandClaude Sonnet 4.6 8d8eeb3505 feat(mcp): add list_tags and get_tag_info MCP tools (#40349)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:16:59 -04:00
Amin GhadersohiandClaude Sonnet 4.6 a69bbcb044 feat(mcp): add list and get tools for saved queries and query history (#40346)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:16:11 -04:00
Amin Ghadersohi f614863ed7 feat(mcp): add list and get tools for annotation layers and annotations (#40342) 2026-05-29 22:14:28 -04:00
53e2793bc3 fix: coerce out-of-bounds nanosecond timestamps to NaT instead of raising (#40127)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
2026-05-29 14:32:33 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Đỗ Trọng Hải
e73d2d0bf6 chore(deps): bump tmp from 0.2.4 to 0.2.7 in /superset-frontend/cypress-base (#40476)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-29 13:43:14 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenberĐỗ Trọng Hải
ae5823fb9c chore(deps-dev): bump tmp from 0.2.5 to 0.2.7 in /superset-frontend (#40480)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-29 13:42:49 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 63e3a18e8f chore(deps): bump baseline-browser-mapping from 2.10.31 to 2.10.32 in /docs (#40484)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:42:32 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 661ff31c6d chore(deps): bump yeoman-generator from 8.1.2 to 8.2.2 in /superset-frontend (#40485)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:42:19 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ead21d9789 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40486)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:42:04 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c3bda6baea chore(deps): bump fs-extra from 11.3.2 to 11.3.5 in /superset-frontend (#40487)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:41:50 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 61cb0aeae7 chore(deps): bump @swc/core from 1.15.33 to 1.15.40 in /docs (#40515)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:41:35 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cf9ee99b5a chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 0.10.1 to 0.10.2 in /superset-frontend (#40516)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:41:22 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e1948c87c6 chore(deps-dev): bump webpack-sources from 3.4.1 to 3.5.0 in /superset-frontend (#40517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:41:08 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 63ae80ab62 chore(deps-dev): bump baseline-browser-mapping from 2.10.31 to 2.10.32 in /superset-frontend (#40518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:40:53 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8f88bbcc79 chore(deps-dev): bump @swc/core from 1.15.33 to 1.15.40 in /superset-frontend (#40519)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 13:40:39 -07:00
b2320820b4 feat(core): SoftDeleteMixin and restore infrastructure (#39977)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:08:10 -07:00
Mehmet Salih Yavuz 8853ab5c75 feat(mcp): add get_dashboard_layout companion tool (#40328) 2026-05-29 14:31:16 +03:00
Evan RusackasandClaude Code b0da0cf202 chore(ci): update GHA actions to Node.js 24-compatible versions (#40477)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-29 11:12:41 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 96b96ad7d4 chore(deps): bump ws from 8.20.1 to 8.21.0 in /superset-websocket (#40483)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 11:10:18 +07:00
Shrey Shekhar f7e1f96894 fix(i18n): Wrapped default Chat Error message in t() to support translation (#40504) 2026-05-29 11:04:15 +07:00
Nishita MatlaniandĐỗ Trọng Hải ec09cec6bd fix(ci): correct first-interaction inputs in welcome workflow (#40508)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-29 10:53:38 +07:00
ShaitanandClaude Sonnet 4.6 f663f47628 fix(embedding): require non-default JWT secret when embedded dashboards are enabled (#39999)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:26:40 -07:00
Abdul RehmanandĐỗ Trọng Hải 8a0026b173 fix(groups): display user full name in role edit user dropdown (#39942)
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-05-28 14:25:45 -07:00
Elizabeth ThompsonandClaude Sonnet 4.6 f037449b75 fix(roles): resolve HTTP 429 and 414 errors on role management page (#39465)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:34:54 -07:00
Gabriel Torres Ruiz e68251fa70 feat(mcp): support custom SQL metrics in generate_chart and update_chart (#40448) 2026-05-28 14:52:43 -03:00
Amin GhadersohiandClaude Sonnet 4.6 0dc58d1042 feat(mcp): browser hello page with working middleware and config-driven content (#40471)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:47:38 -04:00
jesperct c73106b7a2 fix(chart-echarts): drop white textBorder from Funnel segment labels (#40468) 2026-05-27 23:59:51 -07:00
175 changed files with 16751 additions and 554 deletions
+3 -3
View File
@@ -26,7 +26,7 @@ runs:
- name: Set up QEMU
if: ${{ inputs.build == 'true' }}
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
with:
# Pin the binfmt image to a specific QEMU release. The default
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
@@ -39,12 +39,12 @@ runs:
- name: Set up Docker Buildx
if: ${{ inputs.build == 'true' }}
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Try to login to DockerHub
if: ${{ inputs.login-to-dockerhub == 'true' }}
continue-on-error: true
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ inputs.dockerhub-user }}
password: ${{ inputs.dockerhub-token }}
+2 -2
View File
@@ -10,7 +10,7 @@ runs:
steps:
- name: Setup Node Env
uses: actions/setup-node@v4
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
@@ -21,7 +21,7 @@ runs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
if: ${{ inputs.from-npm == 'false' }}
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: apache-superset/supersetbot
path: supersetbot
+5 -1
View File
@@ -95,7 +95,11 @@ jobs:
# in the context of push (using multi-platform build), we need to pull the image locally
- name: Docker pull
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
run: docker pull $IMAGE_TAG
run: |
for i in 1 2 3; do
docker pull $IMAGE_TAG && break
[ $i -lt 3 ] && sleep 30
done
- name: Print docker stats
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
@@ -6,7 +6,8 @@ on:
- "master"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
branches:
- "**"
permissions:
contents: read
@@ -17,10 +18,12 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
@@ -29,3 +32,6 @@ jobs:
- name: Run Script
run: bash .github/workflows/github-action-validator.sh
- name: Check for security issues on GHA workflows
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+7 -1
View File
@@ -45,7 +45,13 @@ jobs:
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
build-deploy:
needs: config
if: needs.config.outputs.has-secrets
# For workflow_run triggers, only deploy when the triggering run originated
# from this repository (not a fork), ensuring the checked-out code and any
# local actions executed with deploy credentials are trusted.
if: >-
needs.config.outputs.has-secrets &&
(github.event_name != 'workflow_run' ||
github.event.workflow_run.head_repository.full_name == github.repository)
name: Build & Deploy
runs-on: ubuntu-24.04
steps:
+2 -1
View File
@@ -97,7 +97,8 @@ jobs:
# Only runs if integration tests succeeded
if: >
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success'
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
name: Build (after integration tests)
runs-on: ubuntu-24.04
defaults:
+8 -3
View File
@@ -13,10 +13,15 @@ jobs:
steps:
- name: Welcome Message
uses: actions/first-interaction@v3
continue-on-error: true
with:
repo-token: ${{ github.token }}
pr-message: |-
repo_token: ${{ github.token }}
issue_message: |-
Congrats on opening your first issue and thank you for contributing to Superset! :tada: :heart:
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
pr_message: |-
Congrats on making your first PR and thank you for contributing to Superset! :tada: :heart:
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
We hope to see you in our [Slack](https://apache-superset.slack.com/) community too! Not signed up? Use our [Slack App](http://bit.ly/join-superset-slack) to self-register.
+11
View File
@@ -158,3 +158,14 @@ repos:
language: system
files: ^superset/config\.py$
pass_filenames: false
- id: zizmor
name: zizmor (GHA security audit)
entry: zizmor
language: python
additional_dependencies: [zizmor==1.25.2]
files: ^\.github/
types: [yaml]
pass_filenames: false
# Advisory until pre-existing findings are resolved; remove
# --no-exit-codes to make this hook blocking.
args: [--no-exit-codes, .github/]
+1 -1
View File
@@ -113,7 +113,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash
# Some bash scripts needed throughout the layers
COPY --chmod=755 docker/*.sh /app/docker/
RUN pip install --no-cache-dir --upgrade uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Using uv as it's faster/simpler than pip
RUN uv venv /app/.venv
+6 -3
View File
@@ -70,9 +70,9 @@
"@storybook/preview-api": "^8.6.18",
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.33",
"@swc/core": "^1.15.40",
"antd": "^6.4.3",
"baseline-browser-mapping": "^2.10.31",
"baseline-browser-mapping": "^2.10.32",
"caniuse-lite": "^1.0.30001793",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
@@ -128,7 +128,10 @@
"react-redux": "^9.2.0",
"@reduxjs/toolkit": "^2.5.0",
"baseline-browser-mapping": "^2.9.19",
"swagger-client": "3.37.3"
"swagger-client": "3.37.3",
"lodash": "4.18.1",
"lodash-es": "4.18.1",
"yaml": "1.10.3"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}
+74 -84
View File
@@ -4033,86 +4033,86 @@
dependencies:
apg-lite "^1.0.4"
"@swc/core-darwin-arm64@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz#d84134fb80417d41128739f0b9014542e3ed9dd3"
integrity sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==
"@swc/core-darwin-arm64@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz#b05d715b04c4fd47baf59288233da85a683cc0bc"
integrity sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==
"@swc/core-darwin-x64@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz#0badb9834071f1c6005986571d4a96359c1d7cd0"
integrity sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==
"@swc/core-darwin-x64@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz#3180daef5c1e47b435f8edd084509e0a5c0d883b"
integrity sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==
"@swc/core-linux-arm-gnueabihf@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz#b7577a825b59d98b6a9a5c991d842046efe1c34a"
integrity sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==
"@swc/core-linux-arm-gnueabihf@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz#18fcd3c70e48fdfae07c9f18751b1409ce1e5e84"
integrity sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==
"@swc/core-linux-arm64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz#304c48321494a18c67b2913c273b08674ee70d8c"
integrity sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==
"@swc/core-linux-arm64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz#26304933922f2a8e3194770e404403fc25a19c89"
integrity sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==
"@swc/core-linux-arm64-musl@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz#d116cbc04ccb4f4ee810da6bca79d4423605dbcd"
integrity sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==
"@swc/core-linux-arm64-musl@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz#3402dfba04ba7b8ea81f243e2f8fa2c336b54d03"
integrity sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==
"@swc/core-linux-ppc64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz#f5354dba36db9414305bab344c817d57b8b457c2"
integrity sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==
"@swc/core-linux-ppc64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz#b3df9065cad352328c1eeef08a28fc9fe98785aa"
integrity sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==
"@swc/core-linux-s390x-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz#016df9f4c9d7fd65b85ca9c558c5aec341f06da0"
integrity sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==
"@swc/core-linux-s390x-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz#58e5b601f641dde81b30626ef66a668701ec918f"
integrity sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==
"@swc/core-linux-x64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz#49f36558ede072e71999aa37f123367daed2a662"
integrity sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==
"@swc/core-linux-x64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz#cf057dce0c148c53f2d30152baaf60ea29e5d59c"
integrity sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==
"@swc/core-linux-x64-musl@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz#b096665f5cfeee2612325f301da5c1590b10d8f3"
integrity sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==
"@swc/core-linux-x64-musl@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz#21fb1a4d0193e9bbcd1469ecd36166d2e96e4006"
integrity sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==
"@swc/core-win32-arm64-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz#f3101263a0dbaa173ec47638c9719d0b89838bd2"
integrity sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==
"@swc/core-win32-arm64-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz#1dba23b2b0db86b3d6d65da2abd627cc607a1fbc"
integrity sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==
"@swc/core-win32-ia32-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz#eb981ef5613d42c9220559bdb0c8bc58cf6c3eb9"
integrity sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==
"@swc/core-win32-ia32-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz#b2da1e33165d469467b1046a2189db468da488eb"
integrity sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==
"@swc/core-win32-x64-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz#a2fed9956933027ceb368857bac4bb4ee203d47c"
integrity sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==
"@swc/core-win32-x64-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz#3563f7e8ce8708f5fda43eb8e0956ef11e0da320"
integrity sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==
"@swc/core@^1.15.33", "@swc/core@^1.7.39":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.33.tgz#2a6571c8aca961925f14beae52b3f43c18370fc6"
integrity sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==
"@swc/core@^1.15.40", "@swc/core@^1.7.39":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.40.tgz#941c949aa88c0d8d291f102f519f3c2c77701b90"
integrity sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.26"
optionalDependencies:
"@swc/core-darwin-arm64" "1.15.33"
"@swc/core-darwin-x64" "1.15.33"
"@swc/core-linux-arm-gnueabihf" "1.15.33"
"@swc/core-linux-arm64-gnu" "1.15.33"
"@swc/core-linux-arm64-musl" "1.15.33"
"@swc/core-linux-ppc64-gnu" "1.15.33"
"@swc/core-linux-s390x-gnu" "1.15.33"
"@swc/core-linux-x64-gnu" "1.15.33"
"@swc/core-linux-x64-musl" "1.15.33"
"@swc/core-win32-arm64-msvc" "1.15.33"
"@swc/core-win32-ia32-msvc" "1.15.33"
"@swc/core-win32-x64-msvc" "1.15.33"
"@swc/core-darwin-arm64" "1.15.40"
"@swc/core-darwin-x64" "1.15.40"
"@swc/core-linux-arm-gnueabihf" "1.15.40"
"@swc/core-linux-arm64-gnu" "1.15.40"
"@swc/core-linux-arm64-musl" "1.15.40"
"@swc/core-linux-ppc64-gnu" "1.15.40"
"@swc/core-linux-s390x-gnu" "1.15.40"
"@swc/core-linux-x64-gnu" "1.15.40"
"@swc/core-linux-x64-musl" "1.15.40"
"@swc/core-win32-arm64-msvc" "1.15.40"
"@swc/core-win32-ia32-msvc" "1.15.40"
"@swc/core-win32-x64-msvc" "1.15.40"
"@swc/counter@^0.1.3":
version "0.1.3"
@@ -5568,10 +5568,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.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.31"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
baseline-browser-mapping@^2.10.32, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.32"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64"
integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==
batch@0.6.1:
version "0.6.1"
@@ -9676,10 +9676,10 @@ locate-path@^7.1.0:
dependencies:
p-locate "^6.0.0"
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash-es@4.18.1, lodash-es@^4.17.21:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
lodash.debounce@^4, lodash.debounce@^4.0.8:
version "4.0.8"
@@ -9701,12 +9701,7 @@ lodash.uniq@^4.5.0:
resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
lodash@4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
lodash@4.17.21, lodash@4.18.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
@@ -15209,12 +15204,7 @@ yaml-ast-parser@0.0.43:
resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz"
integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==
yaml@1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^1.10.0:
yaml@1.10.2, yaml@1.10.3, yaml@^1.10.0:
version "1.10.3"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
+2 -1
View File
@@ -33,6 +33,7 @@ PATTERNS = {
r"^setup\.py",
r"^pyproject\.toml$",
r"^requirements/.+\.txt",
r"^pyproject\.toml",
r"^.pylintrc",
],
"frontend": [
@@ -156,7 +157,7 @@ def main(event_type: str, sha: str, repo: str) -> None:
def get_git_sha() -> str:
return os.getenv("GITHUB_SHA") or subprocess.check_output( # noqa: S603
["git", "rev-parse", "HEAD"] # noqa: S607
["git", "rev-parse", "HEAD"] # noqa: S603, S607
).strip().decode("utf-8")
+6 -6
View File
@@ -8020,9 +8020,9 @@
"peer": true
},
"node_modules/tmp": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"engines": {
"node": ">=14.14"
}
@@ -14601,9 +14601,9 @@
"peer": true
},
"tmp": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ=="
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="
},
"to-regex-range": {
"version": "5.0.1",
+129 -129
View File
@@ -194,7 +194,7 @@
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.33",
"@swc/core": "^1.15.40",
"@swc/plugin-emotion": "^14.10.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
@@ -229,7 +229,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.31",
"baseline-browser-mapping": "^2.10.32",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -249,7 +249,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -296,7 +296,7 @@
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.4.1",
"webpack-sources": "^3.5.0",
"webpack-visualizer-plugin2": "^2.0.0"
},
"engines": {
@@ -7768,9 +7768,9 @@
}
},
"node_modules/@npmcli/arborist/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8034,9 +8034,9 @@
}
},
"node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8194,9 +8194,9 @@
}
},
"node_modules/@npmcli/package-json/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8470,9 +8470,9 @@
}
},
"node_modules/@nx/devkit/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12310,9 +12310,9 @@
}
},
"node_modules/@swc/core": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz",
"integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz",
"integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -12328,18 +12328,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.33",
"@swc/core-darwin-x64": "1.15.33",
"@swc/core-linux-arm-gnueabihf": "1.15.33",
"@swc/core-linux-arm64-gnu": "1.15.33",
"@swc/core-linux-arm64-musl": "1.15.33",
"@swc/core-linux-ppc64-gnu": "1.15.33",
"@swc/core-linux-s390x-gnu": "1.15.33",
"@swc/core-linux-x64-gnu": "1.15.33",
"@swc/core-linux-x64-musl": "1.15.33",
"@swc/core-win32-arm64-msvc": "1.15.33",
"@swc/core-win32-ia32-msvc": "1.15.33",
"@swc/core-win32-x64-msvc": "1.15.33"
"@swc/core-darwin-arm64": "1.15.40",
"@swc/core-darwin-x64": "1.15.40",
"@swc/core-linux-arm-gnueabihf": "1.15.40",
"@swc/core-linux-arm64-gnu": "1.15.40",
"@swc/core-linux-arm64-musl": "1.15.40",
"@swc/core-linux-ppc64-gnu": "1.15.40",
"@swc/core-linux-s390x-gnu": "1.15.40",
"@swc/core-linux-x64-gnu": "1.15.40",
"@swc/core-linux-x64-musl": "1.15.40",
"@swc/core-win32-arm64-msvc": "1.15.40",
"@swc/core-win32-ia32-msvc": "1.15.40",
"@swc/core-win32-x64-msvc": "1.15.40"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -12351,9 +12351,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz",
"integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz",
"integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==",
"cpu": [
"arm64"
],
@@ -12367,9 +12367,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz",
"integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz",
"integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==",
"cpu": [
"x64"
],
@@ -12383,9 +12383,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz",
"integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz",
"integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==",
"cpu": [
"arm"
],
@@ -12399,9 +12399,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz",
"integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz",
"integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==",
"cpu": [
"arm64"
],
@@ -12415,9 +12415,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz",
"integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz",
"integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==",
"cpu": [
"arm64"
],
@@ -12431,9 +12431,9 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz",
"integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz",
"integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==",
"cpu": [
"ppc64"
],
@@ -12447,9 +12447,9 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz",
"integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz",
"integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==",
"cpu": [
"s390x"
],
@@ -12463,9 +12463,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz",
"integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz",
"integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==",
"cpu": [
"x64"
],
@@ -12479,9 +12479,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz",
"integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz",
"integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==",
"cpu": [
"x64"
],
@@ -12495,9 +12495,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz",
"integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz",
"integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==",
"cpu": [
"arm64"
],
@@ -12511,9 +12511,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz",
"integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz",
"integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==",
"cpu": [
"ia32"
],
@@ -12527,9 +12527,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz",
"integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==",
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz",
"integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==",
"cpu": [
"x64"
],
@@ -12775,9 +12775,9 @@
}
},
"node_modules/@tufjs/models/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17209,9 +17209,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
"version": "2.10.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -17402,9 +17402,9 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17416,7 +17416,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -17793,9 +17793,9 @@
}
},
"node_modules/cacache/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -22661,9 +22661,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.1.tgz",
"integrity": "sha512-IK0s/+ShN0bkur5moKCu/lfx2D/9uIeozje8Wv2/XnYdmswa17pDg02aUuytEPb8Gf0eueiQFf/QsvOHHcvujg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.2.tgz",
"integrity": "sha512-cqm9DXcsISYZHnFXT5zPH+ITsMx/bYscmq6zIsbtYvei1vj4dZ+BxN9LgoMmjEdm7sTaWxKVRY5IqQRQvau/GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -22848,9 +22848,9 @@
}
},
"node_modules/eslint-plugin-testing-library/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -23258,15 +23258,15 @@
"license": "Apache-2.0"
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -23285,7 +23285,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -26595,9 +26595,9 @@
}
},
"node_modules/ignore-walk/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -26856,9 +26856,9 @@
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -35946,9 +35946,9 @@
}
},
"node_modules/multimatch/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -36615,9 +36615,9 @@
}
},
"node_modules/nx/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -38409,9 +38409,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
"integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -38429,7 +38429,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.8",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -39002,9 +39002,9 @@
"license": "MIT"
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -39423,9 +39423,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -44969,9 +44969,9 @@
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -47104,9 +47104,9 @@
"license": "MIT"
},
"node_modules/vm2": {
"version": "3.11.3",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.3.tgz",
"integrity": "sha512-DO1TTKuOc+veL11VNOvJwRab80mghFKE40Av3bl6pdXs11bdiDMuR73owy+dS2EsTZEvRUeBkkBuDVRjV/RgEw==",
"version": "3.11.5",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.5.tgz",
"integrity": "sha512-RSrkBiwrj6FRU+QdqNs6KG0XdlvJCjpQ4GXiqmMbrhmwfu5k/XIMpAer0L8f6iuf0uJ3a4T1xJN126Q8yf0VIA==",
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0",
@@ -47889,9 +47889,9 @@
}
},
"node_modules/webpack-sources": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz",
"integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz",
"integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -48303,9 +48303,9 @@
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -48924,9 +48924,9 @@
}
},
"packages/generator-superset/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -50174,7 +50174,7 @@
"license": "Apache-2.0",
"dependencies": {
"@types/d3-scale": "^4.0.9",
"d3-cloud": "^1.2.8",
"d3-cloud": "^1.2.9",
"d3-scale": "^4.0.2"
},
"devDependencies": {
+4 -4
View File
@@ -277,7 +277,7 @@
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.33",
"@swc/core": "^1.15.40",
"@swc/plugin-emotion": "^14.10.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
@@ -312,7 +312,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.31",
"baseline-browser-mapping": "^2.10.32",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -332,7 +332,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -379,7 +379,7 @@
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.4.1",
"webpack-sources": "^3.5.0",
"webpack-visualizer-plugin2": "^2.0.0"
},
"peerDependencies": {
@@ -30,7 +30,7 @@
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.1.2",
"yeoman-generator": "^8.2.2",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -56,7 +56,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -239,8 +239,6 @@ export default function transformProps(
formatter,
show: showLabels,
color: theme.colorText,
textBorderColor: theme.colorBgBase,
textBorderWidth: 1,
};
const legendData = keys.sort((a: string, b: string) => {
if (!legendSort) return 0;
@@ -72,6 +72,15 @@ describe('Funnel transformProps', () => {
}),
);
});
test('does not apply a text border to segment labels', () => {
// A white textBorder washes out the dark text on light-colored segments.
const result = transformProps(chartProps as EchartsFunnelChartProps);
const { label } = (result.echartOptions.series as any)[0];
expect(label.color).toBe(supersetTheme.colorText);
expect(label.textBorderColor).toBeUndefined();
expect(label.textBorderWidth).toBeUndefined();
});
});
describe('formatFunnelLabel', () => {
@@ -17,6 +17,7 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { ClientErrorObject, SupersetError } from '@superset-ui/core';
import { FC } from 'react';
import { useChartOwnerNames } from 'src/hooks/apiResources';
@@ -32,7 +33,7 @@ export type Props = {
stackTrace?: string;
} & Omit<ClientErrorObject, 'error'>;
const DEFAULT_CHART_ERROR = 'Data error';
const DEFAULT_CHART_ERROR = t('Data error');
export const ChartErrorMessage: FC<Props> = ({ chartId, error, ...props }) => {
// fetches the chart owners and adds them to the extra data of the error message
@@ -28,6 +28,7 @@ import {
Select,
AsyncSelect,
} from '@superset-ui/core/components';
import { getUserDisplayLabel } from 'src/features/users/utils';
import { FormValues, GroupModalProps } from './types';
import { createGroup, fetchUserOptions, updateGroup } from './utils';
@@ -94,7 +95,7 @@ function GroupListModal({
users:
group?.users?.map(user => ({
value: user.id,
label: user.username,
label: getUserDisplayLabel(user),
})) || [],
};
@@ -19,6 +19,7 @@
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
import rison from 'rison';
import { getUserDisplayLabel } from 'src/features/users/utils';
import { FormValues } from './types';
export const createGroup = async (values: FormValues) => {
@@ -64,7 +65,7 @@ export const fetchUserOptions = async (
return {
data: results.map((user: any) => ({
value: user.id,
label: user.username,
label: getUserDisplayLabel(user),
})),
totalCount: response.json?.count ?? 0,
};
@@ -63,6 +63,16 @@ jest.mock('@superset-ui/core', () => {
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('RoleListEditModal', () => {
beforeEach(() => {
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: { count: 0, result: [] },
});
});
afterEach(() => {
jest.clearAllMocks();
});
const mockRole = {
id: 1,
name: 'Admin',
@@ -147,8 +157,8 @@ describe('RoleListEditModal', () => {
// Wait for user hydration to complete so setFieldsValue has populated
// the form with the fetched users before submitting.
await screen.findByText('johndoe');
await screen.findByText('janesmith');
await screen.findByText('John Doe');
await screen.findByText('Jane Smith');
fireEvent.change(screen.getByTestId('role-name-input'), {
target: { value: 'Updated Role' },
@@ -241,16 +251,19 @@ describe('RoleListEditModal', () => {
test('preserves missing IDs as numeric fallbacks on partial hydration', async () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
) {
// Only return permission id=10, not id=20
return Promise.resolve({
json: {
count: 1,
result: [
{
id: 10,
permission: { name: 'can_read' },
view_menu: { name: 'Dashboard' },
permission_name: 'can_read',
view_menu_name: 'Dashboard',
},
],
},
@@ -284,7 +297,11 @@ describe('RoleListEditModal', () => {
mockToasts.addDangerToast.mockClear();
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
) {
return Promise.reject(new Error('network error'));
}
if (endpoint?.includes('/api/v1/security/groups/')) {
@@ -354,24 +371,26 @@ describe('RoleListEditModal', () => {
};
mockGet.mockImplementation(({ endpoint }) => {
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
const query = rison.decode(endpoint.split('?q=')[1]) as Record<
string,
unknown
>;
const filters = query.filters as Array<{
col: string;
opr: string;
value: number[];
}>;
const ids = filters?.[0]?.value || [];
const result = ids.map((id: number) => ({
id,
permission: { name: `perm_${id}` },
view_menu: { name: `view_${id}` },
}));
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
return Promise.resolve({
json: { count: result.length, result },
json: {
result: roleA.permission_ids.map(pid => ({
id: pid,
permission_name: `perm_${pid}`,
view_menu_name: `view_${pid}`,
})),
},
});
}
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
return Promise.resolve({
json: {
result: roleB.permission_ids.map(pid => ({
id: pid,
permission_name: `perm_${pid}`,
view_menu_name: `view_${pid}`,
})),
},
});
}
return Promise.resolve({ json: { count: 0, result: [] } });
@@ -388,7 +407,7 @@ describe('RoleListEditModal', () => {
await waitFor(() => {
const permCall = mockGet.mock.calls.find(([c]) =>
c.endpoint.includes('/api/v1/security/permissions-resources/'),
c.endpoint.includes(`/api/v1/security/roles/${roleA.id}/permissions/`),
);
expect(permCall).toBeTruthy();
});
@@ -408,26 +427,16 @@ describe('RoleListEditModal', () => {
await waitFor(() => {
const permCalls = mockGet.mock.calls.filter(([c]) =>
c.endpoint.includes('/api/v1/security/permissions-resources/'),
c.endpoint.includes(`/api/v1/security/roles/${roleB.id}/permissions/`),
);
expect(permCalls.length).toBeGreaterThan(0);
// Should request role B's IDs, not role A's
const query = rison.decode(
permCalls[0][0].endpoint.split('?q=')[1],
) as Record<string, unknown>;
const filters = query.filters as Array<{
col: string;
opr: string;
value: number[];
}>;
expect(filters[0].value).toEqual(roleB.permission_ids);
});
unmount();
mockGet.mockReset();
});
test('fetches permissions and groups by id for hydration', async () => {
test('fetches permissions via role endpoint and groups by id for hydration', async () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockResolvedValue({
json: {
@@ -442,8 +451,11 @@ describe('RoleListEditModal', () => {
expect(mockGet).toHaveBeenCalled();
});
// Permissions should be fetched via the role's permissions endpoint (no ID list in URL)
const permissionCall = mockGet.mock.calls.find(([call]) =>
call.endpoint.includes('/api/v1/security/permissions-resources/'),
call.endpoint.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
),
)?.[0];
const groupsCall = mockGet.mock.calls.find(([call]) =>
call.endpoint.includes('/api/v1/security/groups/'),
@@ -455,26 +467,17 @@ describe('RoleListEditModal', () => {
throw new Error('Expected hydration calls to be defined');
}
const permissionQuery = permissionCall.endpoint.match(/\?q=(.+)/);
// Permission endpoint has no query params (role ID is in the path)
expect(permissionCall.endpoint).toBe(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
);
// Groups still use the id-in filter
const groupsQuery = groupsCall.endpoint.match(/\?q=(.+)/);
expect(permissionQuery).toBeTruthy();
expect(groupsQuery).toBeTruthy();
if (!permissionQuery || !groupsQuery) {
throw new Error('Expected query params to be present');
if (!groupsQuery) {
throw new Error('Expected groups query params to be present');
}
expect(rison.decode(permissionQuery[1])).toEqual({
page_size: 100,
page: 0,
filters: [
{
col: 'id',
opr: 'in',
value: mockRole.permission_ids,
},
],
});
expect(rison.decode(groupsQuery[1])).toEqual({
page_size: 100,
page: 0,
@@ -30,9 +30,11 @@ import {
import {
BaseModalProps,
RoleForm,
RolePermissions,
SelectOption,
} from 'src/features/roles/types';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { SupersetClient } from '@superset-ui/core';
import { fetchPaginatedData } from 'src/utils/fetchOptions';
import { type UserObject } from 'src/pages/UsersList/types';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
@@ -49,6 +51,7 @@ import {
updateRoleUsers,
formatPermissionLabel,
} from './utils';
import { getUserDisplayLabel } from 'src/features/users/utils';
export interface RoleListEditModalProps extends BaseModalProps {
role: RoleObject;
@@ -162,34 +165,38 @@ function RoleListEditModal({
return;
}
let cancelled = false;
setLoadingRolePermissions(true);
permissionFetchSucceeded.current = false;
const filters = [{ col: 'id', opr: 'in', value: stablePermissionIds }];
fetchPaginatedData({
endpoint: `/api/v1/security/permissions-resources/`,
pageSize: 100,
setData: (data: SelectOption[]) => {
SupersetClient.get({
endpoint: `/api/v1/security/roles/${id}/permissions/`,
})
.then(response => {
if (cancelled) return;
permissionFetchSucceeded.current = true;
setRolePermissions(data);
},
filters,
setLoadingState: (loading: boolean) => setLoadingRolePermissions(loading),
loadingKey: 'rolePermissions',
addDangerToast,
errorMessage: t('There was an error loading permissions.'),
mapResult: (permission: {
id: number;
permission: { name: string };
view_menu: { name: string };
}) => ({
value: permission.id,
label: formatPermissionLabel(
permission.permission.name,
permission.view_menu.name,
),
}),
});
const result: RolePermissions[] = response.json.result ?? [];
setRolePermissions(
result.map(p => ({
value: p.id,
label: formatPermissionLabel(p.permission_name, p.view_menu_name),
})),
);
})
.catch(() => {
if (!cancelled) {
addDangerToast(t('There was an error loading permissions.'));
}
})
.finally(() => {
if (!cancelled) {
setLoadingRolePermissions(false);
}
});
return () => {
cancelled = true;
};
}, [addDangerToast, id, stablePermissionIds]);
useEffect(() => {
@@ -226,7 +233,7 @@ function RoleListEditModal({
if (!loadingRoleUsers && formRef.current) {
const userOptions = roleUsers.map(user => ({
value: user.id,
label: user.username,
label: getUserDisplayLabel(user),
}));
formRef.current.setFieldsValue({
roleUsers: userOptions,
@@ -315,7 +322,7 @@ function RoleListEditModal({
roleUsers:
roleUsers?.map(user => ({
value: user.id,
label: user.username,
label: getUserDisplayLabel(user),
})) || [],
roleGroups: group_ids.map(groupId => ({
value: groupId,
@@ -44,6 +44,15 @@ export const deleteUser = async (userId: number) =>
endpoint: `/api/v1/security/users/${userId}`,
});
export const getUserDisplayLabel = (user: {
first_name?: string;
last_name?: string;
username?: string;
}): string =>
[user.first_name, user.last_name].filter(Boolean).join(' ') ||
user.username ||
t('N/A');
export const atLeastOneRoleOrGroup =
(fieldToCheck: 'roles' | 'groups') =>
({
+2
View File
@@ -31,6 +31,7 @@ import 'dayjs/locale/pt';
import 'dayjs/locale/pt-br';
import 'dayjs/locale/ru';
import 'dayjs/locale/ko';
import 'dayjs/locale/cs';
import 'dayjs/locale/sk';
import 'dayjs/locale/sl';
import 'dayjs/locale/nl';
@@ -50,6 +51,7 @@ export const LOCALE_MAPPING = {
pt_BR: () => import('antd/locale/pt_BR'),
ru: () => import('antd/locale/ru_RU'),
ko: () => import('antd/locale/ko_KR'),
cs: () => import('antd/locale/cs_CZ'),
sk: () => import('antd/locale/sk_SK'),
sl: () => import('antd/locale/sl_SI'),
nl: () => import('antd/locale/nl_NL'),
+13 -8
View File
@@ -96,16 +96,21 @@ export const fetchPaginatedData = async ({
}
const totalPages = Math.ceil(totalItems / pageSize);
const concurrencyLimit = 5;
const allResults = [...firstPageResults];
const requests = Array.from({ length: totalPages - 1 }, (_, i) =>
fetchPage(i + 1),
);
const remainingResults = await Promise.all(requests);
for (let batch = 1; batch < totalPages; batch += concurrencyLimit) {
const batchEnd = Math.min(batch + concurrencyLimit, totalPages);
// eslint-disable-next-line no-await-in-loop
const batchResults = await Promise.all(
Array.from({ length: batchEnd - batch }, (_, i) =>
fetchPage(batch + i),
),
);
allResults.push(...batchResults.flatMap(res => res.results));
}
setData([
...firstPageResults,
...remainingResults.flatMap(res => res.results),
]);
setData(allResults);
} catch (err) {
addDangerToast(t(errorMessage));
} finally {
+7 -7
View File
@@ -15,7 +15,7 @@
"jsonwebtoken": "^9.0.3",
"lodash": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.20.1"
"ws": "^8.21.0"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
@@ -6428,9 +6428,9 @@
"dev": true
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -11207,9 +11207,9 @@
"dev": true
},
"ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"requires": {}
},
"y18n": {
+1 -1
View File
@@ -23,7 +23,7 @@
"jsonwebtoken": "^9.0.3",
"lodash": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.20.1"
"ws": "^8.21.0"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
+46
View File
@@ -0,0 +1,46 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from functools import partial
from typing import Any
from superset.commands.base import BaseCommand
from superset.commands.css.exceptions import (
CssTemplateCreateFailedError,
CssTemplateInvalidError,
)
from superset.daos.css import CssTemplateDAO
from superset.models.core import CssTemplate
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
class CreateCssTemplateCommand(BaseCommand):
def __init__(self, properties: dict[str, Any]):
self._properties = properties
@transaction(on_error=partial(on_error, reraise=CssTemplateCreateFailedError))
def run(self) -> CssTemplate:
self.validate()
return CssTemplateDAO.create(attributes=self._properties)
def validate(self) -> None:
if not self._properties.get("template_name", "").strip():
raise CssTemplateInvalidError()
if "css" not in self._properties:
raise CssTemplateInvalidError()
+19 -1
View File
@@ -16,7 +16,13 @@
# under the License.
from flask_babel import lazy_gettext as _
from superset.commands.exceptions import CommandException, DeleteFailedError
from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
UpdateFailedError,
)
class CssTemplateDeleteFailedError(DeleteFailedError):
@@ -25,3 +31,15 @@ class CssTemplateDeleteFailedError(DeleteFailedError):
class CssTemplateNotFoundError(CommandException):
message = _("CSS template not found.")
class CssTemplateCreateFailedError(CreateFailedError):
message = _("CSS template could not be created.")
class CssTemplateInvalidError(CommandInvalidError):
message = _("CSS template parameters are invalid.")
class CssTemplateUpdateFailedError(UpdateFailedError):
message = _("CSS template could not be updated.")
+53
View File
@@ -0,0 +1,53 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from functools import partial
from typing import Any
from superset.commands.base import BaseCommand
from superset.commands.css.exceptions import (
CssTemplateInvalidError,
CssTemplateNotFoundError,
CssTemplateUpdateFailedError,
)
from superset.daos.css import CssTemplateDAO
from superset.models.core import CssTemplate
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
class UpdateCssTemplateCommand(BaseCommand):
def __init__(self, model_id: int, properties: dict[str, Any]):
self._model_id = model_id
self._properties = properties
self._model: CssTemplate | None = None
@transaction(on_error=partial(on_error, reraise=CssTemplateUpdateFailedError))
def run(self) -> CssTemplate:
self.validate()
assert self._model
return CssTemplateDAO.update(self._model, attributes=self._properties)
def validate(self) -> None:
self._model = CssTemplateDAO.find_by_id(self._model_id)
if not self._model:
raise CssTemplateNotFoundError()
template_name = self._properties.get("template_name")
if template_name is not None and not template_name.strip():
raise CssTemplateInvalidError()
@@ -22,6 +22,7 @@ from urllib import request
import pandas as pd
from flask import current_app as app
from pandas.errors import OutOfBoundsDatetime
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, String, Text
from sqlalchemy.exc import MultipleResultsFound
from sqlalchemy.sql.visitors import VisitableType
@@ -202,6 +203,39 @@ def import_dataset( # noqa: C901
return dataset
def _convert_temporal_columns(df: pd.DataFrame, dtype: dict[str, Any]) -> None:
"""Convert Date/DateTime columns in-place, coercing only out-of-bounds values."""
for column_name, sqla_type in dtype.items():
if isinstance(sqla_type, (Date, DateTime)):
try:
df[column_name] = pd.to_datetime(df[column_name])
except OutOfBoundsDatetime:
# Row-level fallback: coerce only OOB values; re-raise for malformed
# strings. Whole-column errors="coerce" would silently swallow
# malformed values that happen to share a column with an OOB date.
original = df[column_name].copy()
result = []
for val in original:
if pd.isna(val):
result.append(pd.NaT)
continue
try:
result.append(pd.to_datetime(val))
except OutOfBoundsDatetime:
result.append(pd.NaT)
# Other exceptions (e.g. malformed strings) propagate
converted = pd.Series(result, index=original.index)
n_coerced = int(converted.isna().sum() - original.isna().sum())
if n_coerced > 0:
logger.warning(
"Coerced %d out-of-bounds datetime value(s) "
"in column '%s' to NaT",
n_coerced,
column_name,
)
df[column_name] = converted
def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
"""
Load data from a data URI into a dataset.
@@ -222,10 +256,7 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
df = pd.read_csv(data, encoding="utf-8")
dtype = get_dtype(df, dataset)
# convert temporal columns
for column_name, sqla_type in dtype.items():
if isinstance(sqla_type, (Date, DateTime)):
df[column_name] = pd.to_datetime(df[column_name])
_convert_temporal_columns(df, dtype)
# reuse session when loading data if possible, to make import atomic
if database.sqlalchemy_uri == app.config.get("SQLALCHEMY_DATABASE_URI"):
+47
View File
@@ -30,6 +30,7 @@ from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.extensions import feature_flag_manager
from superset.models.core import Database
from superset.models.dashboard import dashboard_slices
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
from superset.tags.models import Tag, TaggedObject
from superset.utils import json
from superset.utils.core import check_is_safe_zip
@@ -400,3 +401,49 @@ def get_resource_mappings_batched(
mapping.update({str(x.uuid): value_func(x) for x in batch})
offset += batch_size
return mapping
def find_existing_for_import(model_cls: type[Any], uuid: str) -> Any | None:
"""Look up an existing row by UUID for an import, including soft-deleted matches.
Bypasses the soft-delete visibility filter so a soft-deleted row with
the matching UUID is returned, not hidden. Side-effect-free: returns
the row as-is whether it's live or soft-deleted (or ``None`` if no
row exists). The caller is responsible for deciding what to do with
a soft-deleted match typically calling
:func:`clear_soft_deleted_for_import` to remove it before re-import,
but only after the caller has validated overwrite/permission decisions.
Splitting the lookup from the destructive cleanup keeps the
destructive action explicit at the call site, so a future change
that adds a permission check on the overwrite path doesn't
silently leave a "duck around it via soft-delete" backdoor.
"""
return (
db.session.query(model_cls)
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model_cls}})
.filter_by(uuid=uuid)
.first()
)
def clear_soft_deleted_for_import(existing: Any) -> None:
"""Hard-delete a soft-deleted row to free its UUID for re-import.
Uses ``db.session.delete()`` rather than a raw Core ``DELETE`` so
the ORM ``after_delete`` event listeners fire. Cleanup that depends
on those listeners would otherwise be skipped notably tag rows in
``tagged_object`` (cleaned up by ``ObjectUpdater.after_delete`` in
``superset/tags/core.py``; the table's ``object_id`` is a plain
integer, not a foreign key, so the database cannot cascade them)
and dataset permission-view rows (cleaned up by
``SqlaTable.after_delete`` in ``superset/connectors/sqla/models.py``).
Caller contract: ``existing`` must be a soft-deleted row returned
from :func:`find_existing_for_import`. Callers should run their
overwrite / permission validation *before* invoking this so the
destructive action only happens once the import path is committed
to proceeding.
"""
db.session.delete(existing)
db.session.flush()
+98
View File
@@ -0,0 +1,98 @@
# 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.
"""Base class shared by all soft-delete restore commands."""
from functools import partial
from typing import Any, ClassVar, Generic, TypeVar
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.exceptions import SupersetSecurityException
from superset.models.helpers import SoftDeleteMixin
from superset.utils.decorators import on_error, transaction
T = TypeVar("T", bound=SoftDeleteMixin)
class BaseRestoreCommand(BaseCommand, Generic[T]):
"""Base class for soft-delete restore commands.
Subclasses provide the entity-specific bindings as class variables
no method override required:
- ``dao``: the DAO class (e.g. ``ChartDAO``)
- ``not_found_exc``: raised when the row doesn't exist OR isn't
soft-deleted
- ``forbidden_exc``: raised when the caller doesn't have ownership
- ``restore_failed_exc``: re-raised by the transactional wrapper
when an underlying SQLAlchemy error aborts the commit
The transactional wrapper is applied by this class's ``run()``
using ``restore_failed_exc`` as the rethrow type, so each subclass
just declares the four ClassVars and is done. There is no
subclass-managed decorator contract earlier iterations of this
PR required subclasses to override ``run()`` purely to add a
``@transaction`` decorator, which was fragile (every new entity
rollout had to remember).
The model returned from ``validate()`` is the soft-deleted row,
type-narrowed via ``Generic[T]``. ``run()`` calls ``model.restore()``
on it (the method comes from ``SoftDeleteMixin``).
"""
dao: ClassVar[Any]
not_found_exc: ClassVar[type[Exception]]
forbidden_exc: ClassVar[type[Exception]]
restore_failed_exc: ClassVar[type[Exception]]
def __init__(self, model_uuid: str) -> None:
self._model_uuid = model_uuid
def run(self) -> None:
# Build the transactional wrapper at call time so ``on_error`` can
# reference ``self.restore_failed_exc`` — a per-subclass ClassVar
# that isn't available when this method is defined on the base.
@transaction(on_error=partial(on_error, reraise=self.restore_failed_exc))
def _perform() -> None:
model = self.validate()
model.restore()
_perform()
def validate(self) -> T: # type: ignore[override]
# ``skip_visibility_filter=True`` is the *only* bypass — the
# entity's RBAC ``base_filter`` stays in effect, matching the
# behavior of ``find_by_ids`` on the existing delete paths.
# Restore should not see rows the user cannot see in the live
# UI; ownership is then verified by ``raise_for_ownership``.
model = self.dao.find_by_id(
self._model_uuid,
id_column="uuid",
skip_visibility_filter=True,
)
if model is None:
raise self.not_found_exc(f"No row with uuid={self._model_uuid!r}")
if model.deleted_at is None:
raise self.not_found_exc(
f"Row with uuid={self._model_uuid!r} is not soft-deleted; "
"nothing to restore"
)
try:
security_manager.raise_for_ownership(model)
except SupersetSecurityException as ex:
raise self.forbidden_exc() from ex
return model
+2 -2
View File
@@ -51,7 +51,7 @@ from sqlalchemy.orm.query import Query
from superset.advanced_data_type.plugins.internet_address import internet_address
from superset.advanced_data_type.plugins.internet_port import internet_port
from superset.advanced_data_type.types import AdvancedDataType
from superset.constants import CHANGE_ME_SECRET_KEY
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
from superset.jinja_context import BaseTemplateProcessor
from superset.key_value.types import JsonKeyValueCodec
from superset.stats_logger import DummyStatsLogger
@@ -2354,7 +2354,7 @@ GLOBAL_ASYNC_QUERIES_CACHE_BACKEND = {
# Embedded config options
GUEST_ROLE_NAME = "Public"
GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" # noqa: S105
GUEST_TOKEN_JWT_SECRET = CHANGE_ME_GUEST_TOKEN_JWT_SECRET
GUEST_TOKEN_JWT_ALGO = "HS256" # noqa: S105
GUEST_TOKEN_HEADER_NAME = "X-GuestToken" # noqa: S105
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes
+2
View File
@@ -28,6 +28,7 @@ NULL_STRING = "<NULL>"
EMPTY_STRING = "<empty string>"
CHANGE_ME_SECRET_KEY = "CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET" # noqa: S105
CHANGE_ME_GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" # noqa: S105
# UUID for the examples database
EXAMPLES_DB_UUID = "a2dc77af-e654-49bb-b321-40f6b559a1ee"
@@ -174,6 +175,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"put_filters": "write",
"put_colors": "write",
"sync_permissions": "write",
"restore": "write",
}
EXTRA_FORM_DATA_APPEND_KEYS = {
+75 -13
View File
@@ -48,6 +48,7 @@ from superset.daos.exceptions import (
DAOFindFailedError,
)
from superset.extensions import db
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES, SoftDeleteMixin
T = TypeVar("T", bound=CoreModel)
@@ -60,6 +61,7 @@ class ColumnOperatorEnum(str, Enum):
ne = "ne"
sw = "sw"
ew = "ew"
ct = "ct"
in_ = "in"
nin = "nin"
gt = "gt"
@@ -84,11 +86,12 @@ operator_map: Dict[ColumnOperatorEnum, Any] = {
ColumnOperatorEnum.ne: lambda col, val: col != val,
ColumnOperatorEnum.sw: lambda col, val: col.like(f"{val}%"),
ColumnOperatorEnum.ew: lambda col, val: col.like(f"%{val}"),
ColumnOperatorEnum.ct: lambda col, val: col.ilike(f"%{val}%"),
ColumnOperatorEnum.in_: lambda col, val: col.in_(
val if isinstance(val, (list, tuple)) else [val]
),
ColumnOperatorEnum.nin: lambda col, val: ~col.in_(
val if isinstance(val, (list, tuple)) else [val]
ColumnOperatorEnum.nin: lambda col, val: (
~col.in_(val if isinstance(val, (list, tuple)) else [val])
),
ColumnOperatorEnum.gt: lambda col, val: col > val,
ColumnOperatorEnum.gte: lambda col, val: col >= val,
@@ -107,6 +110,7 @@ TYPE_OPERATOR_MAP = {
ColumnOperatorEnum.ne,
ColumnOperatorEnum.sw,
ColumnOperatorEnum.ew,
ColumnOperatorEnum.ct,
ColumnOperatorEnum.in_,
ColumnOperatorEnum.nin,
ColumnOperatorEnum.like,
@@ -181,11 +185,17 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
cls,
model_id_or_uuid: str,
skip_base_filter: bool = False,
*,
skip_visibility_filter: bool = False,
) -> T | None:
"""
Find a model by id or uuid, if defined applies `base_filter`
"""
query = db.session.query(cls.model_cls)
if skip_visibility_filter:
query = query.execution_options(
**{SKIP_VISIBILITY_FILTER_CLASSES: {cls.model_cls}}
)
if cls.base_filter and not skip_base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
@@ -249,6 +259,8 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
value: str | int,
skip_base_filter: bool = False,
query_options: list[Any] | None = None,
*,
skip_visibility_filter: bool = False,
) -> T | None:
"""
Private method to find a model by any column value.
@@ -257,6 +269,7 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
column_name: Name of the column to search by
value: Value to search for
skip_base_filter: Whether to skip base filtering
skip_visibility_filter: Whether to skip the soft-delete visibility filter
query_options: SQLAlchemy query options (e.g., joinedload,
subqueryload) to apply to the query for eager loading
@@ -264,6 +277,10 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
Model instance or None if not found
"""
query = db.session.query(cls.model_cls)
if skip_visibility_filter:
query = query.execution_options(
**{SKIP_VISIBILITY_FILTER_CLASSES: {cls.model_cls}}
)
query = cls._apply_base_filter(query, skip_base_filter)
if query_options:
@@ -290,6 +307,8 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
skip_base_filter: bool = False,
id_column: str | None = None,
query_options: list[Any] | None = None,
*,
skip_visibility_filter: bool = False,
) -> T | None:
"""
Find a model by ID using specified or default ID column.
@@ -300,12 +319,20 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
id_column: Column name to use (defaults to cls.id_column_name)
query_options: SQLAlchemy query options (e.g., joinedload,
subqueryload) to apply to the query for eager loading
skip_visibility_filter: Keyword-only. Whether to skip the
soft-delete visibility filter
Returns:
Model instance or None if not found
"""
column = id_column or cls.id_column_name
return cls._find_by_column(column, model_id, skip_base_filter, query_options)
return cls._find_by_column(
column,
model_id,
skip_base_filter,
query_options,
skip_visibility_filter=skip_visibility_filter,
)
@classmethod
def find_by_ids(
@@ -313,6 +340,8 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
model_ids: Sequence[str | int],
skip_base_filter: bool = False,
id_column: str | None = None,
*,
skip_visibility_filter: bool = False,
) -> list[T]:
"""
Find a List of models by a list of ids, if defined applies `base_filter`
@@ -321,6 +350,8 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
:param skip_base_filter: If true, skip applying the base filter
:param id_column: Optional column name to use for ID lookup
(defaults to id_column_name)
:param skip_visibility_filter: Keyword-only. If true, skip the
soft-delete visibility filter so soft-deleted rows are returned
"""
column = id_column or cls.id_column_name
id_col = getattr(cls.model_cls, column, None)
@@ -347,7 +378,12 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
if not converted_ids:
return []
query = db.session.query(cls.model_cls).filter(id_col.in_(converted_ids))
query = db.session.query(cls.model_cls)
if skip_visibility_filter:
query = query.execution_options(
**{SKIP_VISIBILITY_FILTER_CLASSES: {cls.model_cls}}
)
query = query.filter(id_col.in_(converted_ids))
query = cls._apply_base_filter(query, skip_base_filter)
try:
@@ -429,25 +465,51 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
return item # type: ignore
@classmethod
def delete(cls, items: list[T]) -> None:
def soft_delete(cls, items: list[T]) -> None:
"""Mark items as soft-deleted by setting ``deleted_at``.
Only valid for models that include ``SoftDeleteMixin``.
:param items: The items to soft-delete
"""
Delete the specified items including their associated relationships.
for item in items:
item.soft_delete()
Note that bulk deletion via `delete` is not invoked in the base class as this
does not dispatch the ORM `after_delete` event which may be required to augment
additional records loosely defined via implicit relationships. Instead ORM
objects are deleted one-by-one via `Session.delete`.
@classmethod
def hard_delete(cls, items: list[T]) -> None:
"""Permanently remove rows from the database.
Subclasses may invoke bulk deletion but are responsible for instrumenting any
post-deletion logic.
Note that bulk deletion via ``delete`` is not invoked in the base
class as this does not dispatch the ORM ``after_delete`` event which
may be required to augment additional records loosely defined via
implicit relationships. Instead ORM objects are deleted one-by-one
via ``Session.delete``.
Subclasses may invoke bulk deletion but are responsible for
instrumenting any post-deletion logic.
:param items: The items to delete
:see: https://docs.sqlalchemy.org/en/latest/orm/queryguide/dml.html
"""
for item in items:
db.session.delete(item)
@classmethod
def delete(cls, items: list[T]) -> None:
"""Route to soft or hard delete based on whether the model supports
soft delete.
For models that include ``SoftDeleteMixin``, this calls
``soft_delete()``. For all other models, this calls ``hard_delete()``
(the original behaviour).
:param items: The items to delete
"""
if cls.model_cls is not None and issubclass(cls.model_cls, SoftDeleteMixin):
cls.soft_delete(items)
else:
cls.hard_delete(items)
@classmethod
def query(cls, query: Query) -> list[T]:
"""
+7
View File
@@ -30,6 +30,7 @@ from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.extensions import db
from superset.models.core import Database, DatabaseUserOAuth2Tokens
from superset.models.dashboard import Dashboard
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
from superset.models.slice import Slice
from superset.models.sql_lab import TabState
from superset.utils.core import DatasourceType
@@ -71,6 +72,8 @@ class DatabaseDAO(BaseDAO[Database]):
skip_base_filter: bool = False,
id_column: str | None = None,
query_options: list[Any] | None = None,
*,
skip_visibility_filter: bool = False,
) -> Database | None:
"""
Find a database by id, eagerly loading the SSH tunnel relationship.
@@ -79,6 +82,10 @@ class DatabaseDAO(BaseDAO[Database]):
if query_options:
all_options.extend(query_options)
query = db.session.query(cls.model_cls).options(*all_options)
if skip_visibility_filter:
query = query.execution_options(
**{SKIP_VISIBILITY_FILTER_CLASSES: {cls.model_cls}}
)
query = cls._apply_base_filter(query, skip_base_filter)
column_name = id_column or cls.id_column_name
+28
View File
@@ -0,0 +1,28 @@
# 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.
"""DAO for FAB Role model."""
from __future__ import annotations
from flask_appbuilder.security.sqla.models import Role
from superset.daos.base import BaseDAO
class RoleDAO(BaseDAO[Role]):
"""DAO for FAB Role model. Provides basic CRUD via BaseDAO."""
+64 -11
View File
@@ -37,7 +37,7 @@ from flask_compress import Compress
from flask_session import Session
from werkzeug.middleware.proxy_fix import ProxyFix
from superset.constants import CHANGE_ME_SECRET_KEY
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
from superset.databases.utils import make_url_safe
from superset.extensions import (
_event_logger,
@@ -634,12 +634,17 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
self.init_all_dependencies_and_extensions()
@staticmethod
def _log_config_warning(message: str) -> None:
top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-"
bottom_banner = 80 * "-" + "\n" + 80 * "-"
logger.warning(top_banner)
logger.warning(message)
logger.warning(bottom_banner)
def check_secret_key(self) -> None:
def log_default_secret_key_warning() -> None:
top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-"
bottom_banner = 80 * "-" + "\n" + 80 * "-"
logger.warning(top_banner)
logger.warning(
if self.config["SECRET_KEY"] == CHANGE_ME_SECRET_KEY:
warning = (
"A Default SECRET_KEY was detected, please use superset_config.py "
"to override it.\n"
"Use a strong complex alphanumeric string and use a tool to help"
@@ -648,21 +653,44 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
"For more info, see: https://superset.apache.org/docs/"
"configuration/configuring-superset#specifying-a-secret_key"
)
logger.warning(bottom_banner)
if self.config["SECRET_KEY"] == CHANGE_ME_SECRET_KEY:
if (
self.superset_app.debug
or self.superset_app.config["TESTING"]
or is_test()
):
logger.warning("Debug mode identified with default secret key")
log_default_secret_key_warning()
self._log_config_warning(warning)
return
log_default_secret_key_warning()
self._log_config_warning(warning)
logger.error("Refusing to start due to insecure SECRET_KEY")
sys.exit(1)
def check_guest_token_secret(self) -> None:
"""Refuse to start with default guest JWT secret when embedding is enabled."""
if not feature_flag_manager.is_feature_enabled("EMBEDDED_SUPERSET"):
return
if (
self.config.get("GUEST_TOKEN_JWT_SECRET")
!= CHANGE_ME_GUEST_TOKEN_JWT_SECRET
):
return
self._log_config_warning(
"EMBEDDED_SUPERSET is enabled but GUEST_TOKEN_JWT_SECRET has not "
"been changed from its default value.\n"
"The default value is publicly known and must be replaced before "
"running in production.\n"
"Set a strong random value in superset_config.py:\n"
" GUEST_TOKEN_JWT_SECRET = "
"'<output of: openssl rand -base64 42>'"
)
if self.superset_app.debug or self.superset_app.config["TESTING"] or is_test():
return
logger.error(
"Refusing to start: insecure GUEST_TOKEN_JWT_SECRET "
"with EMBEDDED_SUPERSET enabled"
)
sys.exit(1)
def configure_session(self) -> None:
if self.config["SESSION_SERVER_SIDE"]:
Session(self.superset_app)
@@ -747,6 +775,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
# Configuration of feature_flags must be done first to allow init features
# conditionally
self.configure_feature_flags()
self.check_guest_token_secret()
self.configure_db_encrypt()
self.setup_db()
@@ -767,6 +796,13 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
with self.superset_app.app_context():
self.init_app_in_ctx()
# Registered outside ``init_app_in_ctx`` because the SQLAlchemy
# event hook attaches to the ``Session`` *class* (a process-wide
# global), not to a Session instance — it has no dependency on
# the Flask app context. ``setup_db()`` ran earlier in
# ``init_app``, so the ``Session`` import has already been
# initialised by the time we get here.
self.setup_soft_delete_listener()
self.post_init()
def set_db_default_isolation(self) -> None:
@@ -949,6 +985,23 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
migrate.init_app(self.superset_app, db=db, directory=APP_DIR + "/migrations")
def setup_soft_delete_listener(self) -> None:
"""Register the global soft-delete filter on the SQLAlchemy Session.
Must be called after ``setup_db()`` so the Session class is
available. Uses the ``do_orm_execute`` + ``with_loader_criteria``
pattern recommended by SQLAlchemy maintainer Mike Bayer for
soft deletion in SQLAlchemy 1.4+:
https://github.com/sqlalchemy/sqlalchemy/issues/7973#issuecomment-1112561295
"""
from sqlalchemy import event
from sqlalchemy.orm import Session
from superset.models.helpers import _add_soft_delete_filter
if not event.contains(Session, "do_orm_execute", _add_soft_delete_filter):
event.listen(Session, "do_orm_execute", _add_soft_delete_filter)
def configure_wtf(self) -> None:
if self.config["WTF_CSRF_ENABLED"]:
csrf.init_app(self.superset_app)
@@ -0,0 +1,16 @@
# 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.
+302
View File
@@ -0,0 +1,302 @@
# 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.
"""Pydantic schemas for action-log MCP tools."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated, Any, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils import sanitize_for_llm_context
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
from superset.utils import json as json_utils
DEFAULT_LOG_COLUMNS: list[str] = ["id", "action", "user_id", "dttm"]
ALL_LOG_COLUMNS: list[str] = [
"id",
"action",
"user_id",
"dttm",
"dashboard_id",
"slice_id",
"json",
]
LOG_SORTABLE_COLUMNS: list[str] = ["id", "dttm"]
class ActionLogFilter(ColumnOperator):
"""Filter object for action-log listing.
col: Column to filter on.
opr: Operator to use.
value: Value to filter by.
"""
col: Literal["action", "user_id", "dashboard_id", "slice_id", "dttm"] = Field(
...,
description="Column to filter on.",
)
opr: ColumnOperatorEnum = Field(..., description="Operator to use.")
value: (
str | int | float | bool | datetime | list[str | int | float | bool | datetime]
) = Field(..., description="Value to filter by")
@model_validator(mode="after")
def normalize_dttm_value(self) -> "ActionLogFilter":
"""Normalize string dttm values to datetime to avoid VARCHAR bind mismatch.
Pydantic's left-to-right union matching keeps ISO strings as str when
str appears before datetime in the union. This validator parses them so
the DAO always receives a typed datetime for TIMESTAMP column comparisons.
Both scalar and list values are normalized so dttm IN (...) is also safe.
Replaces a trailing 'Z' with '+00:00' before parsing because
datetime.fromisoformat does not accept the 'Z' suffix on Python < 3.11.
"""
def _parse(val: str) -> datetime | str:
try:
s = val[:-1] + "+00:00" if val.endswith("Z") else val
parsed = datetime.fromisoformat(s)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
return val
if self.col == "dttm":
if isinstance(self.value, str):
self.value = _parse(self.value)
elif isinstance(self.value, list):
self.value = [
_parse(v) if isinstance(v, str) else v for v in self.value
]
return self
class ActionLogInfo(BaseModel):
id: int | None = Field(None, description="Log entry ID")
action: str | None = Field(None, description="Action name")
user_id: int | None = Field(
None, description="ID of the user who performed the action"
)
dttm: str | datetime | None = Field(None, description="Timestamp of the action")
dashboard_id: int | None = Field(None, description="Associated dashboard ID")
slice_id: int | None = Field(None, description="Associated chart/slice ID")
json: str | None = Field(
None,
description="JSON payload (user-controlled, wrapped in UNTRUSTED-CONTENT)",
)
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
def model_post_init(self, __context: Any) -> None:
if isinstance(self.dttm, datetime) and self.dttm.tzinfo is None:
object.__setattr__(self, "dttm", self.dttm.replace(tzinfo=timezone.utc))
@model_serializer(mode="wrap")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> dict[str, Any]:
data = serializer(self)
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
requested_fields = set(select_columns)
return {k: v for k, v in data.items() if k in requested_fields}
return data
class ActionLogList(BaseModel):
action_logs: list[ActionLogInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: list[str] = Field(default_factory=list)
columns_loaded: list[str] = Field(default_factory=list)
columns_available: list[str] = Field(default_factory=list)
sortable_columns: list[str] = Field(default_factory=list)
filters_applied: list[ActionLogFilter] = Field(default_factory=list)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListActionLogsRequest(BaseModel):
"""Request schema for list_action_logs."""
filters: Annotated[
list[ActionLogFilter],
Field(
default_factory=list,
description=(
"List of filter objects (col, opr, value). "
"Filter columns: action, user_id, dashboard_id, slice_id, dttm. "
"Cannot be used with 'search'."
),
),
]
select_columns: Annotated[
list[str],
Field(
default_factory=list,
description="Columns to return. Defaults to common columns.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description=(
"Text search string matched against action. "
"Cannot be used together with 'filters'."
),
),
]
order_column: Annotated[
str | None,
Field(default=None, description="Column to sort by (default: dttm)"),
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(default="desc", description="Sort direction ('asc' or 'desc')"),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> list[ActionLogFilter]:
return parse_json_or_model_list(v, ActionLogFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> list[str]:
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListActionLogsRequest":
if self.search and self.filters:
raise ValueError(
"Cannot use both 'search' and 'filters' simultaneously. "
"Use 'search' for text matching on action, or 'filters' for "
"column-based filtering, but not both."
)
return self
class ActionLogError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Error type")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
@classmethod
def create(cls, error: str, error_type: str) -> "ActionLogError":
return cls(
error=error,
error_type=error_type,
timestamp=datetime.now(timezone.utc),
)
class GetActionLogInfoRequest(BaseModel):
"""Request schema for get_action_log_info (ID-only lookup)."""
identifier: Annotated[
int,
Field(description="Log entry ID (integer)"),
]
def _sanitize_log_json(raw: Any) -> str | None:
"""Serialize the log JSON blob to a canonical string and wrap it in
UNTRUSTED-CONTENT delimiters.
The entire JSON blob keys and values alike is user-controlled and must
be treated as untrusted. Wrapping the canonical JSON string (rather than
processing individual dict leaves) closes the dict-key injection gap: no
key can inject instructions because every byte of the blob is enclosed
within the trust boundary.
Falls back to wrapping the raw string when the payload is not valid JSON.
"""
if raw is None:
return None
if isinstance(raw, str):
try:
canonical = json_utils.dumps(json_utils.loads(raw))
except (ValueError, TypeError):
canonical = raw
else:
try:
canonical = json_utils.dumps(raw)
except (ValueError, TypeError):
canonical = str(raw)
return sanitize_for_llm_context(
canonical,
field_path=("json",),
excluded_field_names=frozenset(),
)
def serialize_action_log_object(log: Any) -> ActionLogInfo | None:
if not log:
return None
dttm = getattr(log, "dttm", None)
if isinstance(dttm, datetime) and dttm.tzinfo is None:
dttm = dttm.replace(tzinfo=timezone.utc)
return ActionLogInfo(
id=getattr(log, "id", None),
action=getattr(log, "action", None),
user_id=getattr(log, "user_id", None),
dttm=dttm,
dashboard_id=getattr(log, "dashboard_id", None),
slice_id=getattr(log, "slice_id", None),
json=_sanitize_log_json(getattr(log, "json", None)),
)
@@ -0,0 +1,24 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .get_action_log_info import get_action_log_info
from .list_action_logs import list_action_logs
__all__ = [
"list_action_logs",
"get_action_log_info",
]
@@ -0,0 +1,97 @@
# 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.
"""Get action log info MCP tool."""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.daos.log import LogDAO
from superset.extensions import event_logger
from superset.mcp_service.action_log.schemas import (
ActionLogError,
ActionLogInfo,
GetActionLogInfoRequest,
serialize_action_log_object,
)
from superset.mcp_service.mcp_core import ModelGetInfoCore
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Log",
annotations=ToolAnnotations(
title="Get action log info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_action_log_info(
request: GetActionLogInfoRequest,
ctx: Context,
) -> ActionLogInfo | ActionLogError:
"""Get a single action log entry by its integer ID.
Returns the action, user_id, timestamp (dttm), dashboard_id, slice_id,
and JSON payload for the specified log record.
Requires the Log permission (controlled by Superset's RBAC). Users without
that permission will receive a permission error.
Use list_action_logs to discover log IDs.
"""
await ctx.info("Retrieving action log: identifier=%s" % (request.identifier,))
try:
with event_logger.log_context(action="mcp.get_action_log_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=LogDAO,
output_schema=ActionLogInfo,
error_schema=ActionLogError,
serializer=serialize_action_log_object,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.identifier)
if isinstance(result, ActionLogInfo):
await ctx.info(
"Action log retrieved: id=%s, action=%s" % (result.id, result.action)
)
else:
await ctx.warning(
"Action log retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"Action log retrieval failed: identifier=%s, error=%s, error_type=%s"
% (request.identifier, str(e), type(e).__name__)
)
return ActionLogError(
error=f"Failed to get action log info: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)
@@ -0,0 +1,152 @@
# 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.
"""List action logs MCP tool."""
import logging
from datetime import datetime, timedelta, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.daos.log import LogDAO
from superset.extensions import event_logger
from superset.mcp_service.action_log.schemas import (
ActionLogError,
ActionLogFilter,
ActionLogInfo,
ActionLogList,
ALL_LOG_COLUMNS,
DEFAULT_LOG_COLUMNS,
ListActionLogsRequest,
LOG_SORTABLE_COLUMNS,
serialize_action_log_object,
)
from superset.mcp_service.mcp_core import ModelListCore
logger = logging.getLogger(__name__)
_DEFAULT_LIST_ACTION_LOGS_REQUEST = ListActionLogsRequest()
@tool(
tags=["core"],
class_permission_name="Log",
annotations=ToolAnnotations(
title="List action logs",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_action_logs(
request: ListActionLogsRequest | None = None,
ctx: Context | None = None,
) -> ActionLogList | ActionLogError:
"""List Superset action logs with filtering and pagination.
Returns audit log entries recording user interactions with dashboards and
charts. Defaults to the last 7 days to avoid pulling large result sets.
Requires the Log permission (controlled by Superset's RBAC). Users without
that permission will receive a permission error.
Sortable columns for order_column: id, dttm
Filter columns: action, user_id, dashboard_id, slice_id, dttm
When no dttm filter is provided the tool automatically applies
dttm >= (now - 7 days). Add an explicit dttm filter to override.
"""
if ctx is None:
raise RuntimeError("FastMCP context is required for list_action_logs")
request = request or _DEFAULT_LIST_ACTION_LOGS_REQUEST.model_copy(deep=True)
await ctx.info(
"Listing action logs: page=%s, page_size=%s" % (request.page, request.page_size)
)
await ctx.debug(
"Action log parameters: filters=%s, order_column=%s, order_direction=%s"
% (request.filters, request.order_column, request.order_direction)
)
try:
# Inject default 7-day dttm filter unless caller already provides one
filters: list[ColumnOperator] = list(request.filters)
has_dttm_filter = any(getattr(f, "col", None) == "dttm" for f in filters)
if not has_dttm_filter:
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
default_filter = ActionLogFilter(
col="dttm",
opr=ColumnOperatorEnum.gte,
value=cutoff,
)
filters = [default_filter] + filters
await ctx.debug("Applied default 7-day dttm filter: cutoff=%s" % (cutoff,))
def _serialize(obj: object, cols: list[str] | None) -> ActionLogInfo | None:
return serialize_action_log_object(obj)
list_tool = ModelListCore(
dao_class=LogDAO,
output_schema=ActionLogInfo,
item_serializer=_serialize,
filter_type=ActionLogFilter,
default_columns=DEFAULT_LOG_COLUMNS,
search_columns=["action"],
list_field_name="action_logs",
output_list_schema=ActionLogList,
all_columns=ALL_LOG_COLUMNS,
sortable_columns=LOG_SORTABLE_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_action_logs.query"):
result = list_tool.run_tool(
filters=filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column or "dttm",
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"Action logs listed: count=%s, total_count=%s"
% (
len(result.action_logs) if hasattr(result, "action_logs") else 0,
getattr(result, "total_count", None),
)
)
columns_to_filter = result.columns_requested
await ctx.debug(
"Applying field filtering via serialization context: columns=%s"
% (columns_to_filter,)
)
with event_logger.log_context(action="mcp.list_action_logs.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(
"Action log listing failed: page=%s, error=%s, error_type=%s"
% (request.page, str(e), type(e).__name__)
)
raise
@@ -0,0 +1,16 @@
# 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.
@@ -0,0 +1,367 @@
# 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.
"""Pydantic schemas for annotation layer and annotation responses."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils import sanitize_for_llm_context
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
from superset.utils import json as json_utils
DEFAULT_LAYER_COLUMNS = ["id", "name", "descr"]
DEFAULT_ANNOTATION_COLUMNS = ["id", "short_descr", "start_dttm", "end_dttm", "layer_id"]
class AnnotationLayerFilter(ColumnOperator):
"""Filter object for annotation layer listing."""
col: Literal["name"] = Field(
...,
description="Column to filter on. Supported: 'name'.",
)
opr: ColumnOperatorEnum = Field(..., description="Filter operator.")
value: str | int | float | bool | list[str | int | float | bool] = Field(
..., description="Value to filter by."
)
class AnnotationFilter(ColumnOperator):
"""Filter object for annotation listing."""
col: Literal["short_descr"] = Field(
...,
description="Column to filter on. Supported: 'short_descr'.",
)
opr: ColumnOperatorEnum = Field(..., description="Filter operator.")
value: str | int | float | bool | list[str | int | float | bool] = Field(
..., description="Value to filter by."
)
class AnnotationLayerInfo(BaseModel):
id: int | None = Field(None, description="Annotation layer ID")
name: str | None = Field(None, description="Annotation layer name")
descr: str | None = Field(None, description="Annotation layer description")
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
)
created_on: str | datetime | None = Field(None, description="Creation timestamp")
model_config = ConfigDict(from_attributes=True, ser_json_timedelta="iso8601")
class AnnotationLayerList(BaseModel):
annotation_layers: list[AnnotationLayerInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: list[str] = Field(default_factory=list)
columns_loaded: list[str] = Field(default_factory=list)
columns_available: list[str] = Field(default_factory=list)
sortable_columns: list[str] = Field(default_factory=list)
filters_applied: list[AnnotationLayerFilter] = Field(default_factory=list)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListAnnotationLayersRequest(BaseModel):
"""Request schema for list_annotation_layers."""
filters: Annotated[
list[AnnotationLayerFilter],
Field(
default_factory=list,
description="List of filter objects. Cannot be combined with 'search'.",
),
]
select_columns: Annotated[
list[str],
Field(
default_factory=list,
description="Columns to include in the response.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search across annotation layer name and description.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by.")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(default="desc", description="Sort direction."),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number (1-based)."),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Items per page (max {MAX_PAGE_SIZE}).",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> list[AnnotationLayerFilter]:
return parse_json_or_model_list(v, AnnotationLayerFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> list[str]:
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListAnnotationLayersRequest":
if self.search and self.filters:
raise ValueError("Cannot use both 'search' and 'filters' simultaneously.")
return self
class GetAnnotationLayerInfoRequest(BaseModel):
"""Request schema for get_annotation_layer_info."""
id: Annotated[int, Field(description="Annotation layer ID.")]
class AnnotationInfo(BaseModel):
id: int | None = Field(None, description="Annotation ID")
short_descr: str | None = Field(None, description="Short description")
long_descr: str | None = Field(None, description="Long description")
start_dttm: str | datetime | None = Field(None, description="Start datetime")
end_dttm: str | datetime | None = Field(None, description="End datetime")
json_metadata: str | None = Field(None, description="JSON metadata")
layer_id: int | None = Field(None, description="Parent annotation layer ID")
model_config = ConfigDict(from_attributes=True, ser_json_timedelta="iso8601")
class AnnotationList(BaseModel):
annotations: list[AnnotationInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
# layer_id defaults to 0; the tool sets it after ModelListCore constructs this
# object. ModelListCore does not know about this domain-specific field.
layer_id: int = 0
columns_requested: list[str] = Field(default_factory=list)
columns_loaded: list[str] = Field(default_factory=list)
columns_available: list[str] = Field(default_factory=list)
sortable_columns: list[str] = Field(default_factory=list)
filters_applied: list[ColumnOperator] = Field(default_factory=list)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListLayerAnnotationsRequest(BaseModel):
"""Request schema for list_layer_annotations."""
layer_id: Annotated[
int, Field(description="Annotation layer ID to list annotations for.")
]
filters: Annotated[
list[AnnotationFilter],
Field(
default_factory=list,
description="List of filter objects. Cannot be combined with 'search'.",
),
]
select_columns: Annotated[
list[str],
Field(default_factory=list, description="Columns to include in the response."),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search across annotation short and long description.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by.")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(default="desc", description="Sort direction."),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number (1-based)."),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Items per page (max {MAX_PAGE_SIZE}).",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> list[AnnotationFilter]:
return parse_json_or_model_list(v, AnnotationFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> list[str]:
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListLayerAnnotationsRequest":
if self.search and self.filters:
raise ValueError("Cannot use both 'search' and 'filters' simultaneously.")
return self
class GetLayerAnnotationInfoRequest(BaseModel):
"""Request schema for get_layer_annotation_info."""
layer_id: Annotated[int, Field(description="Annotation layer ID.")]
annotation_id: Annotated[int, Field(description="Annotation ID.")]
class AnnotationLayerError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
@classmethod
def create(cls, error: str, error_type: str) -> "AnnotationLayerError":
from datetime import timezone
return cls(
error=error,
error_type=error_type,
timestamp=datetime.now(timezone.utc),
)
def _sanitize_annotation_layer_for_llm_context(
info: AnnotationLayerInfo,
) -> AnnotationLayerInfo:
payload = info.model_dump(mode="python")
for field_name in ("name", "descr"):
payload[field_name] = sanitize_for_llm_context(
payload.get(field_name), field_path=(field_name,)
)
return AnnotationLayerInfo.model_validate(payload)
def _sanitize_annotation_json_metadata(raw: Any) -> str | None:
"""Canonicalize and sanitize the json_metadata blob before LLM exposure.
Serializing to a canonical JSON string first prevents dict-key injection:
keys are rendered as quoted string literals inside the wrapped value rather
than being able to escape the delimiter context.
"""
if raw is None:
return None
if isinstance(raw, str):
try:
canonical: str = json_utils.dumps(json_utils.loads(raw))
except (ValueError, TypeError):
canonical = raw
else:
try:
canonical = json_utils.dumps(raw)
except (ValueError, TypeError):
canonical = str(raw)
return sanitize_for_llm_context(
canonical,
field_path=("json_metadata",),
excluded_field_names=frozenset(),
)
def _sanitize_annotation_for_llm_context(info: AnnotationInfo) -> AnnotationInfo:
payload = info.model_dump(mode="python")
for field_name in ("short_descr", "long_descr"):
payload[field_name] = sanitize_for_llm_context(
payload.get(field_name), field_path=(field_name,)
)
payload["json_metadata"] = _sanitize_annotation_json_metadata(
payload.get("json_metadata")
)
return AnnotationInfo.model_validate(payload)
def serialize_annotation_layer(obj: Any) -> AnnotationLayerInfo | None:
if not obj:
return None
return _sanitize_annotation_layer_for_llm_context(
AnnotationLayerInfo(
id=getattr(obj, "id", None),
name=getattr(obj, "name", None),
descr=getattr(obj, "descr", None),
changed_on=getattr(obj, "changed_on", None),
created_on=getattr(obj, "created_on", None),
)
)
def serialize_annotation(obj: Any) -> AnnotationInfo | None:
if not obj:
return None
return _sanitize_annotation_for_llm_context(
AnnotationInfo(
id=getattr(obj, "id", None),
short_descr=getattr(obj, "short_descr", None),
long_descr=getattr(obj, "long_descr", None),
start_dttm=getattr(obj, "start_dttm", None),
end_dttm=getattr(obj, "end_dttm", None),
json_metadata=getattr(obj, "json_metadata", None),
layer_id=getattr(obj, "layer_id", None),
)
)
@@ -0,0 +1,28 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .get_annotation_layer_info import get_annotation_layer_info
from .get_layer_annotation_info import get_layer_annotation_info
from .list_annotation_layers import list_annotation_layers
from .list_layer_annotations import list_layer_annotations
__all__ = [
"list_annotation_layers",
"get_annotation_layer_info",
"list_layer_annotations",
"get_layer_annotation_info",
]
@@ -0,0 +1,97 @@
# 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.
"""Get annotation layer info FastMCP tool."""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.annotation_layer.schemas import (
AnnotationLayerError,
AnnotationLayerInfo,
GetAnnotationLayerInfoRequest,
serialize_annotation_layer,
)
from superset.mcp_service.mcp_core import ModelGetInfoCore
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Annotation",
annotations=ToolAnnotations(
title="Get annotation layer info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_annotation_layer_info(
request: GetAnnotationLayerInfoRequest,
ctx: Context,
) -> AnnotationLayerInfo | AnnotationLayerError:
"""Get detailed information about an annotation layer by ID.
Returns the layer's name, description, and timestamps.
Example:
```json
{"id": 1}
```
"""
await ctx.info("Retrieving annotation layer: id=%s" % (request.id,))
try:
from superset.daos.annotation_layer import AnnotationLayerDAO
with event_logger.log_context(action="mcp.get_annotation_layer_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=AnnotationLayerDAO,
output_schema=AnnotationLayerInfo,
error_schema=AnnotationLayerError,
serializer=serialize_annotation_layer,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.id)
if isinstance(result, AnnotationLayerInfo):
await ctx.info(
"Annotation layer retrieved: id=%s, name=%s" % (result.id, result.name)
)
else:
await ctx.warning(
"Annotation layer not found: id=%s, error_type=%s"
% (request.id, result.error_type)
)
return result
except Exception as e:
await ctx.error(
"Annotation layer lookup failed: id=%s, error=%s, error_type=%s"
% (request.id, str(e), type(e).__name__)
)
return AnnotationLayerError(
error=f"Failed to get annotation layer info: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)
@@ -0,0 +1,130 @@
# 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.
"""Get a single annotation within a layer FastMCP tool."""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.annotation_layer.schemas import (
AnnotationInfo,
AnnotationLayerError,
GetLayerAnnotationInfoRequest,
serialize_annotation,
)
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Annotation",
annotations=ToolAnnotations(
title="Get annotation info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_layer_annotation_info(
request: GetLayerAnnotationInfoRequest,
ctx: Context,
) -> AnnotationInfo | AnnotationLayerError:
"""Get detailed information about a specific annotation within a layer.
Both layer_id and annotation_id are required. Returns an error if the
annotation does not belong to the specified layer.
Example:
```json
{"layer_id": 1, "annotation_id": 42}
```
"""
await ctx.info(
"Retrieving annotation: layer_id=%s, annotation_id=%s"
% (request.layer_id, request.annotation_id)
)
try:
from superset.daos.annotation_layer import AnnotationDAO, AnnotationLayerDAO
# Verify the layer exists
with event_logger.log_context(
action="mcp.get_layer_annotation_info.layer_lookup"
):
layer = AnnotationLayerDAO.find_by_id(request.layer_id)
if layer is None:
await ctx.warning("Annotation layer not found: id=%s" % (request.layer_id,))
return AnnotationLayerError.create(
error=f"Annotation layer with id '{request.layer_id}' not found",
error_type="not_found",
)
# Fetch the annotation
with event_logger.log_context(
action="mcp.get_layer_annotation_info.annotation_lookup"
):
annotation = AnnotationDAO.find_by_id(request.annotation_id)
if annotation is None:
await ctx.warning(
"Annotation not found: annotation_id=%s" % (request.annotation_id,)
)
return AnnotationLayerError.create(
error=f"Annotation with id '{request.annotation_id}' not found",
error_type="not_found",
)
# Verify the annotation belongs to the requested layer
if getattr(annotation, "layer_id", None) != request.layer_id:
await ctx.warning(
"Annotation %s does not belong to layer %s"
% (request.annotation_id, request.layer_id)
)
return AnnotationLayerError.create(
error=(
f"Annotation '{request.annotation_id}' does not belong to "
f"layer '{request.layer_id}'"
),
error_type="not_found",
)
result = serialize_annotation(annotation)
await ctx.info(
"Annotation retrieved: id=%s, short_descr=%s"
% (result.id if result else None, result.short_descr if result else None)
)
return result or AnnotationLayerError.create(
error="Failed to serialize annotation",
error_type="SerializationError",
)
except Exception as e:
await ctx.error(
"Annotation lookup failed: layer_id=%s, annotation_id=%s, "
"error=%s, error_type=%s"
% (request.layer_id, request.annotation_id, str(e), type(e).__name__)
)
return AnnotationLayerError(
error=f"Failed to get annotation info: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)
@@ -0,0 +1,123 @@
# 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.
"""List annotation layers FastMCP tool."""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.annotation_layer.schemas import (
AnnotationLayerError,
AnnotationLayerFilter,
AnnotationLayerInfo,
AnnotationLayerList,
DEFAULT_LAYER_COLUMNS,
ListAnnotationLayersRequest,
serialize_annotation_layer,
)
from superset.mcp_service.mcp_core import ModelListCore
logger = logging.getLogger(__name__)
_DEFAULT_REQUEST = ListAnnotationLayersRequest()
_ALL_LAYER_COLUMNS = ["id", "name", "descr", "changed_on", "created_on"]
_SORTABLE_LAYER_COLUMNS = ["id", "name", "changed_on", "created_on"]
@tool(
tags=["core"],
class_permission_name="Annotation",
annotations=ToolAnnotations(
title="List annotation layers",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_annotation_layers(
request: ListAnnotationLayersRequest | None = None,
ctx: Context | None = None,
) -> AnnotationLayerList | AnnotationLayerError:
"""List annotation layers with filtering, search, and pagination.
Returns annotation layer metadata including name and description.
Sortable columns for order_column: id, name, changed_on, created_on
"""
if ctx is None:
raise RuntimeError("FastMCP context is required for list_annotation_layers")
request = request or _DEFAULT_REQUEST.model_copy(deep=True)
await ctx.info(
"Listing annotation layers: page=%s, page_size=%s, search=%s"
% (request.page, request.page_size, request.search)
)
try:
from superset.daos.annotation_layer import AnnotationLayerDAO
def _serialize(
obj: object, cols: list[str] | None
) -> AnnotationLayerInfo | None:
return serialize_annotation_layer(obj)
list_tool = ModelListCore(
dao_class=AnnotationLayerDAO,
output_schema=AnnotationLayerInfo,
item_serializer=_serialize,
filter_type=AnnotationLayerFilter,
default_columns=DEFAULT_LAYER_COLUMNS,
search_columns=["name", "descr"],
list_field_name="annotation_layers",
output_list_schema=AnnotationLayerList,
all_columns=_ALL_LAYER_COLUMNS,
sortable_columns=_SORTABLE_LAYER_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_annotation_layers.query"):
result = list_tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"Annotation layers listed: count=%s, total_count=%s"
% (
len(result.annotation_layers)
if hasattr(result, "annotation_layers")
else 0,
getattr(result, "total_count", None),
)
)
return result
except Exception as e:
await ctx.error(
"Annotation layer listing failed: error=%s, error_type=%s"
% (str(e), type(e).__name__)
)
raise
@@ -0,0 +1,144 @@
# 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.
"""List annotations within a layer FastMCP tool."""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.extensions import event_logger
from superset.mcp_service.annotation_layer.schemas import (
AnnotationFilter,
AnnotationInfo,
AnnotationLayerError,
AnnotationList,
DEFAULT_ANNOTATION_COLUMNS,
ListLayerAnnotationsRequest,
serialize_annotation,
)
from superset.mcp_service.mcp_core import ModelListCore
logger = logging.getLogger(__name__)
_ALL_ANNOTATION_COLUMNS = [
"id",
"short_descr",
"long_descr",
"start_dttm",
"end_dttm",
"json_metadata",
"layer_id",
]
_SORTABLE_ANNOTATION_COLUMNS = ["id", "short_descr", "start_dttm", "end_dttm"]
@tool(
tags=["core"],
class_permission_name="Annotation",
annotations=ToolAnnotations(
title="List annotations in a layer",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_layer_annotations(
request: ListLayerAnnotationsRequest,
ctx: Context,
) -> AnnotationList | AnnotationLayerError:
"""List annotations within a specific annotation layer.
The layer_id parameter is required and scopes all results to that layer.
Sortable columns for order_column: id, short_descr, start_dttm, end_dttm
Example:
```json
{"layer_id": 1, "page": 1, "page_size": 25}
```
"""
await ctx.info(
"Listing annotations: layer_id=%s, page=%s, page_size=%s, search=%s"
% (request.layer_id, request.page, request.page_size, request.search)
)
try:
from superset.daos.annotation_layer import AnnotationDAO, AnnotationLayerDAO
# Verify the layer exists before listing
layer = AnnotationLayerDAO.find_by_id(request.layer_id)
if layer is None:
await ctx.warning("Annotation layer not found: id=%s" % (request.layer_id,))
return AnnotationLayerError.create(
error=f"Annotation layer with id '{request.layer_id}' not found",
error_type="not_found",
)
# Prepend the layer_id filter so results are scoped to this layer
layer_filter = ColumnOperator(
col="layer_id", opr=ColumnOperatorEnum.eq, value=request.layer_id
)
combined_filters: list[ColumnOperator] = [layer_filter] + list(request.filters)
def _serialize(obj: object, cols: list[str] | None) -> AnnotationInfo | None:
return serialize_annotation(obj)
list_tool = ModelListCore(
dao_class=AnnotationDAO,
output_schema=AnnotationInfo,
item_serializer=_serialize,
filter_type=AnnotationFilter,
default_columns=DEFAULT_ANNOTATION_COLUMNS,
search_columns=["short_descr", "long_descr"],
list_field_name="annotations",
output_list_schema=AnnotationList,
all_columns=_ALL_ANNOTATION_COLUMNS,
sortable_columns=_SORTABLE_ANNOTATION_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_layer_annotations.query"):
result = list_tool.run_tool(
filters=combined_filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
result.layer_id = request.layer_id
await ctx.info(
"Annotations listed: layer_id=%s, count=%s, total_count=%s"
% (
request.layer_id,
len(result.annotations) if hasattr(result, "annotations") else 0,
getattr(result, "total_count", None),
)
)
return result
except Exception as e:
await ctx.error(
"Annotation listing failed: layer_id=%s, error=%s, error_type=%s"
% (request.layer_id, str(e), type(e).__name__)
)
raise
+134 -2
View File
@@ -127,13 +127,40 @@ Available tools:
Dashboard Management:
- list_dashboards: List dashboards with advanced filters (1-based pagination)
- get_dashboard_info: Get detailed dashboard information by ID
- get_dashboard_layout: Get parsed tabs and chart positions for a dashboard (companion to get_dashboard_info when its omitted_fields hint flags position_json)
- generate_dashboard: Create a dashboard from chart IDs (requires write access)
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
Annotation Layers:
- list_annotation_layers: List annotation layers with advanced filters (1-based pagination)
- get_annotation_layer_info: Get annotation layer details by ID
- list_layer_annotations: List annotations within a layer (requires layer_id, 1-based pagination)
- get_layer_annotation_info: Get annotation details by layer_id and annotation_id
Tag Management:
- list_tags: List tags with advanced filters (1-based pagination)
- get_tag_info: Get detailed tag information by ID
Database Connections:
- list_databases: List database connections with advanced filters (1-based pagination)
- get_database_info: Get detailed database connection info by ID (backend, capabilities)
CSS Templates:
- list_css_templates: List CSS templates with advanced filters (1-based pagination)
- get_css_template_info: Get CSS template details by ID (includes full css content)
- create_css_template: Create a new named CSS template for dashboard styling
- update_css_template: Update an existing CSS template's name or CSS content
Themes:
- list_themes: List themes with advanced filters (1-based pagination)
- get_theme_info: Get theme details by ID or UUID (includes json_data configuration)
User and Role Management:
- list_users: List users with filtering (1-based pagination, admin only)
- get_user_info: Get user details by ID (admin only)
- list_roles: List roles with filtering (1-based pagination, admin only)
- get_role_info: Get role details by ID (admin only)
Dataset Management:
- list_datasets: List datasets with advanced filters (1-based pagination)
- get_dataset_info: Get detailed dataset information by ID (includes columns/metrics)
@@ -155,9 +182,21 @@ SQL Lab Integration:
- execute_sql: Execute SQL queries and get results (requires database_id and SQL access)
- save_sql_query: Save a SQL query to Saved Queries list (requires write access)
- open_sql_lab_with_context: Generate SQL Lab URL with pre-filled sql
- list_saved_queries: List saved SQL queries with filtering and search (1-based pagination)
- get_saved_query_info: Get saved query details by ID or UUID
- list_queries: List SQL query history with filtering and search (1-based pagination)
- get_query_info: Get SQL query history details by ID
Schema Discovery:
- get_schema: Get schema metadata for chart/dataset/dashboard (columns, filters)
- get_schema: Get schema metadata for chart/dataset/dashboard/database/css_template/theme (columns, filters)
Action Logs (requires SUPERSET_LOG_VIEW and FAB_ADD_SECURITY_VIEWS):
- list_action_logs: List user action logs with filtering and pagination (defaults to last 7 days)
- get_action_log_info: Get a single action log entry by integer ID
Task Management (requires GLOBAL_TASK_FRAMEWORK feature flag):
- list_tasks: List background tasks with status filtering and pagination
- get_task_info: Get task details by integer ID or UUID
System Information:
- get_instance_info: Get instance-wide statistics, metadata, and current user identity
@@ -365,6 +404,8 @@ IMPORTANT - Tool-Only Interaction:
General usage tips:
- All listing tools use 1-based pagination (first page is 1)
- Use get_schema to discover filterable columns, sortable columns, and default columns
for chart/dataset/dashboard/database. For action_log and task tools, consult each
tool's docstring — filterable and sortable columns are listed there directly.
- Use 'filters' parameter for advanced queries with filter columns from get_schema
- IDs can be integer or UUID format where supported
- All tools return structured, Pydantic-typed responses
@@ -620,6 +661,16 @@ warnings.filterwarnings(
# NOTE: Always add new prompt/resource imports here when creating new prompts/resources.
# Prompts use @mcp.prompt decorators and resources use @mcp.resource decorators.
# They register automatically on import, similar to tools.
from superset.mcp_service.action_log.tool import ( # noqa: F401, E402
get_action_log_info,
list_action_logs,
)
from superset.mcp_service.annotation_layer.tool import ( # noqa: F401, E402
get_annotation_layer_info,
get_layer_annotation_info,
list_annotation_layers,
list_layer_annotations,
)
from superset.mcp_service.chart import ( # noqa: F401, E402
prompts as chart_prompts,
resources as chart_resources,
@@ -635,10 +686,17 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
update_chart,
update_chart_preview,
)
from superset.mcp_service.css_template.tool import ( # noqa: F401, E402
create_css_template,
get_css_template_info,
list_css_templates,
update_css_template,
)
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
add_chart_to_existing_dashboard,
generate_dashboard,
get_dashboard_info,
get_dashboard_layout,
list_dashboards,
)
from superset.mcp_service.database.tool import ( # noqa: F401, E402
@@ -654,6 +712,18 @@ from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
from superset.mcp_service.explore.tool import ( # noqa: F401, E402
generate_explore_link,
)
from superset.mcp_service.query.tool import ( # noqa: F401, E402
get_query_info,
list_queries,
)
from superset.mcp_service.role.tool import ( # noqa: F401, E402
get_role_info,
list_roles,
)
from superset.mcp_service.saved_query.tool import ( # noqa: F401, E402
get_saved_query_info,
list_saved_queries,
)
from superset.mcp_service.sql_lab.tool import ( # noqa: F401, E402
execute_sql,
open_sql_lab_with_context,
@@ -670,6 +740,22 @@ from superset.mcp_service.system.tool import ( # noqa: F401, E402
get_schema,
health_check,
)
from superset.mcp_service.tag.tool import ( # noqa: F401, E402
get_tag_info,
list_tags,
)
from superset.mcp_service.task.tool import ( # noqa: F401, E402
get_task_info,
list_tasks,
)
from superset.mcp_service.theme.tool import ( # noqa: F401, E402
get_theme_info,
list_themes,
)
from superset.mcp_service.user.tool import ( # noqa: F401, E402
get_user_info,
list_users,
)
def _remove_disabled_tools(disabled_tools: set[str]) -> None:
@@ -691,6 +777,48 @@ def _remove_disabled_tools(disabled_tools: set[str]) -> None:
)
def _remove_tool_quietly(tool_name: str, reason: str) -> None:
"""Remove a single tool from the global MCP instance, ignoring missing-tool errors."""
try:
mcp.local_provider.remove_tool(tool_name)
logger.info("Disabled MCP tool: %s (%s)", tool_name, reason)
except KeyError:
pass
def _apply_config_guards(flask_app: Any) -> set[str]:
"""Remove tools whose backing features are administratively disabled.
Returns the set of tool names that were removed so that callers can exclude
them from generated instructions.
- Action-log tools: mirrors LogRestApi.is_enabled() which checks
FAB_ADD_SECURITY_VIEWS and SUPERSET_LOG_VIEW.
- Task tools: mirrors TaskRestApi conditional registration which checks
the GLOBAL_TASK_FRAMEWORK feature flag via feature_flag_manager so that
all Superset enablement paths (DEFAULT_FEATURE_FLAGS, GET_FEATURE_FLAGS_FUNC,
IS_FEATURE_ENABLED_FUNC, etc.) are respected.
"""
removed: set[str] = set()
if not (
flask_app.config["FAB_ADD_SECURITY_VIEWS"]
and flask_app.config["SUPERSET_LOG_VIEW"]
):
for tool_name in ("list_action_logs", "get_action_log_info"):
_remove_tool_quietly(tool_name, "logging disabled by config flags")
removed.add(tool_name)
from superset.extensions import feature_flag_manager # noqa: PLC0415
if not feature_flag_manager.is_feature_enabled("GLOBAL_TASK_FRAMEWORK"):
for tool_name in ("list_tasks", "get_task_info"):
_remove_tool_quietly(tool_name, "GLOBAL_TASK_FRAMEWORK not enabled")
removed.add(tool_name)
return removed
def init_fastmcp_server(
name: str | None = None,
instructions: str | None = None,
@@ -735,9 +863,13 @@ def init_fastmcp_server(
# 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)
config_guard_removed = _apply_config_guards(flask_app)
if instructions is None:
instructions = get_default_instructions(branding, disabled_tools)
# Merge MCP_DISABLED_TOOLS with config-guard removals so the instructions
# never advertise tools that have been suppressed by either mechanism.
all_disabled = disabled_tools | config_guard_removed
instructions = get_default_instructions(branding, all_disabled)
# Configure the global mcp instance with provided settings.
# Tools are already registered on this instance via @tool decorator imports above.
+65 -16
View File
@@ -22,6 +22,7 @@ This module contains shared logic for chart configuration mapping and explore li
generation that can be used by both generate_chart and generate_explore_link tools.
"""
import hashlib
import logging
from dataclasses import dataclass
from typing import Any, Dict
@@ -488,10 +489,28 @@ def create_metric_object(col: ColumnRef) -> Dict[str, Any] | str:
For saved metrics, returns the metric name as a plain string which
Superset's query engine resolves via its metrics_by_name lookup.
For ad-hoc metrics, returns a SIMPLE expression dict.
For custom SQL metrics, returns a SQL adhoc dict (expressionType="SQL").
For ad-hoc column metrics, returns a SIMPLE expression dict.
"""
if col.sql_expression:
return {
"aggregate": None,
"column": None,
"expressionType": "SQL",
"sqlExpression": col.sql_expression,
"label": col.label,
"optionName": (
"metric_sql_"
+ hashlib.md5(
col.sql_expression.encode("utf-8"), usedforsecurity=False
).hexdigest()[:8]
),
"hasCustomLabel": True,
"datasourceWarning": False,
}
if col.saved_metric:
return col.name
return col.name # type: ignore[return-value]
# Ensure aggregate is valid - default to SUM if not specified or invalid
valid_aggregates = {
@@ -684,7 +703,7 @@ def _add_xy_limits(form_data: Dict[str, Any], config: XYChartConfig) -> None:
form_data["series_limit"] = config.series_limit
def map_xy_config(
def map_xy_config( # noqa: C901
config: XYChartConfig, dataset_id: int | str | None = None
) -> Dict[str, Any]:
"""Map XY chart config to form_data with defensive validation."""
@@ -692,9 +711,12 @@ def map_xy_config(
if not config.y:
raise ValueError("XY chart must have at least one Y-axis metric")
# Resolve x-axis default: use dataset's main_dttm_col when x is omitted
# Resolve x-axis default: use dataset's main_dttm_col when x is omitted.
config = _resolve_default_x_axis(config, dataset_id)
assert config.x is not None # _resolve_default_x_axis guarantees x is set
# ``_resolve_default_x_axis`` guarantees x is set.
if config.x is None or config.x.name is None:
raise ValueError("XY chart requires an x-axis with a resolvable column name")
# Check if x-axis column is truly temporal (based on actual SQL type)
x_is_temporal = is_column_truly_temporal(config.x.name, dataset_id)
@@ -719,7 +741,8 @@ def map_xy_config(
# Convert Y columns to metrics with validation
metrics = []
for col in config.y:
if not col.name.strip(): # Validate column name is not empty
# SQL metrics carry sql_expression instead of name.
if not col.sql_expression and not (col.name and col.name.strip()):
raise ValueError("Y-axis column name cannot be empty")
metrics.append(create_metric_object(col))
@@ -972,7 +995,9 @@ def map_mixed_timeseries_config(
if not config.y_secondary:
raise ValueError("Mixed timeseries must have at least one secondary metric")
# Check if x-axis column is truly temporal
# x rejects sql_expression at validation, so name is set.
if config.x.name is None:
raise ValueError("Mixed timeseries chart requires an x-axis column name")
x_is_temporal = is_column_truly_temporal(config.x.name, dataset_id)
form_data: Dict[str, Any] = {
@@ -1052,7 +1077,9 @@ def _humanize_column(col: ColumnRef) -> str:
"""Return a human-readable label for a column reference."""
if col.label:
return col.label
name = col.name.replace("_", " ").title()
if col.sql_expression:
return col.sql_expression
name = (col.name or "").replace("_", " ").title()
if col.saved_metric:
return name
if col.aggregate:
@@ -1144,21 +1171,32 @@ def _xy_chart_context(config: XYChartConfig) -> str | None:
def _pie_chart_what(config: PieChartConfig) -> str:
"""Build the 'what' portion for a pie chart name."""
dim = config.dimension.name
metric_label = config.metric.label or config.metric.name
metric_label = (
config.metric.label or config.metric.name or config.metric.sql_expression
)
return f"{dim} by {metric_label}"
def _pivot_table_what(config: PivotTableChartConfig) -> str:
"""Build the 'what' portion for a pivot table chart name."""
row_names = ", ".join(r.name for r in config.rows)
# Pivot rows reject sql_expression at validation, so name is set.
row_names = ", ".join(r.name or "" for r in config.rows)
return f"Pivot Table \u2013 {row_names}"
def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str:
"""Build the 'what' portion for a mixed timeseries chart name."""
primary = config.y[0].label or config.y[0].name if config.y else "primary"
primary = (
(config.y[0].label or config.y[0].name or config.y[0].sql_expression)
if config.y
else "primary"
)
secondary = (
config.y_secondary[0].label or config.y_secondary[0].name
(
config.y_secondary[0].label
or config.y_secondary[0].name
or config.y_secondary[0].sql_expression
)
if config.y_secondary
else "secondary"
)
@@ -1172,10 +1210,16 @@ def _handlebars_chart_what(config: HandlebarsChartConfig) -> str:
``generate_chart_name``'s ``\u2013`` context separator.
"""
if config.query_mode == "raw" and config.columns:
cols = ", ".join(col.name for col in config.columns[:3])
# Raw columns reject sql_expression at validation, so col.name is set.
cols = ", ".join(col.name or "" for col in config.columns[:3])
return f"Handlebars ({cols})"
elif config.metrics:
metrics = ", ".join(col.name for col in config.metrics[:3])
# Prefer raw column name for back-compat with existing chart names;
# SQL metrics fall back to label, then the expression itself.
metrics = ", ".join(
col.name or col.label or col.sql_expression or ""
for col in config.metrics[:3]
)
return f"Handlebars ({metrics})"
return "Handlebars Chart"
@@ -1188,10 +1232,12 @@ def _big_number_chart_what(config: BigNumberChartConfig) -> str:
"""
if config.metric.label:
metric_label = config.metric.label
elif config.metric.sql_expression:
metric_label = config.metric.sql_expression
elif config.metric.aggregate:
metric_label = f"{config.metric.aggregate}({config.metric.name})"
else:
metric_label = config.metric.name
metric_label = config.metric.name or ""
if config.show_trendline:
return f"Big Number ({metric_label}, trendline)"
return f"Big Number ({metric_label})"
@@ -1390,7 +1436,10 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics:
if hasattr(config, "x") and config.x:
columns.append(config.x.name)
if hasattr(config, "y") and config.y:
columns.extend([col.name for col in config.y])
# SQL metrics have no name; fall back to label or the expression.
columns.extend(
[col.name or col.label or col.sql_expression for col in config.y]
)
if columns:
ellipsis = "..." if len(columns) > 3 else ""
+201 -35
View File
@@ -25,7 +25,6 @@ import difflib
from datetime import datetime
from typing import Annotated, Any, cast, Dict, List, Literal, Protocol
import humanize
from pydantic import (
AliasChoices,
AliasPath,
@@ -61,8 +60,10 @@ from superset.mcp_service.utils import (
escape_llm_context_delimiters,
sanitize_for_llm_context,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
from superset.mcp_service.utils.sanitization import (
sanitize_filter_value,
sanitize_sql_expression,
sanitize_user_input,
sanitize_user_input_with_changes,
)
@@ -298,13 +299,6 @@ class GetChartInfoRequest(BaseModel):
return self
def _humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
return humanize.naturaltime(datetime.now() - dt)
def extract_filters_from_form_data(
form_data: Dict[str, Any] | None,
) -> ChartFiltersInfo | None:
@@ -409,7 +403,40 @@ CHART_FORM_DATA_EXCLUDED_FIELD_NAMES = frozenset(
)
def sanitize_chart_info_for_llm_context(chart_info: ChartInfo) -> ChartInfo:
def wrap_sql_adhoc_metrics(form_data: Any) -> None:
"""Wrap LLM-controlled SQL adhoc metric strings in-place.
``metric``/``metrics`` are in ``CHART_FORM_DATA_EXCLUDED_FIELD_NAMES`` so
SIMPLE-metric content (bounded scalars) doesn't get wrapped. SQL adhoc
dicts carry up to 2000 chars of LLM-controlled SQL plus a 500-char label
that still need ``<UNTRUSTED-CONTENT>`` delimiters when echoed back.
"""
if not isinstance(form_data, dict):
return
metrics = form_data.get("metrics")
if isinstance(metrics, list):
for index, metric in enumerate(metrics):
if isinstance(metric, dict) and metric.get("expressionType") == "SQL":
for key in ("sqlExpression", "label"):
if isinstance(metric.get(key), str):
metric[key] = sanitize_for_llm_context(
metric[key],
field_path=("form_data", "metrics", str(index), key),
)
metric_singular = form_data.get("metric")
if (
isinstance(metric_singular, dict)
and metric_singular.get("expressionType") == "SQL"
):
for key in ("sqlExpression", "label"):
if isinstance(metric_singular.get(key), str):
metric_singular[key] = sanitize_for_llm_context(
metric_singular[key],
field_path=("form_data", "metric", key),
)
def sanitize_chart_info_for_llm_context(chart_info: ChartInfo) -> ChartInfo: # noqa: C901
"""Wrap chart read-path descriptive fields before LLM exposure."""
payload = chart_info.model_dump(mode="python")
@@ -444,6 +471,7 @@ def sanitize_chart_info_for_llm_context(chart_info: ChartInfo) -> ChartInfo:
| frozenset({"cache_key", "database", "database_name", "schema"})
),
)
wrap_sql_adhoc_metrics(payload["form_data"])
payload["tags"] = [
{
@@ -504,13 +532,9 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
form_data=chart_form_data,
filters=filters_info,
changed_on=getattr(chart, "changed_on", None),
changed_on_humanized=_humanize_timestamp(
getattr(chart, "changed_on", None)
),
changed_on_humanized=humanize_timestamp(getattr(chart, "changed_on", None)),
created_on=getattr(chart, "created_on", None),
created_on_humanized=_humanize_timestamp(
getattr(chart, "created_on", None)
),
created_on_humanized=humanize_timestamp(getattr(chart, "created_on", None)),
uuid=str(getattr(chart, "uuid", ""))
if getattr(chart, "uuid", None)
else None,
@@ -670,8 +694,8 @@ class UnknownFieldCheckMixin(BaseModel):
class ColumnRef(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str = Field(
...,
name: str | None = Field(
None,
min_length=1,
max_length=255,
pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$",
@@ -700,11 +724,62 @@ class ColumnRef(BaseModel):
"(use get_dataset_info to see available metrics). "
"When set, 'aggregate' is ignored.",
)
sql_expression: str | None = Field(
None,
max_length=2000,
description=(
"Custom SQL aggregate expression for an adhoc metric, e.g. "
"'COUNT(CASE WHEN closed_won THEN 1 END)::numeric / "
"NULLIF(COUNT(*),0)'. Metric-only — mutually exclusive with "
"'name', 'aggregate', and 'saved_metric'. Requires 'label'."
),
)
@property
def is_metric(self) -> bool:
"""Whether this ref acts as a metric (has aggregate or is a saved metric)."""
return bool(self.aggregate) or self.saved_metric
"""Whether this ref acts as a metric (aggregate, saved, or SQL)."""
return bool(self.aggregate) or self.saved_metric or bool(self.sql_expression)
# Must run before ``clear_aggregate_for_saved_metric`` (Pydantic v2 runs
# ``mode="after"`` validators in source order) so the aggregate/saved
# conflict surfaces before the cleanup nulls ``aggregate`` out.
@model_validator(mode="after")
def validate_metric_shape(self) -> "ColumnRef":
"""Require exactly one of ``name`` or ``sql_expression``; the SQL form
is mutually exclusive with ``aggregate`` / ``saved_metric`` and
requires a ``label``.
"""
if self.sql_expression:
if self.name is not None:
raise ValueError(
"ColumnRef cannot set both 'name' and 'sql_expression'. "
"Use 'sql_expression' alone for a custom SQL metric, or "
"'name' (plus optional 'aggregate' / 'saved_metric') for "
"a column-based metric."
)
if self.aggregate is not None:
raise ValueError(
"ColumnRef cannot combine 'sql_expression' with "
"'aggregate' — the SQL expression already includes the "
"aggregation."
)
if self.saved_metric:
raise ValueError(
"ColumnRef cannot combine 'sql_expression' with "
"'saved_metric=True' — use the saved metric's name "
"directly instead."
)
if not self.label:
raise ValueError(
"ColumnRef with 'sql_expression' requires a 'label' "
"(used as the metric's display name)."
)
elif self.name is None:
raise ValueError(
"ColumnRef requires either 'name' (column / dimension / "
"saved metric) or 'sql_expression' (custom SQL metric)."
)
return self
@model_validator(mode="after")
def clear_aggregate_for_saved_metric(self) -> "ColumnRef":
@@ -715,13 +790,13 @@ class ColumnRef(BaseModel):
@field_validator("name")
@classmethod
def sanitize_name(cls, v: str) -> str:
def sanitize_name(cls, v: str | None) -> str | None:
"""Sanitize column name to prevent XSS and SQL injection."""
# sanitize_user_input raises ValueError when allow_empty=False (default)
# so the return value is guaranteed to be a non-None str
if v is None:
return None
return sanitize_user_input(
v, "Column name", max_length=255, check_sql_keywords=True
) # type: ignore[return-value]
)
@field_validator("label")
@classmethod
@@ -729,6 +804,14 @@ class ColumnRef(BaseModel):
"""Sanitize display label to prevent XSS attacks."""
return sanitize_user_input(v, "Label", max_length=500, allow_empty=True)
@field_validator("sql_expression")
@classmethod
def sanitize_sql(cls, v: str | None) -> str | None:
"""Sanitize a custom SQL aggregate expression (XSS, DDL/DML, etc.)."""
return sanitize_sql_expression(
v, "SQL expression", max_length=2000, allow_empty=True
)
class AxisConfig(BaseModel):
title: str | None = Field(None, max_length=200)
@@ -918,6 +1001,12 @@ class PieChartConfig(UnknownFieldCheckMixin):
30, description="Donut inner radius % (1-100)", ge=1, le=100
)
@model_validator(mode="after")
def reject_sql_expression_on_dimensions(self) -> "PieChartConfig":
"""sql_expression is metric-only; reject it on the dimension."""
_reject_sql_expression_on_dimension(self.dimension, "dimension")
return self
class PivotTableChartConfig(UnknownFieldCheckMixin):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -972,6 +1061,16 @@ class PivotTableChartConfig(UnknownFieldCheckMixin):
description="Currency symbol applied to numeric metric values",
)
@model_validator(mode="after")
def reject_sql_expression_on_dimensions(self) -> "PivotTableChartConfig":
"""sql_expression is metric-only; reject it on rows and columns."""
for i, col in enumerate(self.rows):
_reject_sql_expression_on_dimension(col, f"rows[{i}]")
if self.columns:
for i, col in enumerate(self.columns):
_reject_sql_expression_on_dimension(col, f"columns[{i}]")
return self
class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -1052,6 +1151,19 @@ class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
def wrap_single_group_by(cls, v: Any) -> Any:
return _normalize_group_by_input(v)
@model_validator(mode="after")
def reject_sql_expression_on_dimensions(self) -> "MixedTimeseriesChartConfig":
"""sql_expression is metric-only; reject it on x and group_by lists."""
_reject_sql_expression_on_dimension(self.x, "x")
for field_name, group in (
("group_by", self.group_by),
("group_by_secondary", self.group_by_secondary),
):
if group:
for i, col in enumerate(group):
_reject_sql_expression_on_dimension(col, f"{field_name}[{i}]")
return self
class HandlebarsChartConfig(UnknownFieldCheckMixin):
model_config = ConfigDict(extra="ignore")
@@ -1120,6 +1232,17 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
max_length=10000,
)
@model_validator(mode="after")
def reject_sql_expression_on_dimensions(self) -> "HandlebarsChartConfig":
"""sql_expression is metric-only; reject it on raw columns and groupby."""
if self.columns:
for i, col in enumerate(self.columns):
_reject_sql_expression_on_dimension(col, f"columns[{i}]")
if self.groupby:
for i, col in enumerate(self.groupby):
_reject_sql_expression_on_dimension(col, f"groupby[{i}]")
return self
@model_validator(mode="after")
def validate_query_fields(self) -> "HandlebarsChartConfig":
"""Validate that the right fields are provided for the query mode."""
@@ -1145,7 +1268,9 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
"Handlebars chart in 'aggregate' query mode requires 'metrics' "
"field. Specify at least one metric with an aggregate function."
)
missing_agg = [m.name for m in self.metrics if not m.is_metric]
# SQL metrics are filtered out by ``is_metric``, so every entry in
# ``missing_agg`` is a name-bearing column ref.
missing_agg = [m.name or "" for m in self.metrics if not m.is_metric]
if missing_agg:
raise ValueError(
f"Handlebars chart in 'aggregate' query mode requires an "
@@ -1272,13 +1397,14 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
@model_validator(mode="after")
def validate_metric_aggregate(self) -> Self:
"""Ensure metric is a valid metric reference (aggregate or saved)."""
"""Ensure metric resolves to a metric expression (aggregate, saved,
or sql_expression)."""
if not self.metric.is_metric:
raise ValueError(
"Big Number metric must be either a saved dataset metric "
"or include an aggregate function (e.g., SUM, COUNT, AVG). "
"Set 'saved_metric': true to use a saved metric, or add "
"'aggregate' to the metric specification."
"Big Number metric must include an aggregate function, "
"reference a saved metric, or carry a sql_expression. "
"Set 'aggregate' (e.g., SUM, COUNT, AVG), 'saved_metric': true, "
"or 'sql_expression' (with a 'label')."
)
return self
@@ -1330,6 +1456,20 @@ class TableChartConfig(UnknownFieldCheckMixin):
max_length=100,
)
@model_validator(mode="after")
def reject_sql_expression_in_raw_mode(self) -> "TableChartConfig":
"""In raw mode every column is a plain selection, so a SQL metric
there would yield ``None`` in ``form_data['all_columns']``."""
if self.query_mode == "raw":
for i, col in enumerate(self.columns):
if col.sql_expression:
raise ValueError(
f"sql_expression is not allowed on columns[{i}] when "
f"query_mode='raw'. Switch to query_mode='aggregate' "
f"(or omit query_mode) to use a SQL metric."
)
return self
@model_validator(mode="after")
def validate_unique_column_labels(self) -> "TableChartConfig":
"""Ensure all column labels are unique."""
@@ -1338,7 +1478,10 @@ class TableChartConfig(UnknownFieldCheckMixin):
for i, col in enumerate(self.columns):
# Generate the label that will be used (same logic as create_metric_object)
if col.saved_metric:
if col.sql_expression:
# SQL metrics carry a required label; use it verbatim.
label = col.label
elif col.saved_metric:
label = col.label or col.name
elif col.aggregate:
label = col.label or f"{col.aggregate}({col.name})"
@@ -1360,13 +1503,25 @@ class TableChartConfig(UnknownFieldCheckMixin):
return self
def _reject_sql_expression_on_dimension(col: ColumnRef | None, position: str) -> None:
"""Raise if a dimension-position ColumnRef carries ``sql_expression``;
SQL adhoc metrics belong on metric positions only."""
if col is not None and col.sql_expression:
raise ValueError(
f"sql_expression is only supported on metrics, not on '{position}' "
f"(which is a dimension). Use 'name' for dimension columns."
)
def _metric_display_label(col: ColumnRef) -> str:
"""Return the display label for a metric column reference."""
if col.sql_expression:
return col.label or ""
if col.saved_metric:
return col.label or col.name
return col.label or col.name or ""
if col.aggregate:
return col.label or f"{col.aggregate}({col.name})"
return col.label or col.name
return col.label or col.name or ""
class XYChartConfig(UnknownFieldCheckMixin):
@@ -1454,15 +1609,25 @@ class XYChartConfig(UnknownFieldCheckMixin):
def wrap_single_group_by(cls, v: Any) -> Any:
return _normalize_group_by_input(v)
@model_validator(mode="after")
def reject_sql_expression_on_dimensions(self) -> "XYChartConfig":
"""sql_expression is metric-only; reject it on x and group_by."""
_reject_sql_expression_on_dimension(self.x, "x")
if self.group_by:
for i, col in enumerate(self.group_by):
_reject_sql_expression_on_dimension(col, f"group_by[{i}]")
return self
@model_validator(mode="after")
def validate_unique_column_labels(self) -> "XYChartConfig":
"""Ensure all column labels are unique across x, y, and group_by."""
labels_seen: dict[str, str] = {}
duplicates: list[str] = []
# Add x-axis label if present (x may be None, resolved later)
# Add x-axis label if present (x may be None, resolved later).
# The dimension validator rejects sql_expression on x, so name is set.
if self.x is not None:
x_label = self.x.label or self.x.name
x_label = self.x.label or self.x.name or ""
labels_seen[x_label] = "x"
# Check Y-axis labels
@@ -1483,7 +1648,8 @@ class XYChartConfig(UnknownFieldCheckMixin):
# to prevent Superset "duplicate label" errors, so
# we allow them through validation.
continue
group_label = col.label or col.name
# group_by rejects sql_expression, so name is set.
group_label = col.label or col.name or ""
if group_label in labels_seen:
duplicates.append(
f"group_by[{i}]: '{group_label}' "
@@ -52,6 +52,7 @@ from superset.mcp_service.chart.schemas import (
GenerateChartRequest,
GenerateChartResponse,
PerformanceMetadata,
wrap_sql_adhoc_metrics,
)
from superset.mcp_service.utils import sanitize_for_llm_context
from superset.mcp_service.utils.oauth2_utils import (
@@ -73,11 +74,13 @@ def _sanitize_generate_chart_form_data_for_llm_context(
form_data: dict[str, Any],
) -> dict[str, Any]:
"""Wrap generated-chart form_data before returning it to LLM clients."""
return sanitize_for_llm_context(
wrapped = sanitize_for_llm_context(
form_data,
field_path=("form_data",),
excluded_field_names=GENERATE_CHART_FORM_DATA_EXCLUDED_FIELD_NAMES,
)
wrap_sql_adhoc_metrics(wrapped)
return wrapped
__all__ = ["CompileResult", "_compile_chart", "validate_and_compile", "generate_chart"]
@@ -142,6 +145,26 @@ async def generate_chart( # noqa: C901
}
```
Example usage with a custom SQL metric (ratios, conditional aggregations,
unit conversions). Pass 'sql_expression' instead of 'name'+'aggregate'.
A 'label' is required and serves as the metric's display name:
```json
{
"dataset_id": 123,
"config": {
"chart_type": "xy",
"x": {"name": "order_date"},
"y": [{
"sql_expression":
"COUNT(CASE WHEN closed_won THEN 1 END)::numeric / "
"NULLIF(COUNT(*), 0)",
"label": "Win Rate"
}],
"kind": "line"
}
}
```
VALIDATION:
- 5-layer pipeline: Schema, business logic, dataset, Superset compatibility, runtime
- XSS/SQL injection prevention
@@ -46,6 +46,7 @@ from superset.mcp_service.chart.schemas import (
GenerateChartResponse,
PerformanceMetadata,
UpdateChartRequest,
wrap_sql_adhoc_metrics,
)
from superset.mcp_service.utils import escape_llm_context_delimiters
from superset.mcp_service.utils.oauth2_utils import (
@@ -84,6 +85,15 @@ def _missing_config_or_name_error() -> GenerateChartResponse:
)
def _wrapped_form_data_for_response(
new_form_data: dict[str, Any] | None,
) -> dict[str, Any]:
"""Wrap SQL-metric strings in form_data before LLM-facing return."""
payload = dict(new_form_data) if new_form_data is not None else {}
wrap_sql_adhoc_metrics(payload)
return payload
def _build_update_payload(
request: UpdateChartRequest,
chart: Any,
@@ -322,6 +332,26 @@ async def update_chart( # noqa: C901
}
```
Example usage with a custom SQL metric (ratios, conditional aggregations,
unit conversions). Pass 'sql_expression' instead of 'name'+'aggregate'.
A 'label' is required:
```json
{
"identifier": 123,
"config": {
"chart_type": "xy",
"x": {"name": "date"},
"y": [{
"sql_expression":
"COUNT(CASE WHEN closed_won THEN 1 END)::numeric / "
"NULLIF(COUNT(*), 0)",
"label": "Win Rate"
}],
"kind": "line"
}
}
```
Use when:
- Modifying existing saved chart
- Updating title, filters, or visualization settings
@@ -532,8 +562,7 @@ async def update_chart( # noqa: C901
},
"error": None,
"warnings": warnings,
# Include form_data so callers can verify what was saved.
"form_data": new_form_data if new_form_data is not None else {},
"form_data": _wrapped_form_data_for_response(new_form_data),
"previews": previews,
"capabilities": capabilities.model_dump() if capabilities else None,
"semantics": semantics.model_dump() if semantics else None,
@@ -115,7 +115,7 @@ class DatasetValidator:
return True, None
@staticmethod
def _validate_columns_exist(
def _validate_columns_exist( # noqa: C901
column_refs: List[ColumnRef], dataset_context: DatasetContext
) -> ChartGenerationError | None:
"""Validate that non-saved-metric column refs exist in the dataset.
@@ -139,6 +139,12 @@ class DatasetValidator:
for col_ref in column_refs:
if col_ref.saved_metric:
continue
if col_ref.sql_expression:
# SQL metrics don't reference a dataset column.
continue
if col_ref.name is None:
# Should be unreachable per validate_metric_shape; defensive.
continue
name_lower = col_ref.name.lower()
if name_lower in column_names_lower:
continue
@@ -158,6 +164,9 @@ class DatasetValidator:
suggestions_map = {}
for col_ref in invalid_columns:
# Loop above filters out refs without a name; defensive guard.
if col_ref.name is None:
continue
suggestions = DatasetValidator._get_column_suggestions(
col_ref.name, dataset_context
)
@@ -371,14 +380,16 @@ class DatasetValidator:
) -> None:
"""Normalize column names in an XY chart config dict in place."""
# Normalize x-axis column
if "x" in config_dict and config_dict["x"]:
if "x" in config_dict and config_dict["x"] and config_dict["x"].get("name"):
config_dict["x"]["name"] = DatasetValidator._get_canonical_column_name(
config_dict["x"]["name"], dataset_context
)
# Normalize y-axis columns
# Normalize y-axis columns (skip SQL-expression metrics; no name).
if "y" in config_dict and config_dict["y"]:
for y_col in config_dict["y"]:
if not y_col.get("name"):
continue
y_col["name"] = DatasetValidator._get_canonical_column_name(
y_col["name"], dataset_context
)
@@ -386,6 +397,8 @@ class DatasetValidator:
# Normalize group_by columns
if "group_by" in config_dict and config_dict["group_by"]:
for gb_col in config_dict["group_by"]:
if not gb_col.get("name"):
continue
gb_col["name"] = DatasetValidator._get_canonical_column_name(
gb_col["name"], dataset_context
)
@@ -397,6 +410,9 @@ class DatasetValidator:
"""Normalize column names in a table chart config dict in place."""
if "columns" in config_dict and config_dict["columns"]:
for col in config_dict["columns"]:
# Skip SQL-expression metrics: no underlying column name.
if not col.get("name"):
continue
col["name"] = DatasetValidator._get_canonical_column_name(
col["name"], dataset_context
)
@@ -514,20 +530,20 @@ class DatasetValidator:
ChartErrorBuilder,
)
# Format error message
if len(invalid_columns) == 1:
col = invalid_columns[0]
suggestions = suggestions_map.get(col.name, [])
col_name = col.name or "<unknown column>"
suggestions = suggestions_map.get(col_name, [])
if suggestions:
return ChartErrorBuilder.column_not_found_error(
col.name, [s.name for s in suggestions]
col_name, [s.name for s in suggestions]
)
else:
return ChartErrorBuilder.column_not_found_error(col.name)
return ChartErrorBuilder.column_not_found_error(col_name)
else:
# Multiple invalid columns
invalid_names = [col.name for col in invalid_columns]
invalid_names: list[str] = [col.name for col in invalid_columns if col.name]
return ChartErrorBuilder.build_error(
error_type="multiple_invalid_columns",
template_key="column_not_found",
@@ -556,10 +572,13 @@ class DatasetValidator:
_column_exists (which checks both lists) but fail at query time.
"""
metric_names = {m["name"].lower() for m in dataset_context.available_metrics}
invalid = [
# ``saved_metric=True`` requires ``name`` per ColumnRef.validate_metric_shape.
invalid: list[str] = [
col_ref.name
for col_ref in column_refs
if col_ref.saved_metric and col_ref.name.lower() not in metric_names
if col_ref.saved_metric
and col_ref.name is not None
and col_ref.name.lower() not in metric_names
]
if not invalid:
return None
@@ -597,8 +616,14 @@ class DatasetValidator:
for col_ref in column_refs:
if col_ref.saved_metric:
continue # Saved metrics have built-in aggregation
if col_ref.sql_expression:
# Custom SQL metrics bring their own aggregation expression.
continue
if not col_ref.aggregate:
continue
if col_ref.name is None:
# Should be unreachable per validate_metric_shape; defensive.
continue
# Find column info
col_info = None
@@ -134,13 +134,13 @@ class RuntimeValidator:
chart_type = config.kind if hasattr(config, "kind") else "default"
# Check X-axis cardinality
if config.x is None:
if config.x is None or config.x.name is None:
return warnings, suggestions
is_ok, cardinality_info = CardinalityValidator.check_cardinality(
dataset_id=dataset_id,
x_column=config.x.name,
chart_type=chart_type,
group_by_column=config.group_by[0].name if config.group_by else None,
group_by_column=(config.group_by[0].name if config.group_by else None),
)
if not is_ok and cardinality_info:
@@ -20,6 +20,7 @@ Chart type suggestions based on data characteristics and user intent.
"""
import logging
import re
from typing import Any, Dict, List, Tuple
from superset.mcp_service.chart.schemas import (
@@ -68,7 +69,7 @@ class ChartTypeSuggester:
issues = []
suggestions = []
if config.x is None:
if config.x is None or config.x.name is None:
return True, None
x_analysis = ChartTypeSuggester._analyze_x_axis(config.x.name)
@@ -138,10 +139,15 @@ class ChartTypeSuggester:
@staticmethod
def _analyze_y_axis(y_columns: List[ColumnRef]) -> Dict[str, Any]:
"""Analyze Y-axis characteristics."""
def _is_count(col: ColumnRef) -> bool:
if col.aggregate in ("COUNT", "COUNT_DISTINCT"):
return True
expr = col.sql_expression or ""
return bool(re.search(r"\bCOUNT\b", expr, re.IGNORECASE))
return {
"has_count": any(
col.aggregate in ["COUNT", "COUNT_DISTINCT"] for col in y_columns
),
"has_count": any(_is_count(col) for col in y_columns),
"num_metrics": len(y_columns),
}
@@ -283,6 +289,8 @@ class ChartTypeSuggester:
# Check for potential negative values
for col in config.y:
if not col.name:
continue
if any(term in col.name.lower() for term in ["loss", "debt", "negative"]):
issues.append(
f"Area chart with potentially negative values in '{col.name}' "
@@ -356,8 +364,8 @@ class ChartTypeSuggester:
suggestions = []
# Count different column types
raw_columns = sum(1 for col in config.columns if not col.aggregate)
metric_columns = sum(1 for col in config.columns if col.aggregate)
raw_columns = sum(1 for col in config.columns if not col.is_metric)
metric_columns = sum(1 for col in config.columns if col.is_metric)
total_columns = len(config.columns)
# Check if data might be better visualized
@@ -373,7 +381,8 @@ class ChartTypeSuggester:
id_columns = sum(
1
for col in config.columns
if any(i in col.name.lower() for i in ["id", "uuid", "guid", "key"])
if col.name
and any(i in col.name.lower() for i in ["id", "uuid", "guid", "key"])
)
if id_columns > total_columns / 2:
suggestions.append(
@@ -392,22 +392,47 @@ class SchemaValidator:
],
error_code="INVALID_BIG_NUMBER_METRIC_TYPE",
)
if not metric.get("aggregate") and not metric.get("saved_metric"):
if (
not metric.get("aggregate")
and not metric.get("saved_metric")
and not metric.get("sql_expression")
):
return False, ChartGenerationError(
error_type="missing_metric_aggregate",
message="Big Number metric must include an aggregate function "
"or reference a saved metric",
details="The metric must have an 'aggregate' field "
"or 'saved_metric': true",
message="Big Number metric must include an aggregate function, "
"a saved metric reference, or a SQL expression",
details="The metric must have an 'aggregate' field, "
"'saved_metric': true, or 'sql_expression'",
suggestions=[
"Add 'aggregate' to your metric: "
"{'name': 'col', 'aggregate': 'SUM'}",
"Or use a saved metric: "
"{'name': 'total_sales', 'saved_metric': true}",
"Or a custom SQL metric: "
"{'sql_expression': 'SUM(a)/SUM(b)', 'label': 'Ratio'}",
"Valid aggregates: SUM, COUNT, AVG, MIN, MAX",
],
error_code="MISSING_BIG_NUMBER_AGGREGATE",
)
# ``label`` may be any JSON type here (pre-Pydantic), so test the
# string-ness explicitly before calling ``.strip()``.
label = metric.get("label")
if metric.get("sql_expression") and not (
isinstance(label, str) and label.strip()
):
return False, ChartGenerationError(
error_type="missing_sql_metric_label",
message="Big Number metric with sql_expression requires a label",
details=(
"Custom SQL metrics have no column name to derive a label "
"from, so 'label' is required for display."
),
suggestions=[
"Add a 'label': "
"{'sql_expression': 'SUM(a)/SUM(b)', 'label': 'Ratio'}",
],
error_code="MISSING_SQL_METRIC_LABEL",
)
show_trendline = config.get("show_trendline", False)
temporal_column = config.get("temporal_column")
@@ -635,3 +635,82 @@ CHART_ALL_COLUMNS: list[str] = []
DATASET_ALL_COLUMNS: list[str] = []
DASHBOARD_ALL_COLUMNS: list[str] = []
DATABASE_ALL_COLUMNS: list[str] = []
# CSS Template configuration
CSS_TEMPLATE_DEFAULT_COLUMNS = [
"id",
"uuid",
"template_name",
]
CSS_TEMPLATE_SORTABLE_COLUMNS = [
"id",
"template_name",
"changed_on",
"created_on",
]
CSS_TEMPLATE_SEARCH_COLUMNS = ["template_name"]
CSS_TEMPLATE_FILTER_COLUMNS: dict[str, list[str]] = {
"template_name": ["eq", "sw", "ilike"],
"created_by_fk": ["eq"],
}
CSS_TEMPLATE_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
"created_by_name": ColumnMetadata(
name="created_by_name",
description="Username of the creator",
type="str",
is_default=False,
),
"changed_by_name": ColumnMetadata(
name="changed_by_name",
description="Username of the last modifier",
type="str",
is_default=False,
),
}
def get_css_template_columns() -> list[ColumnMetadata]:
"""Get column metadata for CssTemplate model dynamically."""
from superset.models.core import CssTemplate
return get_columns_from_model(
CssTemplate,
CSS_TEMPLATE_DEFAULT_COLUMNS,
CSS_TEMPLATE_EXTRA_COLUMNS,
exclude_columns=set(USER_DIRECTORY_FIELDS)
- {"created_by_name", "changed_by_name"},
)
# Theme configuration
THEME_DEFAULT_COLUMNS = [
"id",
"theme_name",
"uuid",
]
THEME_SORTABLE_COLUMNS = [
"id",
"theme_name",
"changed_on",
"created_on",
]
THEME_SEARCH_COLUMNS = ["theme_name"]
THEME_FILTER_COLUMNS: dict[str, list[str]] = {
"theme_name": ["eq", "sw", "ilike"],
"is_system": ["eq"],
"is_system_default": ["eq"],
"is_system_dark": ["eq"],
"created_by_fk": ["eq"],
}
def get_theme_columns() -> list[ColumnMetadata]:
"""Get column metadata for Theme model dynamically."""
from superset.models.core import Theme
return get_columns_from_model(
Theme,
THEME_DEFAULT_COLUMNS,
exclude_columns=set(USER_DIRECTORY_FIELDS),
)
+3 -1
View File
@@ -19,7 +19,9 @@
from typing import Literal
# Supported model types for schema discovery and MCP tools
ModelType = Literal["chart", "dataset", "dashboard", "database"]
ModelType = Literal[
"chart", "dataset", "dashboard", "database", "css_template", "theme"
]
# Pagination defaults
DEFAULT_PAGE_SIZE = 10 # Default number of items per page
@@ -0,0 +1,16 @@
# 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.
@@ -0,0 +1,384 @@
# 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.
"""
Pydantic schemas for CSS template-related responses
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.common.cache_schemas import MetadataCacheControl
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils.sanitization import sanitize_for_llm_context
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
class CssTemplateFilter(ColumnOperator):
"""
Filter object for CSS template listing.
col: The column to filter on. Must be one of the allowed filter fields.
opr: The operator to use. Must be one of the supported operators.
value: The value to filter by (type depends on col and opr).
"""
col: Literal["template_name", "created_by_fk"] = Field(
...,
description="Column to filter on. Use get_schema(model_type='css_template') "
"for available filter columns. To filter by creator, first call find_users "
"to resolve a name to a user ID, then filter by created_by_fk with "
"that integer ID.",
)
opr: ColumnOperatorEnum = Field(
...,
description="Operator to use.",
)
value: str | int | float | bool | List[str | int | float | bool] = Field(
..., description="Value to filter by (type depends on col and opr)"
)
class CssTemplateInfo(BaseModel):
id: int | None = Field(None, description="CSS template ID")
uuid: str | None = Field(None, description="CSS template UUID")
template_name: str | None = Field(None, description="CSS template name")
css: str | None = Field(
None,
description="CSS content (can be large; request via select_columns=['css'])",
)
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
)
created_on: str | datetime | None = Field(None, description="Creation timestamp")
created_by_name: str | None = Field(None, description="Username of the creator")
changed_by_name: str | None = Field(
None, description="Username of the last modifier"
)
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
@model_serializer(mode="wrap")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
"""Filter fields based on serialization context.
If context contains 'select_columns', only include those fields.
Otherwise, include all fields (default behavior).
"""
data = serializer(self)
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
requested_fields = set(select_columns)
return {k: v for k, v in data.items() if k in requested_fields}
return data
class CssTemplateList(BaseModel):
css_templates: List[CssTemplateInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: List[str] = Field(
default_factory=list,
description="Requested columns for the response",
)
columns_loaded: List[str] = Field(
default_factory=list,
description="Columns that were actually loaded for each CSS template",
)
columns_available: List[str] = Field(
default_factory=list,
description="All columns available for selection via select_columns parameter",
)
sortable_columns: List[str] = Field(
default_factory=list,
description="Columns that can be used with order_column parameter",
)
filters_applied: List[CssTemplateFilter] = Field(
default_factory=list,
description="List of advanced filter dicts applied to the query.",
)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListCssTemplatesRequest(MetadataCacheControl):
"""Request schema for list_css_templates."""
filters: Annotated[
List[CssTemplateFilter],
Field(
default_factory=list,
description="List of filter objects (column, operator, value). Cannot be "
"used together with 'search'.",
),
]
select_columns: Annotated[
List[str],
Field(
default_factory=list,
description="List of columns to select. Defaults to common columns if not "
"specified. Use select_columns=['css'] to include the CSS content.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search string to match against CSS template fields. "
"Cannot be used together with 'filters'.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(
default="desc", description="Direction to order results ('asc' or 'desc')"
),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number for pagination (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> List[CssTemplateFilter]:
"""Accept both JSON string and list of objects."""
return parse_json_or_model_list(v, CssTemplateFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> List[str]:
"""Accept JSON array, list, or comma-separated string."""
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListCssTemplatesRequest":
"""Prevent using both search and filters simultaneously."""
if self.search and self.filters:
raise ValueError(
"Cannot use both 'search' and 'filters' parameters simultaneously. "
"Use either 'search' for text-based searching across multiple fields, "
"or 'filters' for precise column-based filtering, but not both."
)
return self
class CssTemplateError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
@field_validator("error")
@classmethod
def sanitize_error_for_llm_context(cls, value: str) -> str:
"""Wrap error text before it is exposed to LLM context."""
return sanitize_for_llm_context(value, field_path=("error",))
@classmethod
def create(cls, error: str, error_type: str) -> "CssTemplateError":
"""Create a standardized CssTemplateError with timestamp."""
from datetime import datetime, timezone
return cls(
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
)
class GetCssTemplateInfoRequest(MetadataCacheControl):
"""Request schema for get_css_template_info with support for ID or UUID."""
identifier: Annotated[
int | str,
Field(description="CSS template identifier - can be numeric ID or UUID string"),
]
def _sanitize_css_template_info_for_llm_context(
css_template_info: CssTemplateInfo,
) -> CssTemplateInfo:
"""Wrap CSS template user-controlled fields before LLM exposure."""
payload = css_template_info.model_dump(mode="python")
for field_name in ("template_name", "css", "created_by_name", "changed_by_name"):
payload[field_name] = sanitize_for_llm_context(
payload.get(field_name),
field_path=(field_name,),
)
return CssTemplateInfo.model_validate(payload)
def serialize_css_template_object(obj: Any) -> CssTemplateInfo | None:
if not obj:
return None
return _sanitize_css_template_info_for_llm_context(
CssTemplateInfo(
id=getattr(obj, "id", None),
uuid=str(getattr(obj, "uuid", "")) if getattr(obj, "uuid", None) else None,
template_name=getattr(obj, "template_name", None),
css=getattr(obj, "css", None),
changed_on=getattr(obj, "changed_on", None),
created_on=getattr(obj, "created_on", None),
created_by_name=getattr(obj, "created_by_name", None) or None,
changed_by_name=getattr(obj, "changed_by_name", None) or None,
)
)
class CreateCssTemplateRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
template_name: str = Field(
...,
min_length=1,
max_length=250,
description="Name for the CSS template.",
)
css: str = Field(
...,
description="CSS content for the template.",
)
@field_validator("template_name")
@classmethod
def template_name_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("template_name must not be empty")
return v.strip()
class CreateCssTemplateResponse(BaseModel):
"""Response schema for create_css_template."""
id: int | None = Field(
None,
description="ID of the created CSS template. None if creation failed.",
)
template_name: str | None = Field(
None,
description="Name of the created CSS template.",
)
css: str | None = Field(
None,
description="CSS content of the created template.",
)
error: str | None = Field(
None,
description="Error message if creation failed, otherwise null.",
)
@field_validator("error")
@classmethod
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
"""Sanitize error text before it is exposed to LLM context."""
if value is None:
return value
return sanitize_for_llm_context(value, field_path=("error",))
class UpdateCssTemplateRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: int = Field(..., description="ID of the CSS template to update.")
template_name: str | None = Field(
None,
max_length=250,
description="New name for the CSS template.",
)
css: str | None = Field(
None,
description="New CSS content for the template.",
)
@field_validator("template_name")
@classmethod
def template_name_must_not_be_empty(cls, v: str | None) -> str | None:
if v is not None:
if not v.strip():
raise ValueError("template_name must not be empty")
return v.strip()
return v
class UpdateCssTemplateResponse(BaseModel):
"""Response schema for update_css_template."""
id: int | None = Field(
None,
description="ID of the updated CSS template. None if update failed.",
)
template_name: str | None = Field(
None,
description="Name of the updated CSS template.",
)
css: str | None = Field(
None,
description="CSS content of the updated template.",
)
error: str | None = Field(
None,
description="Error message if update failed, otherwise null.",
)
@field_validator("error")
@classmethod
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
"""Sanitize error text before it is exposed to LLM context."""
if value is None:
return value
return sanitize_for_llm_context(value, field_path=("error",))
@@ -0,0 +1,28 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .create_css_template import create_css_template
from .get_css_template_info import get_css_template_info
from .list_css_templates import list_css_templates
from .update_css_template import update_css_template
__all__ = [
"list_css_templates",
"get_css_template_info",
"create_css_template",
"update_css_template",
]
@@ -0,0 +1,95 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.css_template.schemas import (
CreateCssTemplateRequest,
CreateCssTemplateResponse,
)
@tool(
tags=["mutate"],
class_permission_name="CssTemplate",
method_permission_name="write",
annotations=ToolAnnotations(
title="Create CSS template",
readOnlyHint=False,
destructiveHint=False,
),
)
async def create_css_template(
request: CreateCssTemplateRequest, ctx: Context
) -> CreateCssTemplateResponse:
"""Create a new CSS template that can be applied to dashboards.
Use this tool when a user wants to save a CSS stylesheet as a named
template for reuse across multiple dashboards.
The returned ``id`` can be used when configuring dashboard appearance.
"""
await ctx.info("Creating CSS template: template_name=%r" % (request.template_name,))
try:
from superset.commands.css.create import CreateCssTemplateCommand
from superset.commands.css.exceptions import (
CssTemplateCreateFailedError,
CssTemplateInvalidError,
)
with event_logger.log_context(action="mcp.create_css_template.create"):
template = CreateCssTemplateCommand(
{
"template_name": request.template_name,
"css": request.css,
}
).run()
await ctx.info(
"CSS template created: id=%s, template_name=%r"
% (template.id, template.template_name)
)
return CreateCssTemplateResponse(
id=template.id,
template_name=template.template_name,
css=template.css,
)
except CssTemplateInvalidError as exc:
await ctx.warning("CSS template validation failed: %s" % (str(exc),))
return CreateCssTemplateResponse(
template_name=request.template_name,
css=request.css,
error=str(exc),
)
except CssTemplateCreateFailedError as exc:
await ctx.error("CSS template creation failed: %s" % (str(exc),))
return CreateCssTemplateResponse(
template_name=request.template_name,
css=request.css,
error=f"Failed to create CSS template: {exc}",
)
except Exception as exc:
await ctx.error(
"Unexpected error creating CSS template: %s: %s"
% (type(exc).__name__, str(exc))
)
raise
@@ -0,0 +1,108 @@
# 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.
"""
Get CSS template info FastMCP tool
"""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.css_template.schemas import (
CssTemplateError,
CssTemplateInfo,
GetCssTemplateInfoRequest,
serialize_css_template_object,
)
from superset.mcp_service.mcp_core import ModelGetInfoCore
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="CssTemplate",
annotations=ToolAnnotations(
title="Get CSS template info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_css_template_info(
request: GetCssTemplateInfoRequest, ctx: Context
) -> CssTemplateInfo | CssTemplateError:
"""Get CSS template details by ID or UUID.
Returns the full CSS template including the css content.
IMPORTANT FOR LLM CLIENTS:
- Use numeric ID (e.g., 123) or UUID string (e.g., "a1b2c3d4-...")
- To find a CSS template ID, use the list_css_templates tool first
Example usage:
```json
{
"identifier": 1
}
```
"""
await ctx.info(
"Retrieving CSS template information: identifier=%s" % (request.identifier,)
)
try:
from superset.daos.css import CssTemplateDAO
with event_logger.log_context(action="mcp.get_css_template_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=CssTemplateDAO,
output_schema=CssTemplateInfo,
error_schema=CssTemplateError,
serializer=serialize_css_template_object,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.identifier)
if isinstance(result, CssTemplateInfo):
await ctx.info(
"CSS template information retrieved successfully: "
"id=%s, template_name=%s" % (result.id, result.template_name)
)
else:
await ctx.warning(
"CSS template retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"CSS template information retrieval failed: identifier=%s, error=%s, "
"error_type=%s" % (request.identifier, str(e), type(e).__name__)
)
return CssTemplateError(
error=f"Failed to get CSS template info: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)
@@ -0,0 +1,150 @@
# 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.
"""
List CSS templates FastMCP tool
"""
import logging
from typing import TYPE_CHECKING
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
from superset.models.core import CssTemplate
from superset.extensions import event_logger
from superset.mcp_service.css_template.schemas import (
CssTemplateError,
CssTemplateFilter,
CssTemplateInfo,
CssTemplateList,
ListCssTemplatesRequest,
serialize_css_template_object,
)
from superset.mcp_service.mcp_core import ModelListCore
logger = logging.getLogger(__name__)
_DEFAULT_LIST_CSS_TEMPLATES_REQUEST = ListCssTemplatesRequest()
@tool(
tags=["core"],
class_permission_name="CssTemplate",
annotations=ToolAnnotations(
title="List CSS templates",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_css_templates(
request: ListCssTemplatesRequest | None = None,
ctx: Context | None = None,
) -> CssTemplateList | CssTemplateError:
"""List CSS templates with filtering and search.
Returns CSS template metadata including name. Use select_columns=['css']
to include the CSS content (omitted by default due to size).
Sortable columns for order_column: id, template_name, changed_on,
created_on
"""
if ctx is None:
raise RuntimeError("FastMCP context is required for list_css_templates")
request = request or _DEFAULT_LIST_CSS_TEMPLATES_REQUEST.model_copy(deep=True)
await ctx.info(
"Listing CSS templates: page=%s, page_size=%s, search=%s"
% (request.page, request.page_size, request.search)
)
await ctx.debug(
"CSS template listing parameters: filters=%s, order_column=%s, "
"order_direction=%s, select_columns=%s"
% (
request.filters,
request.order_column,
request.order_direction,
request.select_columns,
)
)
try:
from superset.daos.css import CssTemplateDAO
from superset.mcp_service.common.schema_discovery import (
CSS_TEMPLATE_DEFAULT_COLUMNS,
CSS_TEMPLATE_SORTABLE_COLUMNS,
get_all_column_names,
get_css_template_columns,
)
all_columns = get_all_column_names(get_css_template_columns())
def _serialize_css_template(
obj: "CssTemplate | None", cols: list[str] | None
) -> CssTemplateInfo | None:
return serialize_css_template_object(obj)
list_tool = ModelListCore(
dao_class=CssTemplateDAO,
output_schema=CssTemplateInfo,
item_serializer=_serialize_css_template,
filter_type=CssTemplateFilter,
default_columns=CSS_TEMPLATE_DEFAULT_COLUMNS,
search_columns=["template_name"],
list_field_name="css_templates",
output_list_schema=CssTemplateList,
all_columns=all_columns,
sortable_columns=CSS_TEMPLATE_SORTABLE_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_css_templates.query"):
result = list_tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"CSS templates listed successfully: count=%s, total_count=%s"
% (
len(result.css_templates) if hasattr(result, "css_templates") else 0,
getattr(result, "total_count", None),
)
)
columns_to_filter = result.columns_requested
with event_logger.log_context(action="mcp.list_css_templates.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(
"CSS template listing failed: page=%s, page_size=%s, error=%s, "
"error_type=%s"
% (request.page, request.page_size, str(e), type(e).__name__)
)
raise
@@ -0,0 +1,111 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.css_template.schemas import (
UpdateCssTemplateRequest,
UpdateCssTemplateResponse,
)
@tool(
tags=["mutate"],
class_permission_name="CssTemplate",
method_permission_name="write",
annotations=ToolAnnotations(
title="Update CSS template",
readOnlyHint=False,
destructiveHint=False,
),
)
async def update_css_template(
request: UpdateCssTemplateRequest, ctx: Context
) -> UpdateCssTemplateResponse:
"""Update an existing CSS template's name or CSS content.
Use this tool when a user wants to rename a CSS template or replace its
CSS content. At least one of ``template_name`` or ``css`` must be provided.
The template is identified by its ``id``.
"""
await ctx.info(
"Updating CSS template: id=%s, fields=%r"
% (
request.id,
[f for f in ("template_name", "css") if getattr(request, f) is not None],
)
)
try:
from superset.commands.css.exceptions import (
CssTemplateInvalidError,
CssTemplateNotFoundError,
CssTemplateUpdateFailedError,
)
from superset.commands.css.update import UpdateCssTemplateCommand
properties: dict[str, Any] = {}
if request.template_name is not None:
properties["template_name"] = request.template_name
if request.css is not None:
properties["css"] = request.css
if not properties:
return UpdateCssTemplateResponse(
error="At least one of template_name or css must be provided.",
)
with event_logger.log_context(action="mcp.update_css_template.update"):
template = UpdateCssTemplateCommand(request.id, properties).run()
await ctx.info(
"CSS template updated: id=%s, template_name=%r"
% (template.id, template.template_name)
)
return UpdateCssTemplateResponse(
id=template.id,
template_name=template.template_name,
css=template.css,
)
except CssTemplateNotFoundError:
await ctx.warning("CSS template not found: id=%s" % (request.id,))
return UpdateCssTemplateResponse(
error="CSS template not found: %s" % (request.id,),
)
except CssTemplateInvalidError as exc:
await ctx.warning("CSS template validation failed: %s" % (str(exc),))
return UpdateCssTemplateResponse(
error=str(exc),
)
except CssTemplateUpdateFailedError as exc:
await ctx.error("CSS template update failed: %s" % (str(exc),))
return UpdateCssTemplateResponse(
error="Failed to update CSS template: %s" % (exc,),
)
except Exception as exc:
await ctx.error(
"Unexpected error updating CSS template: %s: %s"
% (type(exc).__name__, str(exc))
)
raise
+256 -11
View File
@@ -69,7 +69,6 @@ import logging
from datetime import datetime
from typing import Annotated, Any, cast, Dict, List, Literal, TYPE_CHECKING
import humanize
from pydantic import (
AliasChoices,
BaseModel,
@@ -104,6 +103,7 @@ from superset.mcp_service.utils import (
escape_llm_context_delimiters,
sanitize_for_llm_context,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
from superset.mcp_service.utils.sanitization import (
sanitize_user_input,
sanitize_user_input_with_changes,
@@ -314,6 +314,17 @@ class GetDashboardInfoRequest(MetadataCacheControl):
)
class GetDashboardLayoutRequest(BaseModel):
"""Request schema for get_dashboard_layout."""
identifier: Annotated[
int | str,
Field(
description="Dashboard identifier - can be numeric ID, UUID string, or slug"
),
]
logger = logging.getLogger(__name__)
@@ -648,6 +659,71 @@ class GenerateDashboardResponse(BaseModel):
)
class ChartPosition(BaseModel):
"""Position and identity of a chart within a dashboard layout."""
chart_id: int | None = Field(None, description="Chart (slice) ID")
slice_name: str | None = Field(
None,
description=(
"Display name as configured in the layout (sliceNameOverride or sliceName)"
),
)
tab_id: str | None = Field(
None,
description=(
"ID of the tab that contains this chart, or None for charts not nested "
"under any TAB component."
),
)
tab_path: List[str] = Field(
default_factory=list,
description=(
"Names of ancestor tabs (outermost first) so the agent can describe "
"where the chart lives in nested tab layouts."
),
)
width: int | None = Field(None, description="Grid column width")
height: int | None = Field(None, description="Grid row height")
class DashboardTab(BaseModel):
"""A tab in a dashboard layout."""
id: str = Field(..., description="Tab component ID from position_json")
name: str | None = Field(None, description="Tab display name")
parent_tab_id: str | None = Field(
None,
description=("ID of the enclosing tab when tabs are nested, otherwise None."),
)
chart_ids: List[int] = Field(
default_factory=list,
description="IDs of charts contained directly or indirectly under this tab",
)
class DashboardLayout(BaseModel):
"""Parsed layout data for a dashboard, derived from position_json."""
id: int | None = Field(None, description="Dashboard ID")
dashboard_title: str | None = Field(None, description="Dashboard title")
uuid: str | None = Field(None, description="Dashboard UUID")
tabs: List[DashboardTab] = Field(
default_factory=list,
description=(
"Tabs declared in the dashboard layout (empty for untabbed dashboards)"
),
)
charts: List[ChartPosition] = Field(
default_factory=list,
description="Charts placed in the dashboard layout with their tab context",
)
has_layout: bool = Field(
default=False,
description="False when position_json is missing or empty",
)
def _parse_json_metadata(json_metadata_str: str | None) -> Dict[str, Any] | None:
"""Parse json_metadata string into a dict, returning None on any failure.
@@ -719,6 +795,126 @@ def _extract_cross_filters_enabled(json_metadata_str: str | None) -> bool | None
return None
def _parse_position_json(
position_json_str: str | None,
) -> Dict[str, Any] | None:
"""Parse position_json into a dict, returning None on any failure."""
if not position_json_str:
return None
try:
data = json_loads(position_json_str)
except (ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _record_tab(
node_id: str,
meta: Dict[str, Any],
tab_ancestry: tuple[str, ...],
tabs_by_id: Dict[str, DashboardTab],
) -> None:
"""Register a TAB node into tabs_by_id keyed by component id."""
raw_text = meta.get("text")
tab_name = raw_text if isinstance(raw_text, str) else None
tabs_by_id[node_id] = DashboardTab(
id=node_id,
name=tab_name,
parent_tab_id=tab_ancestry[-1] if tab_ancestry else None,
)
def _record_chart(
meta: Dict[str, Any],
tab_ancestry: tuple[str, ...],
tabs_by_id: Dict[str, DashboardTab],
charts: List[ChartPosition],
) -> None:
"""Record a CHART node's position and update enclosing tabs."""
raw_chart_id = meta.get("chartId")
chart_id = raw_chart_id if isinstance(raw_chart_id, int) else None
display_name = meta.get("sliceNameOverride") or meta.get("sliceName")
raw_width = meta.get("width")
raw_height = meta.get("height")
charts.append(
ChartPosition(
chart_id=chart_id,
slice_name=display_name if isinstance(display_name, str) else None,
tab_id=tab_ancestry[-1] if tab_ancestry else None,
tab_path=[tabs_by_id[t].name or t for t in tab_ancestry if t in tabs_by_id],
width=raw_width if isinstance(raw_width, int) else None,
height=raw_height if isinstance(raw_height, int) else None,
)
)
if chart_id is None:
return
for ancestor_id in tab_ancestry:
tab = tabs_by_id.get(ancestor_id)
if tab is not None and chart_id not in tab.chart_ids:
tab.chart_ids.append(chart_id)
def _extract_layout_from_position(
position_json_str: str | None,
) -> tuple[List[DashboardTab], List[ChartPosition]]:
"""Walk position_json and return (tabs, chart_positions).
Traverses the component tree iteratively starting from ROOT_ID. Tab
ancestry is tracked so chart placement and nested tab references stay
accurate. Malformed or missing nodes are skipped silently partial
data is more useful than an exception here, since agents call this
tool defensively after seeing the omitted_fields hint.
"""
position = _parse_position_json(position_json_str)
if not position or "ROOT_ID" not in position:
return [], []
tabs_by_id: Dict[str, DashboardTab] = {}
charts: List[ChartPosition] = []
stack: List[tuple[str, tuple[str, ...]]] = [("ROOT_ID", ())]
visited: set[str] = set()
while stack:
node_id, tab_ancestry = stack.pop()
if node_id in visited:
continue
visited.add(node_id)
node = position.get(node_id)
if not isinstance(node, dict):
continue
node_type = node.get("type")
raw_meta = node.get("meta")
meta: Dict[str, Any] = raw_meta if isinstance(raw_meta, dict) else {}
next_ancestry = tab_ancestry
if node_type == "TAB":
_record_tab(node_id, meta, tab_ancestry, tabs_by_id)
next_ancestry = tab_ancestry + (node_id,)
elif node_type == "CHART":
_record_chart(meta, tab_ancestry, tabs_by_id, charts)
children = node.get("children")
if isinstance(children, list):
for child_id in reversed(children):
if isinstance(child_id, str):
stack.append((child_id, next_ancestry))
tab_order = [
node_id
for node_id in position
if isinstance(position.get(node_id), dict)
and position[node_id].get("type") == "TAB"
and node_id in tabs_by_id
]
tabs = [tabs_by_id[node_id] for node_id in tab_order]
return tabs, charts
def _build_omitted_fields(
json_metadata_str: str | None, position_json_str: str | None
) -> Dict[str, str]:
@@ -736,7 +932,8 @@ def _build_omitted_fields(
raw_value=position_json_str,
reason=(
"Internal layout tree with component positions/hierarchy. "
"Not useful for analysis or LLM context."
"Call get_dashboard_layout(identifier) to retrieve parsed tabs "
"and chart positions on demand."
),
)
.add_extracted_field(
@@ -927,13 +1124,6 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
)
def _humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
return humanize.naturaltime(datetime.now() - dt)
def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
"""Simple dashboard serializer that safely handles object attributes."""
from superset.mcp_service.utils.url_utils import get_superset_base_url
@@ -960,11 +1150,11 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
url=dashboard_url,
published=getattr(dashboard, "published", None),
changed_on=getattr(dashboard, "changed_on", None),
changed_on_humanized=_humanize_timestamp(
changed_on_humanized=humanize_timestamp(
getattr(dashboard, "changed_on", None)
),
created_on=getattr(dashboard, "created_on", None),
created_on_humanized=_humanize_timestamp(
created_on_humanized=humanize_timestamp(
getattr(dashboard, "created_on", None)
),
description=getattr(dashboard, "description", None),
@@ -1004,3 +1194,58 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
else [],
)
)
def _sanitize_dashboard_layout_for_llm_context(
layout: DashboardLayout,
) -> DashboardLayout:
"""Wrap layout text fields before LLM exposure."""
payload = layout.model_dump(mode="python")
payload["dashboard_title"] = sanitize_for_llm_context(
payload.get("dashboard_title"),
field_path=("dashboard_title",),
)
payload["tabs"] = [
{
**tab,
"name": sanitize_for_llm_context(
tab.get("name"),
field_path=("tabs", str(index), "name"),
),
}
for index, tab in enumerate(payload.get("tabs", []))
]
payload["charts"] = [
{
**chart,
"slice_name": sanitize_for_llm_context(
chart.get("slice_name"),
field_path=("charts", str(index), "slice_name"),
),
"tab_path": [
sanitize_for_llm_context(
name,
field_path=("charts", str(index), "tab_path", str(part_index)),
)
for part_index, name in enumerate(chart.get("tab_path", []) or [])
],
}
for index, chart in enumerate(payload.get("charts", []))
]
return DashboardLayout.model_validate(payload)
def dashboard_layout_serializer(dashboard: "Dashboard") -> DashboardLayout:
"""Serialize a Dashboard model to a parsed DashboardLayout."""
position_json_str = getattr(dashboard, "position_json", None)
tabs, charts = _extract_layout_from_position(position_json_str)
return _sanitize_dashboard_layout_for_llm_context(
DashboardLayout(
id=dashboard.id,
dashboard_title=dashboard.dashboard_title or "Untitled",
uuid=str(dashboard.uuid) if dashboard.uuid else None,
tabs=tabs,
charts=charts,
has_layout=bool(position_json_str),
)
)
@@ -18,11 +18,13 @@
from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
from .generate_dashboard import generate_dashboard
from .get_dashboard_info import get_dashboard_info
from .get_dashboard_layout import get_dashboard_layout
from .list_dashboards import list_dashboards
__all__ = [
"list_dashboards",
"get_dashboard_info",
"get_dashboard_layout",
"generate_dashboard",
"add_chart_to_existing_dashboard",
]
@@ -414,10 +414,10 @@ def generate_dashboard( # noqa: C901
# Convert to our response format
from superset.mcp_service.dashboard.schemas import (
_humanize_timestamp,
serialize_chart_summary,
serialize_tag_object,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
include_data_model_metadata = user_can_view_data_model_metadata()
dashboard_info = DashboardInfo(
@@ -428,8 +428,8 @@ def generate_dashboard( # noqa: C901
published=dashboard.published,
created_on=dashboard.created_on,
changed_on=dashboard.changed_on,
created_on_humanized=_humanize_timestamp(dashboard.created_on),
changed_on_humanized=_humanize_timestamp(dashboard.changed_on),
created_on_humanized=humanize_timestamp(dashboard.created_on),
changed_on_humanized=humanize_timestamp(dashboard.changed_on),
created_by=dashboard.created_by_name or None,
changed_by=dashboard.changed_by_name or None,
uuid=str(dashboard.uuid) if dashboard.uuid else None,
@@ -0,0 +1,119 @@
# 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.
"""
Get dashboard layout FastMCP tool
Companion to get_dashboard_info: returns the parsed dashboard layout
(tabs and chart positions) extracted from position_json. Use this
when get_dashboard_info's omitted_fields hint indicates position_json
was stripped and structured layout data is needed for analysis.
"""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
dashboard_layout_serializer,
DashboardError,
DashboardLayout,
GetDashboardLayoutRequest,
)
from superset.mcp_service.mcp_core import ModelGetInfoCore
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Dashboard",
annotations=ToolAnnotations(
title="Get dashboard layout",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_dashboard_layout(
request: GetDashboardLayoutRequest, ctx: Context
) -> DashboardLayout | DashboardError:
"""
Get parsed dashboard layout by ID, UUID, or slug.
Returns the tabs and chart positions extracted from the dashboard's
position_json. get_dashboard_info omits position_json to keep responses
small; call this tool when you need the structured layout (e.g. to
explain which charts live under which tab, or to locate a chart by
its parent tab).
Example usage:
```json
{
"identifier": 123
}
```
"""
await ctx.info("Retrieving dashboard layout: identifier=%s" % (request.identifier,))
try:
from superset.daos.dashboard import DashboardDAO
# No eager loading: the layout serializer only reads position_json
# (plus id/title/uuid), so Dashboard.slices is never accessed.
with event_logger.log_context(action="mcp.get_dashboard_layout.lookup"):
core = ModelGetInfoCore(
dao_class=DashboardDAO,
output_schema=DashboardLayout,
error_schema=DashboardError,
serializer=dashboard_layout_serializer,
supports_slug=True,
logger=logger,
)
result = core.run_tool(request.identifier)
if isinstance(result, DashboardLayout):
await ctx.info(
"Dashboard layout retrieved: id=%s, tab_count=%s, chart_count=%s, "
"has_layout=%s"
% (
result.id,
len(result.tabs),
len(result.charts),
result.has_layout,
)
)
else:
await ctx.warning(
"Dashboard layout retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"Dashboard layout retrieval failed: identifier=%s, error=%s, "
"error_type=%s" % (request.identifier, str(e), type(e).__name__)
)
return DashboardError(
error=f"Failed to get dashboard layout: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)
+3 -11
View File
@@ -24,7 +24,6 @@ from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, cast, Dict, List, Literal
import humanize
from pydantic import (
BaseModel,
ConfigDict,
@@ -43,6 +42,7 @@ from superset.mcp_service.common.cache_schemas import (
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.privacy import filter_user_directory_fields
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils.response_utils import humanize_timestamp
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
@@ -305,14 +305,6 @@ def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None:
return value
def _humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
return humanize.naturaltime(now - dt)
def _get_backend(database: Any) -> str | None:
"""Safely get backend from a Database object or row proxy.
@@ -351,7 +343,7 @@ def serialize_database_object(database: Any) -> DatabaseInfo | None:
external_url=getattr(database, "external_url", None),
extra=_parse_json_field(database, "extra"),
changed_on=getattr(database, "changed_on", None),
changed_on_humanized=_humanize_timestamp(getattr(database, "changed_on", None)),
changed_on_humanized=humanize_timestamp(getattr(database, "changed_on", None)),
created_on=getattr(database, "created_on", None),
created_on_humanized=_humanize_timestamp(getattr(database, "created_on", None)),
created_on_humanized=humanize_timestamp(getattr(database, "created_on", None)),
)
+3 -10
View File
@@ -24,7 +24,6 @@ from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal
import humanize
from pydantic import (
BaseModel,
ConfigDict,
@@ -54,6 +53,7 @@ from superset.mcp_service.utils import (
escape_llm_context_delimiters,
sanitize_for_llm_context,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
from superset.utils import json
@@ -554,13 +554,6 @@ def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None:
return value
def _humanize_timestamp(dt: datetime | None) -> str | None:
"""Convert a datetime to a humanized string like '2 hours ago'."""
if dt is None:
return None
return humanize.naturaltime(datetime.now() - dt)
def _sanitize_dataset_info_for_llm_context(dataset_info: DatasetInfo) -> DatasetInfo:
"""Wrap dataset read-path descriptive fields before LLM exposure."""
payload = dataset_info.model_dump(mode="python")
@@ -691,11 +684,11 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
certified_by=getattr(dataset, "certified_by", None),
certification_details=getattr(dataset, "certification_details", None),
changed_on=getattr(dataset, "changed_on", None),
changed_on_humanized=_humanize_timestamp(
changed_on_humanized=humanize_timestamp(
getattr(dataset, "changed_on", None)
),
created_on=getattr(dataset, "created_on", None),
created_on_humanized=_humanize_timestamp(
created_on_humanized=humanize_timestamp(
getattr(dataset, "created_on", None)
),
tags=[
+263 -12
View File
@@ -26,8 +26,10 @@ HTTP responses always return generic errors per RFC 6750 Section 3.1.
"""
import base64
import html as html_module
import logging
import time
from collections.abc import Callable
from contextvars import ContextVar
from typing import Any, cast
@@ -44,8 +46,9 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
from starlette.authentication import AuthenticationError
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.requests import HTTPConnection
from starlette.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import HTTPConnection, Request
from starlette.responses import HTMLResponse, JSONResponse, Response
from superset.utils import json
@@ -61,21 +64,247 @@ _jwt_failure_reason: ContextVar[str | None] = ContextVar(
"_jwt_failure_reason", default=None
)
_HTML_STYLES = """
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #1a1a1a;
margin: 0;
padding: 40px 16px;
line-height: 1.6;
}
.card {
max-width: 640px;
margin: 0 auto;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,.12);
padding: 40px 40px 32px;
}
h1 { font-size: 1.4rem; margin: 0 0 8px; }
.badge {
display: inline-block;
background: #e8f4fd;
color: #0070c0;
font-size: .75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
margin-bottom: 20px;
letter-spacing: .04em;
text-transform: uppercase;
}
p { margin: 0 0 20px; color: #444; }
h2 { font-size: 1rem; margin: 24px 0 8px; color: #1a1a1a; }
pre {
background: #f0f0f0;
border-radius: 6px;
padding: 16px;
font-size: .85rem;
overflow-x: auto;
margin: 0 0 24px;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
.note {
font-size: .85rem;
color: #666;
border-left: 3px solid #ddd;
padding-left: 12px;
margin-top: 24px;
}
.logo {
max-height: 48px; max-width: 200px; margin-bottom: 20px; display: block;
}"""
def _json_auth_error_handler(
conn: HTTPConnection, exc: AuthenticationError
) -> JSONResponse:
"""JSON 401 error handler for authentication failures.
_DEFAULT_CLIENTS = [
"Claude Desktop",
"Claude Code (CLI)",
"Cursor",
]
Per RFC 6750 Section 3.1, error responses MUST NOT leak server
configuration or token claim values. Only generic error codes are
returned to clients. Detailed failure reasons are logged server-side
only for debugging.
_DEFAULT_HELLO_PAGE_CONFIG: dict[str, Any] = {
# Page heading and browser tab title
"title": "Superset MCP Server",
# Key name used in the mcpServers config snippet (e.g. "superset", "my-company")
"server_key": "superset",
# Include "transport": "streamable-http" in the config snippet.
# Recommended: Claude Desktop defaults to SSE so the transport must be explicit.
"show_transport": True,
# Supported MCP clients listed on the page
"clients": _DEFAULT_CLIENTS,
}
def _build_config_snippet(
auth_enabled: bool, server_key: str, show_transport: bool
) -> str:
# superset.utils.json.dumps() ensures the key is a valid JSON string.
from superset.utils import json as superset_json
key_json = superset_json.dumps(server_key)
inner_parts = [' "url": "<this-url>"']
if show_transport:
inner_parts.append(' "transport": "streamable-http"')
if auth_enabled:
inner_parts.append(
' "headers": {\n'
' "Authorization": "Bearer <your-api-key>"\n'
" }"
)
inner = ",\n".join(inner_parts)
return f'{{\n "mcpServers": {{\n {key_json}: {{\n{inner}\n }}\n }}\n}}'
def _build_browser_hello_html(
auth_enabled: bool,
page_config: dict[str, Any] | None = None,
) -> str:
cfg = {**_DEFAULT_HELLO_PAGE_CONFIG, **(page_config or {})}
title: str = html_module.escape(str(cfg["title"]))
server_key: str = cfg["server_key"]
show_transport: bool = cfg["show_transport"]
clients: list[str] = [html_module.escape(str(c)) for c in cfg["clients"]]
app_name: str = html_module.escape(str(cfg.get("app_name", "Apache Superset")))
logo_url: str | None = None
if logo_url_raw := cfg.get("logo_url"):
logo_url_stripped = str(logo_url_raw).strip()
if logo_url_stripped.startswith(("http://", "https://")):
logo_url = html_module.escape(logo_url_stripped)
# html.escape() ensures server_key and all other content in the snippet
# cannot break out of the <pre><code> block (json.dumps does not escape HTML).
config_block = html_module.escape(
_build_config_snippet(auth_enabled, server_key, show_transport)
)
if auth_enabled:
connect_desc = (
"Add the following to your MCP client configuration, "
"replacing the URL and API key with your actual values:"
)
note = (
"Replace <code>&lt;this-url&gt;</code> with the full URL of this page "
"and <code>&lt;your-api-key&gt;</code> with a valid API key or JWT token."
)
else:
connect_desc = (
"Add the following to your MCP client configuration, "
"replacing the URL with your actual server URL:"
)
note = "Replace <code>&lt;this-url&gt;</code> with the full URL of this page."
client_items = "\n".join(f" <li>{c}</li>" for c in clients)
logo_html = (
f'<img src="{logo_url}" alt="{title}" class="logo">\n ' if logo_url else ""
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>{_HTML_STYLES}
</style>
</head>
<body>
<div class="card">
{logo_html}<div class="badge">MCP API Endpoint</div>
<h1>{title}</h1>
<p>
This is the <strong>Model Context Protocol (MCP)</strong> endpoint for
{app_name}. It is an API designed for AI coding assistants
not a web page to browse directly.
</p>
<h2>How to connect</h2>
<p>{connect_desc}</p>
<pre><code>{config_block}</code></pre>
<h2>Supported clients</h2>
<p>This endpoint works with any MCP-compatible client, including:</p>
<ul style="color:#444;margin:0 0 20px;padding-left:20px;">
{client_items}
<li>Any client that supports the <code>streamable-http</code> transport</li>
</ul>
<div class="note">
{note}
</div>
</div>
</body>
</html>"""
# Pre-built for _auth_error_handler (auth-required context, default config)
_MCP_BROWSER_HELLO_HTML = _build_browser_hello_html(auth_enabled=True)
def _prefers_browser_html(conn: HTTPConnection) -> bool:
"""Return True when the request looks like a browser navigation.
Checks both the HTTP method (GET/HEAD only) and the Accept header
(text/html present, application/json and text/event-stream absent).
Case-insensitive to handle unusual but valid header values.
"""
if conn.scope.get("method") not in ("GET", "HEAD"):
return False
accept = conn.headers.get("accept", "").lower()
return (
"text/html" in accept
and "application/json" not in accept
and "text/event-stream" not in accept
)
class BrowserHelloMiddleware(BaseHTTPMiddleware):
"""Starlette middleware that returns a browser-friendly hello page.
Intercepts GET/HEAD requests with a browser Accept header before they
reach FastMCP's router (which returns 405 for GET). Works regardless
of whether MCP_AUTH_ENABLED is True or False.
When auth_enabled=True the page includes Bearer token setup instructions.
When auth_enabled=False the page omits the Authorization header from the
config snippet since no credentials are required.
"""
def __init__(
self,
app: Any,
auth_enabled: bool = False,
page_config: dict[str, Any] | None = None,
) -> None:
super().__init__(app)
self._html = _build_browser_hello_html(
auth_enabled=auth_enabled, page_config=page_config
)
async def dispatch(
self, request: Request, call_next: Callable[..., Any]
) -> Response:
if request.method in ("GET", "HEAD") and _prefers_browser_html(request):
return HTMLResponse(content=self._html, status_code=200)
return await call_next(request)
def _auth_error_handler(conn: HTTPConnection, exc: AuthenticationError) -> Response:
"""Auth error handler for unauthenticated MCP requests.
Returns a friendly HTML page for browser navigation requests so users
who open the MCP URL in a browser see setup instructions instead of a
raw JSON 401.
For all other clients (API, SSE, non-GET methods) returns a standard
JSON 401 per RFC 6750 Section 3.1.
References:
- RFC 6750 Section 3.1: https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
- CVE-2022-29266, CVE-2019-7644: verbose JWT errors led to exploits
"""
if _prefers_browser_html(conn):
return HTMLResponse(status_code=200, content=_MCP_BROWSER_HELLO_HTML)
# Log detailed reason server-side only
logger.warning("JWT authentication failed: %s", exc)
@@ -91,6 +320,28 @@ def _json_auth_error_handler(
)
class MCPJWTVerifier(JWTVerifier):
"""JWTVerifier with Superset MCP auth error handling.
Provides browser-friendly HTML responses for unauthenticated browser
navigation requests (GET/HEAD with Accept: text/html), while maintaining
RFC 6750-compliant JSON 401 responses for API and SSE clients.
Use this as the base for all Superset JWT verifiers so the browser hello
page is active regardless of which verifier variant is configured.
"""
def get_middleware(self) -> list[Any]:
return [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(self),
on_error=_auth_error_handler,
),
Middleware(AuthContextMiddleware),
]
class DetailedBearerAuthBackend(BearerAuthBackend):
"""
Bearer auth backend that raises AuthenticationError with specific
@@ -124,7 +375,7 @@ class DetailedBearerAuthBackend(BearerAuthBackend):
return None
class DetailedJWTVerifier(JWTVerifier):
class DetailedJWTVerifier(MCPJWTVerifier):
"""
JWT verifier with tiered server-side logging for each validation step.
@@ -300,7 +551,7 @@ class DetailedJWTVerifier(JWTVerifier):
Middleware(
AuthenticationMiddleware,
backend=DetailedBearerAuthBackend(self),
on_error=_json_auth_error_handler,
on_error=_auth_error_handler,
),
Middleware(AuthContextMiddleware),
]
+3 -3
View File
@@ -343,10 +343,10 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
auth_provider = DetailedJWTVerifier(**common_kwargs)
else:
# Default JWTVerifier: minimal logging, generic error responses.
from fastmcp.server.auth.providers.jwt import JWTVerifier
# MCPJWTVerifier: minimal logging + browser-friendly error page.
from superset.mcp_service.jwt_verifier import MCPJWTVerifier
auth_provider = JWTVerifier(**common_kwargs)
auth_provider = MCPJWTVerifier(**common_kwargs)
return auth_provider
except Exception:
+7
View File
@@ -733,6 +733,7 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
default_sort: str = "changed_on",
default_sort_direction: Literal["asc", "desc"] = "desc",
exclude_filter_columns: set[str] | None = None,
filter_columns_override: dict[str, list[str]] | None = None,
logger: logging.Logger | None = None,
) -> None:
"""
@@ -750,6 +751,9 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
default_sort_direction: Default sort direction
exclude_filter_columns: Column names to omit from filter discovery
(e.g., sensitive fields like passwords or connection URIs)
filter_columns_override: When set, use this mapping directly as the
filter_columns output instead of querying the DAO. Use this to
restrict advertised filters to the exact set the list tool accepts.
logger: Optional logger instance
"""
super().__init__(logger)
@@ -770,9 +774,12 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
# Hide user-directory columns from filter discovery, except the small
# set callers may legitimately filter by ID (resolved via find_users).
self.exclude_filter_columns.update(USER_DIRECTORY_FIELDS - USER_FILTER_FIELDS)
self.filter_columns_override = filter_columns_override
def _get_filter_columns(self) -> Dict[str, List[str]]:
"""Get filterable columns and operators from the DAO."""
if self.filter_columns_override is not None:
return self.filter_columns_override
try:
filterable = self.dao_class.get_filterable_columns_and_operators()
# Defensive handling: ensure we have a valid mapping
+16
View File
@@ -0,0 +1,16 @@
# 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.
+293
View File
@@ -0,0 +1,293 @@
# 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.
"""
Pydantic schemas for query history-related responses
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.constants import MAX_PAGE_SIZE
from superset.mcp_service.privacy import filter_user_directory_fields
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
DEFAULT_QUERY_COLUMNS = ["id", "status", "start_time", "database_id", "schema"]
SORTABLE_QUERY_COLUMNS = [
"id",
"start_time",
"end_time",
"status",
"database_id",
"changed_on",
]
ALL_QUERY_COLUMNS = [
"id",
"sql",
"executed_sql",
"status",
"start_time",
"end_time",
"rows",
"database_id",
"schema",
"catalog",
"tab_name",
"error_message",
"client_id",
"limit",
"progress",
"changed_on",
"user_id",
]
DEFAULT_QUERY_PAGE_SIZE = 25
class QueryFilter(ColumnOperator):
"""
Filter object for query history listing.
col: The column to filter on. Must be one of the allowed filter fields.
opr: The operator to use. Must be one of the supported operators.
value: The value to filter by (type depends on col and opr).
"""
col: Literal["status", "database_id", "schema", "user_id", "start_time"] = Field(
...,
description="Column to filter on.",
)
opr: ColumnOperatorEnum = Field(
...,
description="Operator to use.",
)
value: str | int | float | bool | List[str | int | float | bool] = Field(
..., description="Value to filter by (type depends on col and opr)"
)
class QueryInfo(BaseModel):
id: int | None = Field(None, description="Query ID")
sql: str | None = Field(None, description="SQL query text as submitted")
executed_sql: str | None = Field(
None, description="Actual SQL executed after templating/CTAS rewriting"
)
status: str | None = Field(None, description="Query execution status")
start_time: float | None = Field(
None, description="Query start time (seconds since epoch)"
)
end_time: float | None = Field(
None, description="Query end time (seconds since epoch)"
)
rows: int | None = Field(None, description="Number of rows returned or affected")
database_id: int | None = Field(None, description="Database connection ID")
schema: str | None = Field(None, description="Database schema name")
catalog: str | None = Field(None, description="Database catalog name")
tab_name: str | None = Field(None, description="SQL Lab tab name")
error_message: str | None = Field(None, description="Error message if query failed")
client_id: str | None = Field(None, description="Client-assigned query identifier")
limit: int | None = Field(None, description="Row limit applied to the query")
progress: int | None = Field(None, description="Query execution progress (0-100)")
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
)
user_id: int | None = Field(None, description="ID of the user who ran the query")
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
@model_serializer(mode="wrap")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
data = filter_user_directory_fields(serializer(self))
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
requested_fields = set(select_columns)
return {k: v for k, v in data.items() if k in requested_fields}
return data
class QueryList(BaseModel):
queries: List[QueryInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: List[str] = Field(
default_factory=list,
description="Requested columns for the response",
)
columns_loaded: List[str] = Field(
default_factory=list,
description="Columns that were actually loaded for each query",
)
columns_available: List[str] = Field(
default_factory=list,
description="All columns available for selection via select_columns parameter",
)
sortable_columns: List[str] = Field(
default_factory=list,
description="Columns that can be used with order_column parameter",
)
filters_applied: List[QueryFilter] = Field(
default_factory=list,
description="List of advanced filter dicts applied to the query.",
)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListQueriesRequest(BaseModel):
"""Request schema for list_queries."""
filters: Annotated[
List[QueryFilter],
Field(
default_factory=list,
description="List of filter objects (column, operator, value). Each "
"filter is an object with 'col', 'opr', and 'value' "
"properties. Cannot be used together with 'search'.",
),
]
select_columns: Annotated[
List[str],
Field(
default_factory=list,
description="List of columns to select. Defaults to common columns if not "
"specified.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search string to match against query fields. "
"Cannot be used together with 'filters'.",
),
]
order_column: Annotated[
str | None,
Field(default=None, description="Column to order results by"),
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(
default="desc",
description="Direction to order results ('asc' or 'desc')",
),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number for pagination (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_QUERY_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> List[QueryFilter]:
"""Accept both JSON string and list of objects."""
return parse_json_or_model_list(v, QueryFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> List[str]:
"""Accept JSON array, list, or comma-separated string."""
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListQueriesRequest":
"""Prevent using both search and filters simultaneously."""
if self.search and self.filters:
raise ValueError(
"Cannot use both 'search' and 'filters' parameters simultaneously. "
"Use either 'search' for text-based searching across multiple fields, "
"or 'filters' for precise column-based filtering, but not both."
)
return self
class QueryError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
class GetQueryInfoRequest(BaseModel):
"""Request schema for get_query_info with support for numeric ID only."""
identifier: Annotated[
int,
Field(description="Query ID (numeric)"),
]
def serialize_query_object(query: Any) -> QueryInfo | None:
if not query:
return None
return QueryInfo(
id=getattr(query, "id", None),
sql=getattr(query, "sql", None),
executed_sql=getattr(query, "executed_sql", None),
status=getattr(query, "status", None),
start_time=getattr(query, "start_time", None),
end_time=getattr(query, "end_time", None),
rows=getattr(query, "rows", None),
database_id=getattr(query, "database_id", None),
schema=getattr(query, "schema", None),
catalog=getattr(query, "catalog", None),
tab_name=getattr(query, "tab_name", None),
error_message=getattr(query, "error_message", None),
client_id=getattr(query, "client_id", None),
limit=getattr(query, "limit", None),
progress=getattr(query, "progress", None),
changed_on=getattr(query, "changed_on", None),
user_id=getattr(query, "user_id", None),
)
@@ -0,0 +1,24 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .get_query_info import get_query_info
from .list_queries import list_queries
__all__ = [
"list_queries",
"get_query_info",
]
@@ -0,0 +1,122 @@
# 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.
"""
Get query info FastMCP tool
This module contains the FastMCP tool for getting detailed information
about a specific SQL query from the query history.
"""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelGetInfoCore
from superset.mcp_service.query.schemas import (
GetQueryInfoRequest,
QueryError,
QueryInfo,
serialize_query_object,
)
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Query",
annotations=ToolAnnotations(
title="Get query info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_query_info(
request: GetQueryInfoRequest, ctx: Context
) -> QueryInfo | QueryError:
"""Get SQL query history details by ID.
Returns query details including SQL text, execution status, timing,
row count, and any error messages.
IMPORTANT FOR LLM CLIENTS:
- Use numeric ID (e.g., 123)
- To find a query ID, use the list_queries tool first
Example usage:
```json
{
"identifier": 123
}
```
"""
await ctx.info(
"Retrieving query information: identifier=%s" % (request.identifier,)
)
try:
from superset.daos.query import QueryDAO
with event_logger.log_context(action="mcp.get_query_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=QueryDAO,
output_schema=QueryInfo,
error_schema=QueryError,
serializer=serialize_query_object,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.identifier)
if isinstance(result, QueryInfo):
await ctx.info(
"Query information retrieved successfully: "
"query_id=%s, status=%s, database_id=%s"
% (
result.id,
result.status,
result.database_id,
)
)
else:
await ctx.warning(
"Query retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"Query information retrieval failed: identifier=%s, error=%s, "
"error_type=%s"
% (
request.identifier,
str(e),
type(e).__name__,
)
)
return QueryError(
error="Failed to get query info",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)
@@ -0,0 +1,156 @@
# 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.
"""
List queries FastMCP tool
This module contains the FastMCP tool for listing SQL query history
with filtering, search, and pagination.
"""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelListCore
from superset.mcp_service.query.schemas import (
DEFAULT_QUERY_COLUMNS,
ListQueriesRequest,
QueryError,
QueryFilter,
QueryInfo,
QueryList,
serialize_query_object,
SORTABLE_QUERY_COLUMNS,
)
logger = logging.getLogger(__name__)
_DEFAULT_LIST_QUERIES_REQUEST = ListQueriesRequest()
@tool(
tags=["core"],
class_permission_name="Query",
annotations=ToolAnnotations(
title="List queries",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_queries(
request: ListQueriesRequest | None = None,
ctx: Context | None = None,
) -> QueryList | QueryError:
"""List SQL query history with filtering and search.
Returns recent queries executed by the current user (or all queries for
admins), including SQL text, status, timing, and database information.
Results are ordered by changed_on descending by default (start_time is not
always populated for all query records).
Sortable columns for order_column: id, start_time, end_time, status,
database_id, changed_on
"""
if ctx is None:
raise RuntimeError("FastMCP context is required for list_queries")
request = request or _DEFAULT_LIST_QUERIES_REQUEST.model_copy(deep=True)
await ctx.info(
"Listing queries: page=%s, page_size=%s, search=%s"
% (
request.page,
request.page_size,
request.search,
)
)
await ctx.debug(
"Query listing parameters: filters=%s, order_column=%s, "
"order_direction=%s, select_columns=%s"
% (
request.filters,
request.order_column,
request.order_direction,
request.select_columns,
)
)
try:
from superset.daos.query import QueryDAO
def _serialize_query(obj: object, cols: list[str] | None) -> QueryInfo | None:
return serialize_query_object(obj)
list_tool = ModelListCore(
dao_class=QueryDAO,
output_schema=QueryInfo,
item_serializer=_serialize_query,
filter_type=QueryFilter,
default_columns=DEFAULT_QUERY_COLUMNS,
search_columns=["tab_name", "sql"],
list_field_name="queries",
output_list_schema=QueryList,
all_columns=list(QueryInfo.model_fields.keys()),
sortable_columns=SORTABLE_QUERY_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_queries.query"):
result = list_tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column or "changed_on",
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"Queries listed successfully: count=%s, total_count=%s, total_pages=%s"
% (
len(result.queries) if hasattr(result, "queries") else 0,
getattr(result, "total_count", None),
getattr(result, "total_pages", None),
)
)
columns_to_filter = result.columns_requested
await ctx.debug(
"Applying field filtering via serialization context: columns=%s"
% (columns_to_filter,)
)
with event_logger.log_context(action="mcp.list_queries.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(
"Query listing failed: page=%s, page_size=%s, error=%s, error_type=%s"
% (
request.page,
request.page_size,
str(e),
type(e).__name__,
)
)
raise
+16
View File
@@ -0,0 +1,16 @@
# 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.
+264
View File
@@ -0,0 +1,264 @@
# 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.
"""Pydantic schemas for role-related MCP tool responses."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated, Any, List, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils import sanitize_for_llm_context
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
DEFAULT_ROLE_COLUMNS = ["id", "name"]
ROLE_ALL_COLUMNS = ["id", "name"]
ROLE_SORTABLE_COLUMNS = ["id", "name"]
class RoleFilter(ColumnOperator):
"""Filter object for role listing.
col: The column to filter on. Must be one of the allowed filter fields.
opr: The operator to use. Must be one of the supported operators.
value: The value to filter by (type depends on col and opr).
"""
col: Literal["name"] = Field(
...,
description="Column to filter on.",
)
opr: ColumnOperatorEnum = Field(
...,
description="Operator to use.",
)
value: str | int | float | bool | List[str | int | float | bool] = Field(
..., description="Value to filter by (type depends on col and opr)"
)
class RoleInfo(BaseModel):
id: int | None = Field(None, description="Role ID")
name: str | None = Field(None, description="Role name")
permissions: list[str] | None = Field(
None,
description=(
"Permission names assigned to this role "
"(only populated by get_role_info, not list_roles)"
),
)
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
@model_serializer(mode="wrap")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> dict[str, Any]:
data = serializer(self)
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
return {k: v for k, v in data.items() if k in select_columns}
return data
class RoleList(BaseModel):
roles: List[RoleInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: List[str] = Field(
default_factory=list,
description="Requested columns for the response",
)
columns_loaded: List[str] = Field(
default_factory=list,
description="Columns that were actually loaded for each role",
)
columns_available: List[str] = Field(
default_factory=list,
description="All columns available for selection via select_columns parameter",
)
sortable_columns: List[str] = Field(
default_factory=list,
description="Columns that can be used with order_column parameter",
)
filters_applied: List[RoleFilter] = Field(
default_factory=list,
description="List of advanced filter dicts applied to the query.",
)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListRolesRequest(BaseModel):
"""Request schema for list_roles."""
filters: Annotated[
List[RoleFilter],
Field(
default_factory=list,
description="List of filter objects (column, operator, value). Each "
"filter is an object with 'col', 'opr', and 'value' properties. "
"Cannot be used together with 'search'.",
),
]
select_columns: Annotated[
List[str],
Field(
default_factory=list,
description="List of columns to select. Defaults to common columns if "
"not specified.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search string to match against role name. Cannot be "
"used together with 'filters'.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(
default="asc", description="Direction to order results ('asc' or 'desc')"
),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number for pagination (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> List[RoleFilter]:
"""Accept both JSON string and list of objects."""
return parse_json_or_model_list(v, RoleFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> List[str]:
"""Accept JSON array, list, or comma-separated string."""
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListRolesRequest":
if self.search and self.filters:
raise ValueError(
"Cannot use both 'search' and 'filters' parameters simultaneously. "
"Use either 'search' for text-based searching or 'filters' for "
"precise column-based filtering, but not both."
)
return self
class RoleError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
@field_validator("error")
@classmethod
def sanitize_error_for_llm_context(cls, value: str) -> str:
"""Wrap error text before it is exposed to LLM context."""
return sanitize_for_llm_context(value, field_path=("error",))
@classmethod
def create(cls, error: str, error_type: str) -> "RoleError":
"""Create a standardized RoleError with timestamp."""
return cls(
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
)
class GetRoleInfoRequest(BaseModel):
"""Request schema for get_role_info."""
identifier: Annotated[
int,
Field(description="Role ID (integer)"),
]
def serialize_role_object(
role: Any, include_permissions: bool = False
) -> RoleInfo | None:
"""Serialize a FAB Role object into a RoleInfo schema.
Set include_permissions=True for get_role_info; leave False for list_roles
to avoid a per-role N+1 permissions lazy-load.
"""
if not role:
return None
permissions: list[str] | None = None
if include_permissions:
raw_perms = getattr(role, "permissions", None)
if raw_perms is not None:
try:
permissions = [p.name for p in raw_perms if hasattr(p, "name")]
except (AttributeError, TypeError):
permissions = None
return RoleInfo(
id=getattr(role, "id", None),
name=sanitize_for_llm_context(
getattr(role, "name", None), field_path=("name",)
),
permissions=[
sanitize_for_llm_context(p, field_path=("permissions",))
for p in permissions
]
if permissions is not None
else None,
)
@@ -0,0 +1,24 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .get_role_info import get_role_info
from .list_roles import list_roles
__all__ = [
"list_roles",
"get_role_info",
]
@@ -0,0 +1,97 @@
# 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.
"""Get role info FastMCP tool."""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelGetInfoCore
from superset.mcp_service.role.schemas import (
GetRoleInfoRequest,
RoleError,
RoleInfo,
serialize_role_object,
)
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="Role",
annotations=ToolAnnotations(
title="Get role info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_role_info(
request: GetRoleInfoRequest, ctx: Context
) -> RoleInfo | RoleError:
"""Get role details by ID. Admin only.
Returns role metadata including id and name.
Example usage:
```json
{
"identifier": 1
}
```
"""
await ctx.info("Retrieving role information: identifier=%s" % (request.identifier,))
try:
from superset.daos.role import RoleDAO
def _serializer(obj: object) -> RoleInfo | None:
return serialize_role_object(obj, include_permissions=True)
with event_logger.log_context(action="mcp.get_role_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=RoleDAO,
output_schema=RoleInfo,
error_schema=RoleError,
serializer=_serializer,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.identifier)
if isinstance(result, RoleInfo):
await ctx.info(
"Role information retrieved successfully: role_id=%s, name=%s"
% (result.id, result.name)
)
else:
await ctx.warning(
"Role retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"Role information retrieval failed: identifier=%s, error=%s, error_type=%s"
% (request.identifier, str(e), type(e).__name__)
)
raise
@@ -0,0 +1,135 @@
# 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.
"""List roles FastMCP tool."""
import logging
from typing import Any
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelListCore
from superset.mcp_service.role.schemas import (
DEFAULT_ROLE_COLUMNS,
ListRolesRequest,
ROLE_ALL_COLUMNS,
ROLE_SORTABLE_COLUMNS,
RoleError,
RoleFilter,
RoleInfo,
RoleList,
serialize_role_object,
)
logger = logging.getLogger(__name__)
_DEFAULT_LIST_ROLES_REQUEST = ListRolesRequest()
@tool(
tags=["core"],
class_permission_name="Role",
annotations=ToolAnnotations(
title="List roles",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_roles(
request: ListRolesRequest | None = None,
ctx: Context | None = None,
) -> RoleList | RoleError:
"""List roles with filtering and search. Admin only.
Returns role metadata including id and name.
Sortable columns for order_column: id, name
"""
if ctx is None:
raise RuntimeError("FastMCP context is required for list_roles")
request = request or _DEFAULT_LIST_ROLES_REQUEST.model_copy(deep=True)
await ctx.info(
"Listing roles: page=%s, page_size=%s, search=%s"
% (request.page, request.page_size, request.search)
)
await ctx.debug(
"Role listing parameters: filters=%s, order_column=%s, order_direction=%s"
% (request.filters, request.order_column, request.order_direction)
)
try:
from superset.daos.role import RoleDAO
def _serialize_role(obj: Any, _cols: list[str] | None) -> RoleInfo | None:
return serialize_role_object(obj)
list_tool = ModelListCore(
dao_class=RoleDAO,
output_schema=RoleInfo,
item_serializer=_serialize_role,
filter_type=RoleFilter,
default_columns=DEFAULT_ROLE_COLUMNS,
search_columns=["name"],
list_field_name="roles",
output_list_schema=RoleList,
all_columns=ROLE_ALL_COLUMNS,
sortable_columns=ROLE_SORTABLE_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_roles.query"):
result = list_tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column or "id",
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
count = len(result.roles) if hasattr(result, "roles") else 0
await ctx.info(
"Roles listed successfully: count=%s, total_count=%s, total_pages=%s"
% (
count,
getattr(result, "total_count", None),
getattr(result, "total_pages", None),
)
)
columns_to_filter = result.columns_requested
await ctx.debug(
"Applying field filtering via serialization context: columns=%s"
% (columns_to_filter,)
)
with event_logger.log_context(action="mcp.list_roles.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(
"Role listing failed: page=%s, page_size=%s, error=%s, error_type=%s"
% (request.page, request.page_size, str(e), type(e).__name__)
)
raise
@@ -0,0 +1,16 @@
# 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.
+269
View File
@@ -0,0 +1,269 @@
# 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.
"""
Pydantic schemas for saved query-related responses
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.privacy import filter_user_directory_fields
from superset.mcp_service.system.schemas import PaginationInfo
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
DEFAULT_SAVED_QUERY_COLUMNS = ["id", "label", "db_id", "schema", "uuid"]
SORTABLE_SAVED_QUERY_COLUMNS = [
"id",
"label",
"db_id",
"schema",
"changed_on",
"created_on",
]
ALL_SAVED_QUERY_COLUMNS = [
"id",
"label",
"db_id",
"schema",
"catalog",
"uuid",
"sql",
"description",
"changed_on",
"created_on",
"last_run",
]
class SavedQueryFilter(ColumnOperator):
"""
Filter object for saved query listing.
col: The column to filter on. Must be one of the allowed filter fields.
opr: The operator to use. Must be one of the supported operators.
value: The value to filter by (type depends on col and opr).
"""
col: Literal["label", "db_id", "schema", "catalog", "created_by_fk"] = Field(
...,
description="Column to filter on.",
)
opr: ColumnOperatorEnum = Field(
...,
description="Operator to use.",
)
value: str | int | float | bool | List[str | int | float | bool] = Field(
..., description="Value to filter by (type depends on col and opr)"
)
class SavedQueryInfo(BaseModel):
id: int | None = Field(None, description="Saved query ID")
uuid: str | None = Field(None, description="Saved query UUID")
label: str | None = Field(None, description="Saved query label/name")
sql: str | None = Field(None, description="SQL query text")
db_id: int | None = Field(None, description="Database connection ID")
schema: str | None = Field(None, description="Database schema name")
catalog: str | None = Field(None, description="Database catalog name")
description: str | None = Field(None, description="User-provided description")
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
)
created_on: str | datetime | None = Field(None, description="Creation timestamp")
last_run: str | datetime | None = Field(
None, description="Timestamp of last execution"
)
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
@model_serializer(mode="wrap")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
data = filter_user_directory_fields(serializer(self))
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
requested_fields = set(select_columns)
return {k: v for k, v in data.items() if k in requested_fields}
return data
class SavedQueryList(BaseModel):
saved_queries: List[SavedQueryInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: List[str] = Field(
default_factory=list,
description="Requested columns for the response",
)
columns_loaded: List[str] = Field(
default_factory=list,
description="Columns that were actually loaded for each saved query",
)
columns_available: List[str] = Field(
default_factory=list,
description="All columns available for selection via select_columns parameter",
)
sortable_columns: List[str] = Field(
default_factory=list,
description="Columns that can be used with order_column parameter",
)
filters_applied: List[SavedQueryFilter] = Field(
default_factory=list,
description="List of advanced filter dicts applied to the query.",
)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListSavedQueriesRequest(BaseModel):
"""Request schema for list_saved_queries."""
filters: Annotated[
List[SavedQueryFilter],
Field(
default_factory=list,
description="List of filter objects (column, operator, value). Each "
"filter is an object with 'col', 'opr', and 'value' "
"properties. Cannot be used together with 'search'.",
),
]
select_columns: Annotated[
List[str],
Field(
default_factory=list,
description="List of columns to select. Defaults to common columns if not "
"specified.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search string to match against saved query fields. "
"Cannot be used together with 'filters'.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(
default="desc", description="Direction to order results ('asc' or 'desc')"
),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number for pagination (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> List[SavedQueryFilter]:
"""Accept both JSON string and list of objects."""
return parse_json_or_model_list(v, SavedQueryFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> List[str]:
"""Accept JSON array, list, or comma-separated string."""
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListSavedQueriesRequest":
"""Prevent using both search and filters simultaneously."""
if self.search and self.filters:
raise ValueError(
"Cannot use both 'search' and 'filters' parameters simultaneously. "
"Use either 'search' for text-based searching across multiple fields, "
"or 'filters' for precise column-based filtering, but not both."
)
return self
class SavedQueryError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
class GetSavedQueryInfoRequest(BaseModel):
"""Request schema for get_saved_query_info with support for ID or UUID."""
identifier: Annotated[
int | str,
Field(description="Saved query identifier - can be numeric ID or UUID string"),
]
def serialize_saved_query_object(saved_query: Any) -> SavedQueryInfo | None:
if not saved_query:
return None
return SavedQueryInfo(
id=getattr(saved_query, "id", None),
uuid=str(getattr(saved_query, "uuid", ""))
if getattr(saved_query, "uuid", None)
else None,
label=getattr(saved_query, "label", None),
sql=getattr(saved_query, "sql", None),
db_id=getattr(saved_query, "db_id", None),
schema=getattr(saved_query, "schema", None),
catalog=getattr(saved_query, "catalog", None),
description=getattr(saved_query, "description", None),
changed_on=getattr(saved_query, "changed_on", None),
created_on=getattr(saved_query, "created_on", None),
last_run=getattr(saved_query, "last_run", None),
)
@@ -0,0 +1,24 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .get_saved_query_info import get_saved_query_info
from .list_saved_queries import list_saved_queries
__all__ = [
"list_saved_queries",
"get_saved_query_info",
]
@@ -0,0 +1,129 @@
# 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.
"""
Get saved query info FastMCP tool
This module contains the FastMCP tool for getting detailed information
about a specific saved SQL query.
"""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelGetInfoCore
from superset.mcp_service.saved_query.schemas import (
GetSavedQueryInfoRequest,
SavedQueryError,
SavedQueryInfo,
serialize_saved_query_object,
)
logger = logging.getLogger(__name__)
@tool(
tags=["discovery"],
class_permission_name="SavedQuery",
annotations=ToolAnnotations(
title="Get saved query info",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_saved_query_info(
request: GetSavedQueryInfoRequest, ctx: Context
) -> SavedQueryInfo | SavedQueryError:
"""Get saved query details by ID or UUID.
Returns the full saved query including SQL text, label, database,
schema, and timestamps.
IMPORTANT FOR LLM CLIENTS:
- Use numeric ID (e.g., 42) or UUID string (e.g., "a1b2c3d4-...")
- To find a saved query ID, use the list_saved_queries tool first
Example usage:
```json
{
"identifier": 42
}
```
Or with UUID:
```json
{
"identifier": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}
```
"""
await ctx.info(
"Retrieving saved query information: identifier=%s" % (request.identifier,)
)
try:
from superset.daos.query import SavedQueryDAO
with event_logger.log_context(action="mcp.get_saved_query_info.lookup"):
get_tool = ModelGetInfoCore(
dao_class=SavedQueryDAO,
output_schema=SavedQueryInfo,
error_schema=SavedQueryError,
serializer=serialize_saved_query_object,
supports_slug=False,
logger=logger,
)
result = get_tool.run_tool(request.identifier)
if isinstance(result, SavedQueryInfo):
await ctx.info(
"Saved query information retrieved successfully: "
"saved_query_id=%s, label=%s, db_id=%s"
% (
result.id,
result.label,
result.db_id,
)
)
else:
await ctx.warning(
"Saved query retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"Saved query information retrieval failed: identifier=%s, error=%s, "
"error_type=%s"
% (
request.identifier,
str(e),
type(e).__name__,
)
)
return SavedQueryError(
error="Failed to get saved query info",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)
@@ -0,0 +1,158 @@
# 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.
"""
List saved queries FastMCP tool
This module contains the FastMCP tool for listing saved SQL queries
with filtering, search, and pagination.
"""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import ModelListCore
from superset.mcp_service.saved_query.schemas import (
DEFAULT_SAVED_QUERY_COLUMNS,
ListSavedQueriesRequest,
SavedQueryError,
SavedQueryFilter,
SavedQueryInfo,
SavedQueryList,
serialize_saved_query_object,
SORTABLE_SAVED_QUERY_COLUMNS,
)
logger = logging.getLogger(__name__)
_DEFAULT_LIST_SAVED_QUERIES_REQUEST = ListSavedQueriesRequest()
@tool(
tags=["core"],
class_permission_name="SavedQuery",
annotations=ToolAnnotations(
title="List saved queries",
readOnlyHint=True,
destructiveHint=False,
),
)
async def list_saved_queries(
request: ListSavedQueriesRequest | None = None,
ctx: Context | None = None,
) -> SavedQueryList | SavedQueryError:
"""List saved SQL queries with filtering and search.
Returns saved queries owned by the current user, including label, SQL,
database ID, and schema.
Sortable columns for order_column: id, label, db_id, schema,
changed_on, created_on
"""
if ctx is None:
raise RuntimeError("FastMCP context is required for list_saved_queries")
request = request or _DEFAULT_LIST_SAVED_QUERIES_REQUEST.model_copy(deep=True)
await ctx.info(
"Listing saved queries: page=%s, page_size=%s, search=%s"
% (
request.page,
request.page_size,
request.search,
)
)
await ctx.debug(
"Saved query listing parameters: filters=%s, order_column=%s, "
"order_direction=%s, select_columns=%s"
% (
request.filters,
request.order_column,
request.order_direction,
request.select_columns,
)
)
try:
from superset.daos.query import SavedQueryDAO
def _serialize_saved_query(
obj: object, cols: list[str] | None
) -> SavedQueryInfo | None:
return serialize_saved_query_object(obj)
list_tool = ModelListCore(
dao_class=SavedQueryDAO,
output_schema=SavedQueryInfo,
item_serializer=_serialize_saved_query,
filter_type=SavedQueryFilter,
default_columns=DEFAULT_SAVED_QUERY_COLUMNS,
search_columns=["label", "description", "sql"],
list_field_name="saved_queries",
output_list_schema=SavedQueryList,
all_columns=list(SavedQueryInfo.model_fields.keys()),
sortable_columns=SORTABLE_SAVED_QUERY_COLUMNS,
logger=logger,
)
with event_logger.log_context(action="mcp.list_saved_queries.query"):
result = list_tool.run_tool(
filters=request.filters,
search=request.search,
select_columns=request.select_columns,
order_column=request.order_column,
order_direction=request.order_direction,
page=max(request.page - 1, 0),
page_size=request.page_size,
)
await ctx.info(
"Saved queries listed successfully: count=%s, total_count=%s, "
"total_pages=%s"
% (
len(result.saved_queries) if hasattr(result, "saved_queries") else 0,
getattr(result, "total_count", None),
getattr(result, "total_pages", None),
)
)
columns_to_filter = result.columns_requested
await ctx.debug(
"Applying field filtering via serialization context: columns=%s"
% (columns_to_filter,)
)
with event_logger.log_context(action="mcp.list_saved_queries.serialization"):
return result.model_dump(
mode="json",
context={"select_columns": columns_to_filter},
)
except Exception as e:
await ctx.error(
"Saved query listing failed: page=%s, page_size=%s, error=%s, "
"error_type=%s"
% (
request.page,
request.page_size,
str(e),
type(e).__name__,
)
)
raise
+58
View File
@@ -32,6 +32,7 @@ from fastmcp.exceptions import ToolError
from fastmcp.server.middleware import Middleware
from superset.mcp_service.app import create_mcp_app, init_fastmcp_server
from superset.mcp_service.jwt_verifier import BrowserHelloMiddleware
from superset.mcp_service.mcp_config import (
get_mcp_factory_config,
MCP_STORE_CONFIG,
@@ -717,6 +718,53 @@ def build_middleware_list() -> list[Middleware]:
]
def _build_starlette_middleware(
flask_app: Any | None = None, auth_provider: Any | None = None
) -> list[Any]:
from starlette.middleware import Middleware as StarletteMiddleware
if flask_app is None:
from superset.mcp_service.flask_singleton import get_flask_app
flask_app = get_flask_app()
# Auth is active only when an instantiated provider was passed in.
# Config-flag presence is not sufficient — MCP_AUTH_FACTORY may return
# None, and use_factory_config auth lives outside Flask config entirely.
auth_enabled = auth_provider is not None
app_name: str = flask_app.config.get("APP_NAME", "Superset")
app_icon: str = flask_app.config.get("APP_ICON", "")
base_page_config: dict[str, Any] = {
"title": f"{app_name} MCP Server",
"server_key": app_name.lower().replace(" ", "-"),
"app_name": app_name,
}
if app_icon:
if app_icon.startswith(("http://", "https://")):
base_page_config["logo_url"] = app_icon
elif app_icon.startswith("/"):
# Relative path — combine with Superset webserver address if configured
superset_addr = flask_app.config.get(
"SUPERSET_WEBSERVER_ADDRESS", ""
).rstrip("/")
if superset_addr:
base_page_config["logo_url"] = f"{superset_addr}{app_icon}"
mcp_hello_page = flask_app.config.get("MCP_HELLO_PAGE")
if mcp_hello_page is not None and not isinstance(mcp_hello_page, dict):
logger.warning(
"MCP_HELLO_PAGE must be a dict, ignoring value of type %s",
type(mcp_hello_page).__name__,
)
mcp_hello_page = None
page_config: dict[str, Any] = {**base_page_config, **(mcp_hello_page or {})}
return [
StarletteMiddleware(
BrowserHelloMiddleware,
auth_enabled=auth_enabled,
page_config=page_config,
)
]
def run_server(
host: str = "127.0.0.1",
port: int = 5008,
@@ -750,6 +798,9 @@ def run_server(
logging.info("Creating MCP app from factory configuration...")
factory_config = get_mcp_factory_config()
mcp_instance = create_mcp_app(**factory_config)
# Capture the actual auth object so the hello page reflects real auth state
auth_provider = factory_config.get("auth")
flask_app = None
# Apply tool search transform if configured
tool_search_config = MCP_TOOL_SEARCH_CONFIG
@@ -794,6 +845,11 @@ def run_server(
# Create EventStore for session management (Redis for multi-pod, None for in-memory)
event_store = create_event_store(event_store_config)
starlette_middleware = _build_starlette_middleware(
flask_app=flask_app,
auth_provider=auth_provider,
)
env_key = f"FASTMCP_RUNNING_{port}"
if not os.environ.get(env_key):
os.environ[env_key] = "1"
@@ -807,6 +863,7 @@ def run_server(
transport="streamable-http",
event_store=event_store,
stateless_http=True,
middleware=starlette_middleware,
)
uvicorn.run(app, host=host, port=port)
else:
@@ -817,6 +874,7 @@ def run_server(
host=host,
port=port,
stateless_http=True,
middleware=starlette_middleware,
)
except Exception as e:
logging.error("FastMCP failed: %s", e)
+90 -2
View File
@@ -30,10 +30,15 @@ from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.auth import MCPPermissionDeniedError
from superset.mcp_service.common.schema_discovery import (
CHART_DEFAULT_COLUMNS,
CHART_SEARCH_COLUMNS,
CHART_SORTABLE_COLUMNS,
CSS_TEMPLATE_DEFAULT_COLUMNS,
CSS_TEMPLATE_FILTER_COLUMNS,
CSS_TEMPLATE_SEARCH_COLUMNS,
CSS_TEMPLATE_SORTABLE_COLUMNS,
DASHBOARD_DEFAULT_COLUMNS,
DASHBOARD_SEARCH_COLUMNS,
DASHBOARD_SORTABLE_COLUMNS,
@@ -44,12 +49,18 @@ from superset.mcp_service.common.schema_discovery import (
DATASET_SEARCH_COLUMNS,
DATASET_SORTABLE_COLUMNS,
get_chart_columns,
get_css_template_columns,
get_dashboard_columns,
get_database_columns,
get_dataset_columns,
get_theme_columns,
GetSchemaRequest,
GetSchemaResponse,
ModelSchemaInfo,
THEME_DEFAULT_COLUMNS,
THEME_FILTER_COLUMNS,
THEME_SEARCH_COLUMNS,
THEME_SORTABLE_COLUMNS,
)
from superset.mcp_service.constants import ModelType
from superset.mcp_service.mcp_core import ModelGetSchemaCore
@@ -144,6 +155,44 @@ def _get_database_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
)
def _get_css_template_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
"""Create CSS template schema core with dynamically extracted columns."""
from superset.daos.css import CssTemplateDAO
return ModelGetSchemaCore(
model_type="css_template",
dao_class=CssTemplateDAO,
output_schema=ModelSchemaInfo,
select_columns=get_css_template_columns(),
sortable_columns=CSS_TEMPLATE_SORTABLE_COLUMNS,
default_columns=CSS_TEMPLATE_DEFAULT_COLUMNS,
search_columns=CSS_TEMPLATE_SEARCH_COLUMNS,
default_sort="changed_on",
default_sort_direction="desc",
filter_columns_override=CSS_TEMPLATE_FILTER_COLUMNS,
logger=logger,
)
def _get_theme_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
"""Create theme schema core with dynamically extracted columns."""
from superset.daos.theme import ThemeDAO
return ModelGetSchemaCore(
model_type="theme",
dao_class=ThemeDAO,
output_schema=ModelSchemaInfo,
select_columns=get_theme_columns(),
sortable_columns=THEME_SORTABLE_COLUMNS,
default_columns=THEME_DEFAULT_COLUMNS,
search_columns=THEME_SEARCH_COLUMNS,
default_sort="changed_on",
default_sort_direction="desc",
filter_columns_override=THEME_FILTER_COLUMNS,
logger=logger,
)
# Map model types to their core factory functions
_SCHEMA_CORE_FACTORIES: dict[
ModelType,
@@ -153,12 +202,25 @@ _SCHEMA_CORE_FACTORIES: dict[
"dataset": _get_dataset_schema_core,
"dashboard": _get_dashboard_schema_core,
"database": _get_database_schema_core,
"css_template": _get_css_template_schema_core,
"theme": _get_theme_schema_core,
}
# Maps each model type to the FAB class permission name used by its tools.
# Used for per-model-type inline RBAC checks instead of a single static
# class_permission_name on the @tool decorator.
_MODEL_TYPE_CLASS_PERMISSION: dict[ModelType, str] = {
"chart": "Chart",
"dataset": "Dataset",
"dashboard": "Dashboard",
"database": "Database",
"css_template": "CssTemplate",
"theme": "Theme",
}
@tool(
tags=["discovery"],
class_permission_name="Dataset",
annotations=ToolAnnotations(
title="Get schema",
readOnlyHint=True,
@@ -182,13 +244,39 @@ async def get_schema(
Column metadata is extracted dynamically from SQLAlchemy models.
Args:
model_type: One of "chart", "dataset", "dashboard", or "database"
model_type: One of "chart", "dataset", "dashboard", "database",
"css_template", or "theme"
Returns:
Comprehensive schema information for the requested model type
"""
await ctx.info(f"Getting schema for model_type={request.model_type}")
# Per-model-type RBAC check (replaces the static class_permission_name on @tool,
# which wrongly gated all schema types behind Dataset permission).
class_permission = _MODEL_TYPE_CLASS_PERMISSION.get(request.model_type)
if class_permission:
from flask import current_app, g
from superset import security_manager
if current_app.config.get("MCP_RBAC_ENABLED", True) and not (
security_manager.can_access("can_read", class_permission)
):
user_str = getattr(getattr(g, "user", None), "username", None)
logger.warning(
"get_schema RBAC denied: user=%s type=%s view=%s",
user_str,
request.model_type,
class_permission,
)
raise MCPPermissionDeniedError(
permission_name="can_read",
view_name=class_permission,
user=user_str,
tool_name="get_schema",
)
can_view_data_model_metadata = user_can_view_data_model_metadata()
if not can_view_data_model_metadata and request.model_type in {
"dataset",
+16
View File
@@ -0,0 +1,16 @@
# 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.
+251
View File
@@ -0,0 +1,251 @@
# 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.
"""
Pydantic schemas for tag-related responses
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
PositiveInt,
)
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.mcp_service.system.schemas import (
PaginationInfo,
TagInfo as BaseTagInfo,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
from superset.mcp_service.utils.sanitization import sanitize_for_llm_context
from superset.mcp_service.utils.schema_utils import (
parse_json_or_list,
parse_json_or_model_list,
)
class TagFilter(ColumnOperator):
"""
Filter object for tag listing.
col: The column to filter on. Must be one of the allowed filter fields.
opr: The operator to use. Must be one of the supported operators.
value: The value to filter by (type depends on col and opr).
"""
col: Literal["name", "type"] = Field(
...,
description="Column to filter on. Supported: 'name' (string match), "
"'type' (tag type: custom, type, owner, favorited_by).",
)
opr: ColumnOperatorEnum = Field(
...,
description="Operator to use. Common operators: 'eq' (equals), "
"'ct' (contains), 'sw' (starts with), 'ew' (ends with).",
)
value: str | int | float | bool | List[str | int | float | bool] = Field(
..., description="Value to filter by (type depends on col and opr)"
)
class TagInfo(BaseTagInfo):
"""Extends the shared BaseTagInfo with audit timestamps for MCP list/get tools."""
changed_on: str | datetime | None = Field(
None, description="Last modification timestamp"
)
changed_on_humanized: str | None = Field(
None, description="Humanized modification time"
)
created_on: str | datetime | None = Field(None, description="Creation timestamp")
created_on_humanized: str | None = Field(
None, description="Humanized creation time"
)
model_config = ConfigDict(
from_attributes=True,
ser_json_timedelta="iso8601",
populate_by_name=True,
)
@model_serializer(mode="wrap")
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
"""Filter serialized fields to those requested via select_columns context."""
data: Dict[str, Any] = serializer(self)
if info.context and isinstance(info.context, dict):
select_columns = info.context.get("select_columns")
if select_columns:
requested_fields = set(select_columns)
return {k: v for k, v in data.items() if k in requested_fields}
return data
class TagList(BaseModel):
tags: List[TagInfo]
count: int
total_count: int
page: int
page_size: int
total_pages: int
has_previous: bool
has_next: bool
columns_requested: List[str] = Field(default_factory=list)
columns_loaded: List[str] = Field(default_factory=list)
columns_available: List[str] = Field(default_factory=list)
sortable_columns: List[str] = Field(default_factory=list)
filters_applied: List[TagFilter] = Field(default_factory=list)
pagination: PaginationInfo | None = None
timestamp: datetime | None = None
model_config = ConfigDict(ser_json_timedelta="iso8601")
class ListTagsRequest(BaseModel):
"""Request schema for list_tags."""
filters: Annotated[
List[TagFilter],
Field(
default_factory=list,
description="List of filter objects (column, operator, value). Each "
"filter has 'col', 'opr', and 'value' properties. Cannot be used "
"together with 'search'.",
),
]
select_columns: Annotated[
List[str],
Field(
default_factory=list,
description="List of columns to select. Defaults to common columns if not "
"specified.",
),
]
search: Annotated[
str | None,
Field(
default=None,
description="Text search string to match against tag name. Cannot be used "
"together with 'filters'.",
),
]
order_column: Annotated[
str | None, Field(default=None, description="Column to order results by")
]
order_direction: Annotated[
Literal["asc", "desc"],
Field(
default="desc",
description="Direction to order results ('asc' or 'desc')",
),
]
page: Annotated[
PositiveInt,
Field(default=1, description="Page number for pagination (1-based)"),
]
page_size: Annotated[
int,
Field(
default=DEFAULT_PAGE_SIZE,
gt=0,
le=MAX_PAGE_SIZE,
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
),
]
@field_validator("filters", mode="before")
@classmethod
def parse_filters(cls, v: Any) -> List[TagFilter]:
return parse_json_or_model_list(v, TagFilter, "filters")
@field_validator("select_columns", mode="before")
@classmethod
def parse_columns(cls, v: Any) -> List[str]:
return parse_json_or_list(v, "select_columns")
@model_validator(mode="after")
def validate_search_and_filters(self) -> "ListTagsRequest":
if self.search and self.filters:
raise ValueError(
"Cannot use both 'search' and 'filters' parameters simultaneously. "
"Use either 'search' for text-based searching or 'filters' for "
"precise column-based filtering, but not both."
)
return self
class TagError(BaseModel):
error: str = Field(..., description="Error message")
error_type: str = Field(..., description="Type of error")
timestamp: str | datetime | None = Field(None, description="Error timestamp")
model_config = ConfigDict(ser_json_timedelta="iso8601")
@classmethod
def create(cls, error: str, error_type: str) -> "TagError":
from datetime import timezone
return cls(
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
)
class GetTagInfoRequest(BaseModel):
"""Request schema for get_tag_info with numeric ID."""
identifier: Annotated[
int,
Field(description="Tag identifier — numeric ID"),
]
def _sanitize_tag_info_for_llm_context(tag_info: TagInfo) -> TagInfo:
"""Wrap user-controlled tag fields before LLM exposure."""
payload = tag_info.model_dump(mode="python")
for field_name in ("name", "description"):
payload[field_name] = sanitize_for_llm_context(
payload.get(field_name),
field_path=(field_name,),
)
return TagInfo(**payload)
def serialize_tag_object(tag: Any) -> TagInfo | None:
if not tag:
return None
type_str: str | None = None
if (raw_type := getattr(tag, "type", None)) is not None:
type_str = raw_type.name if hasattr(raw_type, "name") else str(raw_type)
return _sanitize_tag_info_for_llm_context(
TagInfo(
id=getattr(tag, "id", None),
name=getattr(tag, "name", None),
type=type_str,
description=getattr(tag, "description", None),
changed_on=getattr(tag, "changed_on", None),
changed_on_humanized=humanize_timestamp(getattr(tag, "changed_on", None)),
created_on=getattr(tag, "created_on", None),
created_on_humanized=humanize_timestamp(getattr(tag, "created_on", None)),
)
)
+24
View File
@@ -0,0 +1,24 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .get_tag_info import get_tag_info
from .list_tags import list_tags
__all__ = [
"get_tag_info",
"list_tags",
]

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