This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 0228dad5b chore(studio): 3.0.0 release readiness (#5007)
0228dad5b is described below

commit 0228dad5b9c9460f3e18c5c3e2b56c6525856198
Author: lizhimins <[email protected]>
AuthorDate: Wed Sep 23 11:06:35 2026 +0800

    chore(studio): 3.0.0 release readiness (#5007)
    
    License compliance:
    - Fix root/server/web NOTICE (copyright holder) and LICENSE (restore the
      appendix placeholder), add vendored LobeHub icon attribution
    - mysql-connector-j: scope=provided + spring-boot-maven-plugin exclude,
      keeping the GPL connector out of the distributed fat JAR
    - Dockerfile downloads the connector at image build time and loads it
      via LOADER_PATH; only the Dockerfile text is distributed, not the
      connector binary
    - AI CLI packages (claude-code, qodercli): obtained from npm at user
      build time, not redistributed; remove the blocking legal.py npm gate
    - server/scripts/legal.py: JAR license gate (GPL detection + completeness)
    - rmqctl/scripts/license-binary.go: Go binary license collection
    - web/scripts/licenses.mjs: Vite plugin for distribution license manifest
    
    CI & test stability:
    - web/src/test/setup.ts: await antd unmount in afterEach to fix leaked
      async React root errors; add setup.test.ts
    - rmqctl: regenerate catalog_gen.go for catalog-verify gate
    - ci.yml: frontend lint/test/license-test + rmqctl catalog gate
    
    Docs & version:
    - README: simplify quick start, add AI setup prompt, drop cross-cloud
    - Bump version to 3.0.0 (server, web, rmqctl, About page)
    - Dockerfile: reformat with stage separators and aligned comments
---
 .github/workflows/ci.yml                           |  38 ++-
 .gitignore                                         |   4 +
 LICENSE                                            |  15 +-
 NOTICE                                             |  11 +-
 README.md                                          |  62 ++++-
 README_zh.md                                       |  59 +++-
 rmqctl/Makefile                                    |  25 +-
 rmqctl/cmd/app.go                                  |   2 +-
 rmqctl/internal/catalog/catalog_gen.go             |   6 +-
 rmqctl/scripts/license-binary.go                   | 281 +++++++++++++++++++
 rmqctl/scripts/license-binary_test.go              | 129 +++++++++
 rmqctl/scripts/package-release.sh                  |  47 +++-
 server/Dockerfile                                  | 154 +++++++---
 server/LICENSE                                     |   2 +-
 server/NOTICE                                      |   6 +-
 server/pom.xml                                     |  83 +++++-
 server/scripts/legal.py                            | 310 +++++++++++++++++++++
 server/scripts/legal_test.py                       | 114 ++++++++
 web/Dockerfile                                     |  18 ++
 web/LICENSE                                        |  15 +-
 web/NOTICE                                         |  11 +-
 web/licenses/toggle-selection-1.0.6/LICENSE        |  21 ++
 web/package-lock.json                              |   4 +-
 web/package.json                                   |   6 +-
 web/scripts/licenses.d.mts                         |  18 ++
 web/scripts/licenses.mjs                           | 247 ++++++++++++++++
 web/scripts/licenses.test.mjs                      |  98 +++++++
 web/src/assets/model-logos/LICENSE                 |  25 +-
 .../pages/instance/__tests__/ConsumerPage.test.tsx |  12 +-
 web/src/pages/settings/AboutTab.tsx                |   4 +-
 web/src/test/setup.test.ts                         | 134 +++++++++
 web/src/test/setup.ts                              |  60 +++-
 web/vite.config.ts                                 |  21 +-
 33 files changed, 1905 insertions(+), 137 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 29fa34548..21f78d279 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,13 +1,30 @@
+# 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.
+
 name: CI
 
-# 检测前端与后端是否都能正确编译
+# Verify the build, tests, and generated artifacts for the frontend, backend, 
and CLI.
 on:
   push:
     branches:
       - master
+      - rocketmq-studio
   pull_request:
     branches:
       - master
+      - rocketmq-studio
 
 concurrency:
   group: ci-${{ github.ref }}
@@ -72,8 +89,20 @@ jobs:
           cache-dependency-path: web/package-lock.json
 
       - name: Install dependencies
+        # Keep the dependency install scripts, but forbid husky in prepare 
from modifying the Git hooks config.
+        env:
+          HUSKY: "0"
         run: npm ci
 
+      - name: Lint frontend
+        run: npm run lint
+
+      - name: Run all frontend tests
+        run: npm test
+
+      - name: Run frontend license tests
+        run: npm run license:test
+
       - name: Build frontend
         run: npm run build
 
@@ -115,9 +144,7 @@ jobs:
       - name: Check formatting
         run: make fmt
 
-      # catalog-verify regenerates internal/catalog/catalog_gen.go from
-      # ../server/src/main/resources/tool-catalog/tools and fails on drift, so 
the
-      # full repository (rmqctl/ + server/) must be checked out.
+      # Check whether the Go catalog is stale against the server-side YAML, so 
the full repository must be checked out.
       - name: Verify generated tool catalog
         run: make catalog-verify
 
@@ -129,3 +156,6 @@ jobs:
 
       - name: Build rmqctl binary
         run: make build
+
+      - name: Build rmqctl for all supported platforms
+        run: make build-all
diff --git a/.gitignore b/.gitignore
index 5bf7c7804..af963fc2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,10 @@ dist/
 target/
 rmqctl/bin/
 
+# Python artifacts
+__pycache__/
+*.pyc
+
 # Dependencies
 node_modules/
 
diff --git a/LICENSE b/LICENSE
index c8aa782be..438d98cb0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
       understanding of the Apache License by reading the FAQ at
       http://www.apache.org/foundation/license-faq.html
 
-   Copyright 2026 RocketMQ Studio Contributors
+   Copyright [yyyy] [name of copyright owner]
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
@@ -199,3 +199,16 @@
    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.
+
+Third-party source materials
+===========================
+This source distribution includes 17 model brand SVG icons from
+@lobehub/icons-static-svg 1.95.0 (https://github.com/lobehub/lobe-icons).
+They are licensed under MIT; the complete upstream license is reproduced in
+web/src/assets/model-logos/LICENSE. Copyright (c) 2023 LobeHub.
+Upstream license: https://github.com/lobehub/lobe-icons/blob/v1.95.0/LICENSE
+This does not grant rights to third-party trademarks.
+
+Dependencies downloaded during builds are not bundled in this source tree.
+Binary distributions must additionally include the licenses and required
+notices of their actually bundled dependencies, generated during packaging.
diff --git a/NOTICE b/NOTICE
index fde8f8e31..7f0bec17a 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,10 @@
-RocketMQ Studio
-Copyright 2026 RocketMQ Studio Contributors
+Apache RocketMQ Studio
+Copyright 2026 The Apache Software Foundation
 
 This product includes software developed at
-The Apache Software Foundation (http://www.apache.org/).
+The Apache Software Foundation (https://www.apache.org/).
+
+Third-party source materials
+===========================
+Model brand icons from LobeHub (https://github.com/lobehub/lobe-icons):
+Copyright (c) 2023 LobeHub.
diff --git a/README.md b/README.md
index 93c065851..57d2b9087 100644
--- a/README.md
+++ b/README.md
@@ -2,26 +2,47 @@
 
 **English** | [中文](README_zh.md)
 
-> Cross-cluster · Cross-architecture · Cross-cloud unified RocketMQ management 
platform
+> Cross-cluster · Cross-architecture unified RocketMQ management platform
 
-RocketMQ Studio is a unified management platform for RocketMQ, supporting 
multi-cluster, multi-architecture, and multi-cloud environments. It provides 
instance management, cluster operations, Topic / Consumer Group CRUD, ACL 
permission control, message query and tracing, dead letter queue handling, 
monitoring alerts, audit logs, and an AI assistant.
+RocketMQ Studio is a unified management platform for RocketMQ, supporting 
multi-cluster and multi-architecture environments. It provides instance 
management, cluster operations, Topic / Consumer Group CRUD, ACL permission 
control, message query and tracing, dead letter queue handling, monitoring 
alerts, audit logs, and an AI assistant.
+
+## Start with AI
+
+You can use an AI coding agent (Claude Code, Qoder, Cursor, etc.) to set up 
and launch
+Studio. Open the agent at the repository root and paste the prompt below:
+
+```text
+Set up and run RocketMQ Studio locally from this repository:
+1. Check that Docker and Docker Compose are installed. If not, tell me how to 
install them and stop.
+2. Create the shared network: `docker network create rocketmq_net` (ignore the 
error if it already exists).
+3. Build and start Studio: `docker compose -f deploy/docker-compose.yml up -d 
--build`.
+4. Wait for the backend to become healthy, then open http://127.0.0.1:6789 and 
confirm the page loads.
+Report the running containers and any errors. Do not modify source code or 
create commits.
+```
+
+To also start the optional bundled RocketMQ demo cluster, insert
+`docker compose -f deploy/rocketmq/docker-compose.yml up -d` before step 3 and 
wait until its
+`ps` output is healthy.
 
 ## Quick Start
 
+Run from the repository root:
+
 ```bash
-(docker network create rocketmq_net 2>/dev/null || true) && docker compose -f 
deploy/rocketmq/docker-compose.yml up -d && docker compose -f 
deploy/docker-compose.yml up -d --build
+# 1. Create the shared Docker network (the compose files attach to it as 
external)
+docker network create rocketmq_net
+
+# 2. Build and start RocketMQ Studio (mysql + rocketmq-server + rocketmq-web)
+docker compose -f deploy/docker-compose.yml up -d --build
 ```
 
-Visit **http://127.0.0.1:6789** after startup.
+Then visit **http://127.0.0.1:6789**.
+
+This starts only Studio and its own MySQL database — enough to open the 
console, log in, and explore
+the UI. No RocketMQ cluster is bundled here; register an instance pointing at 
your own RocketMQ to
+manage real resources, or start the optional demo cluster in
+[Bundled RocketMQ Cluster](#bundled-rocketmq-cluster-optional) below.
 
-Run from the repository root. The leading `docker network create` is required
-because both compose files declare `rocketmq_net` as `external` and neither
-creates it. The first compose file starts the bundled RocketMQ topology
-(nameserver, broker-0/broker-1, proxy, producer/consumer load rig,
-prometheus); the second builds and starts the Studio stack (mysql,
-rocketmq-server, rocketmq-web). Check the topology is healthy with
-`docker compose -f deploy/rocketmq/docker-compose.yml ps` before starting
-Studio.
 The default schema creates only Studio tables. It does not seed instances, 
topics, consumer groups, or ACL
 records. Development-only sample data can be imported explicitly from 
`deploy/mysql/`; it is not part of the
 default deployment. Import `upgrade-demo-instance.sql` first and then 
`upgrade-demo-acl.sql`; both scripts
@@ -30,8 +51,6 @@ and should never be imported into a production database.
 
 **Studio ports:** Frontend 6789 (Nginx), Backend 8888 (Spring Boot)
 
-**RocketMQ ports:** NameServer 9876, Broker 10911, Proxy Remoting 8080, Proxy 
gRPC 8081
-
 To enable login protection for a shared environment, copy 
`deploy/.env.example` to
 `deploy/.env`, set `STUDIO_AUTH_LOGIN_REQUIRED=true`, and configure
 `STUDIO_AUTH_ADMIN_USERNAME` / `STUDIO_AUTH_ADMIN_PASSWORD`. These configured 
credentials are a
@@ -42,6 +61,21 @@ user-management page; browsers authenticate with an 
`HttpOnly` session cookie, a
 request a bearer token explicitly. Disabling login protection only skips API 
interception for local
 development.
 
+## Bundled RocketMQ Cluster (Optional)
+
+The Quick Start above runs only Studio and its database. To try it against a 
ready-made RocketMQ,
+start the bundled demo cluster — nameserver, two brokers, proxy, a 
producer/consumer load generator,
+and Prometheus — on the same `rocketmq_net` network:
+
+```bash
+docker compose -f deploy/rocketmq/docker-compose.yml up -d
+```
+
+Confirm it is healthy with `docker compose -f 
deploy/rocketmq/docker-compose.yml ps`. Studio reaches
+the cluster at `nameserver:9876` over the shared network (the backend default).
+
+**RocketMQ ports:** NameServer 9876, Broker 10911, Proxy Remoting 8080, Proxy 
gRPC 8081
+
 ## Screenshots
 
 **Dashboard · AI chat** — a single entry point that switches between AI chat, 
cluster diagnosis, resource management, and message query, with model selection 
and MCP tool calls.
diff --git a/README_zh.md b/README_zh.md
index 535b7ac00..b08f58662 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -2,28 +2,46 @@
 
 [English](README.md) | **中文**
 
-> 跨集群 · 跨架构 · 跨云的 RocketMQ 统一管控平台
+> 跨集群 · 跨架构的 RocketMQ 统一管控平台
 
-RocketMQ Studio 是一个面向多集群、多架构、多云环境的 RocketMQ 管控平台,提供实例管理、集群运维、Topic / 消费组 
CRUD、ACL 权限管控、消息查询与轨迹追踪、死信队列处理、监控告警、审计日志以及 AI 智能助手等一站式能力。
+RocketMQ Studio 是一个面向多集群、多架构环境的 RocketMQ 管控平台,提供实例管理、集群运维、Topic / 消费组 CRUD、ACL 
权限管控、消息查询与轨迹追踪、死信队列处理、监控告警、审计日志以及 AI 智能助手等一站式能力。
 
-## 一键构建 & 运行
+## 用 AI 启动
 
-```bash
-(docker network create rocketmq_net 2>/dev/null || true) && docker compose -f 
deploy/rocketmq/docker-compose.yml up -d && docker compose -f 
deploy/docker-compose.yml up -d --build
+可以让 AI 编码 agent(Claude Code、Qoder、Cursor 等)帮你把 Studio 装好并跑起来。
+在仓库根目录打开 agent,把下面的提示词粘贴给它:
+
+```text
+从当前仓库在本地拉起并运行 RocketMQ Studio:
+1. 确认已安装 Docker 与 Docker Compose;若未安装,告诉我安装方式并停止。
+2. 创建共享网络:`docker network create rocketmq_net`(若已存在,忽略报错)。
+3. 构建并启动 Studio:`docker compose -f deploy/docker-compose.yml up -d --build`。
+4. 等待后端健康检查通过后,打开 http://127.0.0.1:6789 确认页面正常加载。
+最后报告运行中的容器与任何错误。不要修改源码,也不要提交任何改动。
 ```
 
-在仓库根目录执行。开头的 `docker network create` 必不可少:两个 compose 文件都把
-`rocketmq_net` 声明为 `external`,自身都不会创建它。第一个 compose 启动内置
-RocketMQ 拓扑(nameserver、broker-0/broker-1、proxy、producer/consumer 测试挂具、
-prometheus);第二个构建并启动 Studio 三件套(mysql、rocketmq-server、
-rocketmq-web)。启动 Studio 前可用
-`docker compose -f deploy/rocketmq/docker-compose.yml ps` 确认 RocketMQ 已就绪。
+如果还想启动可选的内置 RocketMQ 演示集群,在第 3 步之前插入
+`docker compose -f deploy/rocketmq/docker-compose.yml up -d`,并等待它的 `ps` 输出显示就绪。
+
+## 快速开始
+
+在仓库根目录执行:
+
+```bash
+# 1. 创建共享 Docker 网络(compose 文件以 external 方式引用它)
+docker network create rocketmq_net
+
+# 2. 构建并启动 RocketMQ Studio(mysql + rocketmq-server + rocketmq-web)
+docker compose -f deploy/docker-compose.yml up -d --build
+```
 
 启动后访问 **http://127.0.0.1:6789** 即可使用。
 
-**Studio 服务端口:** 前端 6789(Nginx)、后端 8888(Spring Boot)
+这一步只启动 Studio 和它自己的 MySQL 数据库,足以打开控制台、登录并浏览界面,不包含
+RocketMQ 集群。要管理真实资源,请注册一个指向你自己 RocketMQ 的实例,或启动下方的
+「内置 RocketMQ 集群(可选)」。
 
-**RocketMQ 服务端端口:** NameServer 9876、Broker 10911、Proxy Remoting 8080、Proxy 
gRPC 8081
+**Studio 服务端口:** 前端 6789(Nginx)、后端 8888(Spring Boot)
 
 共享环境可复制 `deploy/.env.example` 为 `deploy/.env`,设置
 `STUDIO_AUTH_LOGIN_REQUIRED=true`,并配置 `STUDIO_AUTH_ADMIN_USERNAME` /
@@ -33,6 +51,21 @@ rocketmq-web)。启动 Studio 前可用
 启用/停用、重置密码);浏览器使用 `HttpOnly` 会话 Cookie 认证,API 客户端
 可显式换取 bearer token。关闭登录保护只会跳过本地开发场景下的 API 拦截。
 
+## 内置 RocketMQ 集群(可选)
+
+上面的快速开始只跑了 Studio 和数据库。想连一个现成的 RocketMQ 试用,可在同一
+`rocketmq_net` 网络上启动内置的演示集群,包含 nameserver、两个 broker、proxy、producer/consumer
+压测客户端,以及 Prometheus:
+
+```bash
+docker compose -f deploy/rocketmq/docker-compose.yml up -d
+```
+
+用 `docker compose -f deploy/rocketmq/docker-compose.yml ps` 确认集群就绪。Studio 
通过共享网络
+以 `nameserver:9876`(后端默认值)访问它。
+
+**RocketMQ 服务端端口:** NameServer 9876、Broker 10911、Proxy Remoting 8080、Proxy 
gRPC 8081
+
 ## 界面预览
 
 **首页 · AI 对话** — 统一入口,可切换 AI 对话 / 集群诊断 / 资源管理 / 消息查询四类场景,支持多模型选择与 MCP 工具调用。
diff --git a/rmqctl/Makefile b/rmqctl/Makefile
index ec1c8c32f..0f8e1e601 100644
--- a/rmqctl/Makefile
+++ b/rmqctl/Makefile
@@ -13,8 +13,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+.DEFAULT_GOAL := build
 GO ?= go
-VERSION ?= 1.0.0
+VERSION ?= 3.0.0
 GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
 BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
 BUILD_DIR ?= $(CURDIR)/bin
@@ -32,7 +33,7 @@ CATALOG_GENERATOR := ./internal/catalog/generate
 CATALOG_MARKDOWN := ../docs/generated/rmq-tools.md
 CATALOG_SDK := ../docs/generated/rmq-tools.json
 
-.PHONY: build build-all package checksum release clean catalog-generate 
catalog-verify test test-race vet lint fmt verify ci completion install
+.PHONY: build build-all package checksum release clean catalog-generate 
catalog-verify test test-race vet lint fmt verify ci completion install 
license-binary
 
 catalog-generate:
        $(GO) run $(CATALOG_GENERATOR) -input-dir $(CATALOG_SOURCE) -output 
$(CATALOG_GENERATED) \
@@ -65,6 +66,8 @@ ci: fmt test-race vet build-all
 build: catalog-verify
        @mkdir -p $(BUILD_DIR)
        CGO_ENABLED=0 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/rmqctl 
main.go
+       $(GO) run scripts/license-binary.go -go "$(GO)" -binary 
$(BUILD_DIR)/rmqctl -output $(LEGAL_DIR)/native
+       $(GO) run scripts/license-binary.go -go "$(GO)" -binary 
$(BUILD_DIR)/rmqctl -output $(LEGAL_DIR)/native -check
 
 build-all: catalog-verify
        @mkdir -p $(BUILD_DIR)
@@ -74,18 +77,27 @@ build-all: catalog-verify
        CGO_ENABLED=0 GOOS=linux   GOARCH=arm64 $(GO) build -ldflags 
"$(LDFLAGS)" -o $(BUILD_DIR)/rmqctl-linux-arm64     main.go
        CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -ldflags 
"$(LDFLAGS)" -o $(BUILD_DIR)/rmqctl-windows-amd64.exe main.go
        CGO_ENABLED=0 GOOS=windows GOARCH=arm64 $(GO) build -ldflags 
"$(LDFLAGS)" -o $(BUILD_DIR)/rmqctl-windows-arm64.exe main.go
+       # Generate per target; Windows-only dependencies must not reuse the 
Linux license manifest.
+       @set -e; for target in $(TARGETS); do \
+               os=$${target%%_*}; arch=$${target##*_}; suffix=; \
+               if [ "$$os" = "windows" ]; then suffix=.exe; fi; \
+               $(GO) run scripts/license-binary.go -go "$(GO)" -binary 
$(BUILD_DIR)/rmqctl-$$os-$$arch$$suffix -output $(LEGAL_DIR)/$$target; \
+               $(GO) run scripts/license-binary.go -go "$(GO)" -binary 
$(BUILD_DIR)/rmqctl-$$os-$$arch$$suffix -output $(LEGAL_DIR)/$$target -check; \
+       done
+
+license-binary: build-all
 
-package: build-all
+package: license-binary
        @rm -rf $(PACKAGE_DIR)
        @mkdir -p $(PACKAGE_DIR)
-       @for target in $(TARGETS); do \
+       @set -e; for target in $(TARGETS); do \
                os=$${target%%_*}; arch=$${target##*_}; \
                if [ "$$os" = "windows" ]; then \
                        binary=$(BUILD_DIR)/rmqctl-$$os-$$arch.exe; \
                else \
                        binary=$(BUILD_DIR)/rmqctl-$$os-$$arch; \
                fi; \
-               bash scripts/package-release.sh "$$binary" "$$os" "$$arch" 
"$(VERSION)" "$(LEGAL_DIR)" "$(PACKAGE_DIR)"; \
+               GO="$(GO)" bash scripts/package-release.sh "$$binary" "$$os" 
"$$arch" "$(VERSION)" "$(LEGAL_DIR)/$$target" "$(PACKAGE_DIR)"; \
        done
 
 checksum: package
@@ -103,6 +115,9 @@ completion: build
 install: build
        @mkdir -p $(DESTDIR)/usr/local/bin
        cp $(BUILD_DIR)/rmqctl $(DESTDIR)/usr/local/bin/rmqctl
+       @mkdir -p $(DESTDIR)/usr/local/share/licenses/rmqctl/legal
+       cp $(LEGAL_DIR)/native/LICENSE $(LEGAL_DIR)/native/NOTICE 
$(DESTDIR)/usr/local/share/licenses/rmqctl/
+       cp -R $(LEGAL_DIR)/native/. 
$(DESTDIR)/usr/local/share/licenses/rmqctl/legal/
 
 clean:
        rm -rf $(BUILD_DIR)
diff --git a/rmqctl/cmd/app.go b/rmqctl/cmd/app.go
index fc1521cff..50968a202 100644
--- a/rmqctl/cmd/app.go
+++ b/rmqctl/cmd/app.go
@@ -29,7 +29,7 @@ import (
 )
 
 var (
-       CLIVersion = "1.0.0"
+       CLIVersion = "3.0.0"
        GitCommit  = ""
        BuildDate  = ""
 )
diff --git a/rmqctl/internal/catalog/catalog_gen.go 
b/rmqctl/internal/catalog/catalog_gen.go
index 622d4a59d..47ffa019c 100644
--- a/rmqctl/internal/catalog/catalog_gen.go
+++ b/rmqctl/internal/catalog/catalog_gen.go
@@ -21,7 +21,7 @@ package catalog
 var defaultDocument = Document{
        Version:              "2.0.0",
        MinimumClientVersion: "2.0.0",
-       Digest:               
"94c2164d0181f55aebc2f32ef1f3db2c0b92185e98ac7824e492b6f0e29014b9",
+       Digest:               
"30a907ada71734e87305982bcf29a3ff7c6f3ae050a2211f06e87c11b0424e2d",
        Tools: []Tool{
                {
                        Name:                 "rmq.acl.list",
@@ -582,7 +582,7 @@ var defaultDocument = Document{
                        Description:          "Discover Proxy data endpoints, 
optionally filtered by physical cluster name. Discovery does not establish 
Broker cluster membership or management capability.",
                        RiskLevel:            "L1",
                        Permission:           "proxy:read",
-                       RequiredCapabilities: []string{"PROXY_DISCOVERY"},
+                       RequiredCapabilities: []string{},
                        InputSchema: InputSchema{
                                Fields: []Field{
                                        {Name: "clusterName", Flag: 
"cluster-name", Description: "Physical RocketMQ cluster name.", Kind: 
StringField, MinLength: 1},
@@ -597,7 +597,7 @@ var defaultDocument = Document{
                        Description:          "Read the Proxy configuration and 
reachability snapshot for one physical cluster, with an optional addr filter 
(read-only; configuration reload is not exposed through MCP).",
                        RiskLevel:            "L1",
                        Permission:           "proxy:read",
-                       RequiredCapabilities: []string{"PROXY_DISCOVERY"},
+                       RequiredCapabilities: []string{},
                        InputSchema: InputSchema{
                                Fields: []Field{
                                        {Name: "clusterName", Flag: 
"cluster-name", Description: "Physical RocketMQ cluster name.", Kind: 
StringField, Required: true, MinLength: 1},
diff --git a/rmqctl/scripts/license-binary.go b/rmqctl/scripts/license-binary.go
new file mode 100644
index 000000000..dc6beeb56
--- /dev/null
+++ b/rmqctl/scripts/license-binary.go
@@ -0,0 +1,281 @@
+// 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.
+
+// Collect license texts driven by the target binary's Go buildinfo; do not 
treat modules in go.mod that are not linked in as distributed dependencies.
+package main
+
+import (
+       "bytes"
+       "crypto/sha256"
+       "debug/buildinfo"
+       "encoding/hex"
+       "encoding/json"
+       "flag"
+       "fmt"
+       "io"
+       "io/fs"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "regexp"
+       "sort"
+       "strings"
+)
+
+var legalName = 
regexp.MustCompile(`(?i)^(licen[sc]e|notice|copying|copyright|patents|authors)([._-].*)?$`)
+
+type component struct {
+       Name    string   `json:"name"`
+       Version string   `json:"version"`
+       Sum     string   `json:"sum,omitempty"`
+       Files   []string `json:"files"`
+}
+
+type inventory struct {
+       BinarySHA256 string            `json:"binarySha256"`
+       GOOS         string            `json:"goos"`
+       GOARCH       string            `json:"goarch"`
+       Components   []component       `json:"components"`
+       Files        map[string]string `json:"files"`
+}
+
+func must(err error) {
+       if err != nil {
+               fmt.Fprintln(os.Stderr, "license gate:", err)
+               os.Exit(1)
+       }
+}
+
+func read(path string) []byte {
+       data, err := os.ReadFile(path)
+       must(err)
+       if len(bytes.TrimSpace(data)) == 0 {
+               must(fmt.Errorf("empty license material: %s", path))
+       }
+       return data
+}
+
+func digest(data []byte) string {
+       sum := sha256.Sum256(data)
+       return hex.EncodeToString(sum[:])
+}
+
+func command(goCommand, cwd string, args ...string) []byte {
+       cmd := exec.Command(goCommand, args...)
+       cmd.Dir = cwd
+       cmd.Stderr = os.Stderr
+       out, err := cmd.Output()
+       must(err)
+       return out
+}
+
+func main() {
+       binary := flag.String("binary", "", "target Go binary")
+       output := flag.String("output", "", "license output directory")
+       root := flag.String("root", "..", "repository root")
+       goCommand := flag.String("go", "go", "Go command used for the build")
+       check := flag.Bool("check", false, "recompute and verify existing 
materials byte by byte without writing files")
+       expectedOS := flag.String("os", "", "expected target operating system")
+       expectedArch := flag.String("arch", "", "expected target architecture")
+       flag.Parse()
+       if *binary == "" || *output == "" {
+               flag.Usage()
+               os.Exit(2)
+       }
+       info, err := buildinfo.ReadFile(*binary)
+       must(err)
+       const ownModule = "github.com/apache/rocketmq-dashboard/rmqctl"
+       isOwn := info.Main.Path == ownModule
+       for _, dep := range info.Deps {
+               if dep.Path == ownModule && dep.Version == "(devel)" && 
dep.Replace == nil {
+                       isOwn = true
+               }
+       }
+       if !isOwn || len(info.Deps) == 0 {
+               must(fmt.Errorf("not an rmqctl binary with module info: %s", 
*binary))
+       }
+       cwd := filepath.Join(*root, "rmqctl")
+       var env struct{ GOROOT, GOVERSION string }
+       must(json.Unmarshal(command(*goCommand, cwd, "env", "-json", "GOROOT", 
"GOVERSION"), &env))
+       if info.GoVersion != env.GOVERSION {
+               must(fmt.Errorf("Go license version mismatch: binary=%s 
toolchain=%s", info.GoVersion, env.GOVERSION))
+       }
+       inv := inventory{BinarySHA256: digest(read(*binary)), Files: 
map[string]string{}}
+       for _, s := range info.Settings {
+               switch s.Key {
+               case "GOOS":
+                       inv.GOOS = s.Value
+               case "GOARCH":
+                       inv.GOARCH = s.Value
+               case "CGO_ENABLED":
+                       if s.Value != "0" {
+                               must(fmt.Errorf("binaries with C dynamic 
dependencies are not supported yet; their licenses need separate review"))
+                       }
+               }
+       }
+       if inv.GOOS == "" || inv.GOARCH == "" {
+               must(fmt.Errorf("binary is missing target platform info"))
+       }
+       if (*expectedOS != "" && inv.GOOS != *expectedOS) || (*expectedArch != 
"" && inv.GOARCH != *expectedArch) {
+               must(fmt.Errorf("binary target does not match the package name: 
%s/%s", inv.GOOS, inv.GOARCH))
+       }
+       // The standard library also contains vendored code; keep only the 
ancestor licenses of packages actually used on the target platform.
+       stdDirs := map[string]bool{env.GOROOT: true}
+       args := []string{"list", "-deps", "-json", "-mod=readonly"}
+       for _, setting := range info.Settings {
+               if setting.Key == "-tags" {
+                       args = append(args, "-tags="+setting.Value)
+               }
+       }
+       args = append(args, "main.go")
+       cmd := exec.Command(*goCommand, args...)
+       cmd.Dir = cwd
+       cmd.Env = append(os.Environ(), "GOOS="+inv.GOOS, "GOARCH="+inv.GOARCH, 
"CGO_ENABLED=0", "GOFLAGS=")
+       cmd.Stderr = os.Stderr
+       packages, err := cmd.Output()
+       must(err)
+       decoder := json.NewDecoder(bytes.NewReader(packages))
+       for {
+               var pkg struct {
+                       Standard bool
+                       Dir      string
+               }
+               err := decoder.Decode(&pkg)
+               if err == io.EOF {
+                       break
+               }
+               must(err)
+               if pkg.Standard {
+                       for dir := pkg.Dir; dir != env.GOROOT && 
strings.HasPrefix(dir, env.GOROOT+string(filepath.Separator)); dir = 
filepath.Dir(dir) {
+                               stdDirs[dir] = true
+                       }
+               }
+       }
+       files := map[string][]byte{}
+       base := func(name string) string {
+               text := string(read(filepath.Join(*root, name)))
+               return strings.Split(text, "\nThird-party source 
materials\n")[0]
+       }
+       license, notice := base("LICENSE"), base("NOTICE")
+       collect := func(name, version, sum, dir string, toolchain bool) {
+               c := component{Name: name, Version: version, Sum: sum}
+               prefix := "licenses/" + name + "@" + version + "/"
+               foundLicense := false
+               must(filepath.WalkDir(dir, func(path string, entry fs.DirEntry, 
walkErr error) error {
+                       if walkErr != nil {
+                               return walkErr
+                       }
+                       rel, err := filepath.Rel(dir, path)
+                       if err != nil {
+                               return err
+                       }
+                       if entry.IsDir() {
+                               if entry.Name() == ".git" || entry.Name() == 
"node_modules" || entry.Name() == "testdata" {
+                                       return filepath.SkipDir
+                               }
+                               if toolchain && !stdDirs[path] {
+                                       return filepath.SkipDir
+                               }
+                               return nil
+                       }
+                       if !legalName.MatchString(entry.Name()) || 
strings.HasSuffix(entry.Name(), ".go") {
+                               return nil
+                       }
+                       if entry.Type()&os.ModeSymlink != 0 {
+                               return fmt.Errorf("symlinked license not 
accepted: %s", path)
+                       }
+                       data := read(path)
+                       lower := strings.ToLower(entry.Name())
+                       if strings.HasPrefix(lower, "licen") || 
strings.HasPrefix(lower, "copying") {
+                               if len(data) < 300 {
+                                       return fmt.Errorf("license too short; 
the complete text must be verified: %s", path)
+                               }
+                               foundLicense = true
+                       }
+                       key := prefix + filepath.ToSlash(rel) + ".txt"
+                       files[key] = data
+                       c.Files = append(c.Files, key)
+                       if strings.HasPrefix(lower, "notice") {
+                               notice += "\n--- " + name + " " + version + " / 
" + filepath.ToSlash(rel) + " ---\n" + string(data) + "\n"
+                       }
+                       return nil
+               }))
+               if !foundLicense {
+                       must(fmt.Errorf("%s@%s is missing the complete upstream 
LICENSE/COPYING", name, version))
+               }
+               sort.Strings(c.Files)
+               inv.Components = append(inv.Components, c)
+               license += "\n" + name + " " + version + ": complete upstream 
license, copyright and additional terms under legal/" + prefix + "\n"
+       }
+       collect("go", info.GoVersion, "", env.GOROOT, true)
+       sort.Slice(info.Deps, func(i, j int) bool { return info.Deps[i].Path < 
info.Deps[j].Path })
+       for _, dep := range info.Deps {
+               if dep.Path == ownModule && dep.Version == "(devel)" && 
dep.Replace == nil {
+                       continue
+               }
+               if dep.Replace != nil {
+                       must(fmt.Errorf("module replacement requires manual 
source review: %s", dep.Path))
+               }
+               var module struct {
+                       Path, Version, Dir, Sum string
+                       Error                   *struct{ Err string }
+               }
+               must(json.Unmarshal(command(*goCommand, cwd, "mod", "download", 
"-json", dep.Path+"@"+dep.Version), &module))
+               if module.Dir == "" || module.Path != dep.Path || 
module.Version != dep.Version || module.Sum != dep.Sum {
+                       must(fmt.Errorf("module source/checksum mismatch: 
%s@%s", dep.Path, dep.Version))
+               }
+               collect(dep.Path, dep.Version, dep.Sum, module.Dir, false)
+       }
+       files["LICENSE"] = []byte(license)
+       files["NOTICE"] = []byte(notice)
+       for name, data := range files {
+               inv.Files[name] = digest(data)
+       }
+       manifest, err := json.MarshalIndent(inv, "", "  ")
+       must(err)
+       files["manifest.json"] = append(manifest, '\n')
+       if *check {
+               seen := 0
+               must(filepath.WalkDir(*output, func(path string, entry 
fs.DirEntry, walkErr error) error {
+                       if walkErr != nil {
+                               return walkErr
+                       }
+                       if entry.IsDir() {
+                               return nil
+                       }
+                       rel, err := filepath.Rel(*output, path)
+                       if err != nil {
+                               return err
+                       }
+                       want, ok := files[filepath.ToSlash(rel)]
+                       if !ok || entry.Type()&os.ModeSymlink != 0 || 
!bytes.Equal(read(path), want) {
+                               return fmt.Errorf("material missing, stale or 
modified: %s", path)
+                       }
+                       seen++
+                       return nil
+               }))
+               if seen != len(files) {
+                       must(fmt.Errorf("license file count mismatch: got %d, 
expected %d", seen, len(files)))
+               }
+       } else {
+               // Do not delete unknown files; a later check rejects leftover 
materials from a previous build.
+               for name, data := range files {
+                       path := filepath.Join(*output, filepath.FromSlash(name))
+                       must(os.MkdirAll(filepath.Dir(path), 0755))
+                       must(os.WriteFile(path, data, 0644))
+               }
+       }
+       fmt.Printf("license materials: %s/%s, %d actual components, %d 
files\n", inv.GOOS, inv.GOARCH, len(inv.Components), len(files))
+}
diff --git a/rmqctl/scripts/license-binary_test.go 
b/rmqctl/scripts/license-binary_test.go
new file mode 100644
index 000000000..f56a6e0e5
--- /dev/null
+++ b/rmqctl/scripts/license-binary_test.go
@@ -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.
+
+package main
+
+import (
+       "archive/tar"
+       "archive/zip"
+       "compress/gzip"
+       "io"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "runtime"
+       "strings"
+       "testing"
+)
+
+func TestBinaryLicensePackagingTest(t *testing.T) {
+       _, source, _, _ := runtime.Caller(0)
+       root := filepath.Clean(filepath.Join(filepath.Dir(source), "../.."))
+       goCommand := filepath.Join(runtime.GOROOT(), "bin/go")
+       for _, target := range []string{"linux", "windows"} {
+               t.Run(target, func(t *testing.T) {
+                       dir := t.TempDir()
+                       binary := filepath.Join(dir, "rmqctl")
+                       build := exec.Command(goCommand, "build", 
"-mod=readonly", "-o", binary, "main.go")
+                       build.Dir = filepath.Join(root, "rmqctl")
+                       build.Env = append(os.Environ(), "CGO_ENABLED=0", 
"GOOS="+target, "GOARCH=amd64")
+                       if output, err := build.CombinedOutput(); err != nil {
+                               t.Fatalf("targeted packaging build failed: 
%v\n%s", err, output)
+                       }
+                       legal := filepath.Join(dir, "legal")
+                       generate := exec.Command(goCommand, "run", 
filepath.Join(root, "rmqctl/scripts/license-binary.go"),
+                               "-go", goCommand, "-root", root, "-binary", 
binary, "-output", legal)
+                       if output, err := generate.CombinedOutput(); err != nil 
{
+                               t.Fatalf("license generation failed: %v\n%s", 
err, output)
+                       }
+                       pack := func(osName, material string, success bool) {
+                               t.Helper()
+                               cmd := exec.Command("bash", filepath.Join(root, 
"rmqctl/scripts/package-release.sh"),
+                                       binary, osName, "amd64", "fixture", 
material, filepath.Join(dir, "packages"))
+                               cmd.Env = append(os.Environ(), "GO="+goCommand)
+                               output, err := cmd.CombinedOutput()
+                               if (err == nil) != success {
+                                       t.Fatalf("packaging gate returned the 
wrong result success=%v err=%v\n%s", success, err, output)
+                               }
+                       }
+                       pack(target, legal, true)
+                       contents := map[string]string{}
+                       archive := filepath.Join(dir, "packages", 
"rmqctl-"+target+"-amd64-fixture")
+                       if target == "windows" {
+                               z, err := zip.OpenReader(archive + ".zip")
+                               if err != nil {
+                                       t.Fatal(err)
+                               }
+                               defer z.Close()
+                               for _, file := range z.File {
+                                       r, err := file.Open()
+                                       if err != nil {
+                                               t.Fatal(err)
+                                       }
+                                       data, err := io.ReadAll(r)
+                                       r.Close()
+                                       if err != nil {
+                                               t.Fatal(err)
+                                       }
+                                       contents[strings.TrimPrefix(file.Name, 
"./")] = string(data)
+                               }
+                       } else {
+                               f, err := os.Open(archive + ".tar.gz")
+                               if err != nil {
+                                       t.Fatal(err)
+                               }
+                               defer f.Close()
+                               gz, err := gzip.NewReader(f)
+                               if err != nil {
+                                       t.Fatal(err)
+                               }
+                               defer gz.Close()
+                               tr := tar.NewReader(gz)
+                               for {
+                                       header, err := tr.Next()
+                                       if err == io.EOF {
+                                               break
+                                       }
+                                       if err != nil {
+                                               t.Fatal(err)
+                                       }
+                                       data, err := io.ReadAll(tr)
+                                       if err != nil {
+                                               t.Fatal(err)
+                                       }
+                                       
contents[strings.TrimPrefix(header.Name, "./")] = string(data)
+                               }
+                       }
+                       if 
!strings.Contains(contents["legal/licenses/github.com/spf13/[email protected]/LICENSE.txt"],
 "Alex Ogier") {
+                               t.Fatal("package is missing the original pflag 
BSD license")
+                       }
+                       if 
!strings.Contains(contents["legal/licenses/github.com/mark3labs/[email protected]/LICENSE.txt"],
 "2024 Anthropic, PBC") {
+                               t.Fatal("package is missing the original mcp-go 
MIT license")
+                       }
+                       if !strings.Contains(contents["NOTICE"], "The Apache 
Software Foundation") || strings.Contains(contents["LICENSE"], "LobeHub") {
+                               t.Fatal("the binary must carry the ASF NOTICE 
and must not mix in the web source-package attribution")
+                       }
+                       if target == "windows" && 
!strings.Contains(contents["LICENSE"], "mousetrap") {
+                               t.Fatal("the Windows package is missing a 
platform-specific dependency license")
+                       }
+                       pack("darwin", legal, false)
+                       pack(target, "", false)
+                       if err := os.WriteFile(filepath.Join(legal, "NOTICE"), 
[]byte("tampered"), 0644); err != nil {
+                               t.Fatal(err)
+                       }
+                       pack(target, legal, false)
+               })
+       }
+}
diff --git a/rmqctl/scripts/package-release.sh 
b/rmqctl/scripts/package-release.sh
index 35508579d..6978b2f6b 100755
--- a/rmqctl/scripts/package-release.sh
+++ b/rmqctl/scripts/package-release.sh
@@ -17,7 +17,7 @@
 #
 
 # Package a single rmqctl binary into a release archive (tar.gz or zip)
-# with LICENSE, NOTICE, completion scripts, and optional legal bundle.
+# Bundles LICENSE, NOTICE, the completion script, and the third-party legal 
materials that must pass the check.
 #
 # Usage: package-release.sh <binary> <os> <arch> <version> <legal_dir> 
<package_dir>
 #
@@ -45,6 +45,21 @@ package_dir="$6"
 script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 project_root="$(cd "${script_dir}/../.." && pwd)"
 
+# Verify the materials for the actual binary byte by byte before packaging; an 
empty directory, a stale manifest, or a silent omission is not allowed.
+if [ -z "$legal_dir" ] || [ ! -d "$legal_dir" ]; then
+    echo "Missing third-party legal materials; run make license-binary first" 
>&2
+    exit 1
+fi
+for value in "$os" "$arch" "$version"; do
+    [[ "$value" =~ ^[a-zA-Z0-9._+-]+$ ]] || { echo "Invalid packaging 
argument" >&2; exit 1; }
+done
+binary="$(cd "$(dirname "$binary")" && pwd)/$(basename "$binary")"
+legal_dir="$(cd "$legal_dir" && pwd)"
+mkdir -p "$package_dir"
+package_dir="$(cd "$package_dir" && pwd)"
+"${GO:-go}" run "${script_dir}/license-binary.go" -root "$project_root" \
+    -go "${GO:-go}" -binary "$binary" -output "$legal_dir" -os "$os" -arch 
"$arch" -check
+
 # Staging directory for archive contents
 stage="$(mktemp -d)"
 trap 'rm -rf "$stage"' EXIT
@@ -59,11 +74,9 @@ fi
 # Copy binary
 cp "$binary" "${stage}/${bin_name}"
 
-# Copy LICENSE and NOTICE from project root
-cp "${project_root}/LICENSE" "${stage}/LICENSE"
-if [ -f "${project_root}/NOTICE" ]; then
-    cp "${project_root}/NOTICE" "${stage}/NOTICE"
-fi
+# Use the binary-specific LICENSE/NOTICE; do not mix in the web icon 
attribution from the source package.
+cp "${legal_dir}/LICENSE" "${stage}/LICENSE"
+cp "${legal_dir}/NOTICE" "${stage}/NOTICE"
 
 # Copy completion scripts if they exist (produced by `make completion`)
 completion_dir="${script_dir}/../bin/completion"
@@ -72,10 +85,8 @@ if [ -d "$completion_dir" ]; then
     cp "$completion_dir"/* "${stage}/completion/" 2>/dev/null || true
 fi
 
-# Copy legal bundle if it exists (produced by license-binary, currently unused)
-if [ -n "$legal_dir" ] && [ -d "$legal_dir" ]; then
-    cp -r "$legal_dir" "${stage}/legal"
-fi
+# The license manifest and the full license texts must ship with the package.
+cp -r "$legal_dir" "${stage}/legal"
 
 # Build archive
 archive_name="rmqctl-${os}-${arch}-${version}"
@@ -83,18 +94,20 @@ cd "$stage"
 
 if [ "$os" = "windows" ]; then
     archive="${package_dir}/${archive_name}.zip"
+    # Updating an existing zip archive keeps the old files, so refuse to reuse 
an existing target package.
+    [ ! -e "$archive" ] || { echo "Target archive already exists: $archive" 
>&2; exit 1; }
     # Use zip if available; fall back to python zipfile for portability
     if command -v zip >/dev/null 2>&1; then
         zip -r -q "$archive" .
     else
-        python3 -c "
-import zipfile, os
-with zipfile.ZipFile('${archive}', 'w', zipfile.ZIP_DEFLATED) as z:
+        python3 - "$archive" <<'PY'
+import zipfile, os, sys
+with zipfile.ZipFile(sys.argv[1], 'w', zipfile.ZIP_DEFLATED) as z:
     for root, dirs, files in os.walk('.'):
         for f in files:
             path = os.path.join(root, f)
             z.write(path, os.path.relpath(path, '.'))
-"
+PY
     fi
 else
     archive="${package_dir}/${archive_name}.tar.gz"
@@ -103,6 +116,10 @@ fi
 
 # Generate SHA-256 checksum
 cd "$package_dir"
-shasum -a 256 "$(basename "$archive")" > "$(basename "$archive").sha256"
+if command -v sha256sum >/dev/null 2>&1; then
+    sha256sum "$(basename "$archive")" > "$(basename "$archive").sha256"
+else
+    shasum -a 256 "$(basename "$archive")" > "$(basename "$archive").sha256"
+fi
 
 echo "Created $(basename "$archive") + $(basename "$archive").sha256"
diff --git a/server/Dockerfile b/server/Dockerfile
index 5b8983820..4851ec150 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,71 +1,145 @@
-# NOTE: the build context for this Dockerfile is the REPOSITORY ROOT, not 
server/.
-# It was widened so the rmqctl Go stage below can `COPY rmqctl/ ...` and ship
-# /usr/local/bin/rmqctl in the server image (the AI backend spawns the agent 
CLI
-# with an MCP config that runs `rmqctl mcp stdio`). Every COPY/ADD path in this
-# file is therefore rooted at the repository. Callers must pass the root as the
-# context: `docker build -f server/Dockerfile .` / compose `context: ..`.
-# A root-level .dockerignore keeps that wider context lean.
-
-# rmqctl CLI + MCP server. Built here rather than shipped as a host artifact so
-# the image is self-contained and reproducible.
-#   - golang:1.27.1 EXACTLY: rmqctl/go.mod declares `go 1.27.1`, and an older
-#     toolchain refuses to build it unless GOTOOLCHAIN may download a newer 
one,
-#     which the China network makes unreliable. GOTOOLCHAIN=local pins the
-#     in-image toolchain so a version mismatch fails loudly instead of hanging.
-#   - GOPROXY=goproxy.cn mirrors the other China-network choices in this file
-#     (npmmirror for the Node.js tarball, registry.npmmirror.com for npm).
-#   - CGO_ENABLED=0: this stage is Debian-based while the runtime is
-#     dragonwell:21 (Anolis), so a static binary runs regardless of glibc.
-#   - go.mod/go.sum are copied BEFORE the sources so the module download layer
-#     stays cached across source-only changes.
+# 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.
+
+# 
==============================================================================
+# Build context: REPOSITORY ROOT (not server/).
+# Callers: docker build -f server/Dockerfile .  /  compose context: ..
+# 
==============================================================================
+
+# 
------------------------------------------------------------------------------
+# Stage 1: rmqctl CLI + MCP server (static Go binary)
+# 
------------------------------------------------------------------------------
+# golang:1.27.1 pinned to match rmqctl/go.mod; GOTOOLCHAIN=local prevents
+# silent downloads. CGO_ENABLED=0 produces a static binary portable across
+# Debian (this stage) and Anolis (runtime stage).
 FROM golang:1.27.1 AS rmqctl-build
-WORKDIR /src
+WORKDIR /src/rmqctl
+
 ENV GOPROXY=https://goproxy.cn,direct \
     GOTOOLCHAIN=local \
     CGO_ENABLED=0
+
+# Cache module downloads separately from source changes.
 COPY rmqctl/go.mod rmqctl/go.sum ./
 RUN go mod download
+
 COPY rmqctl/ ./
-RUN mkdir -p /out && go build -trimpath -ldflags "-s -w" -o /out/rmqctl main.go
+COPY LICENSE NOTICE /src/
 
-# Shared runtime dependencies
-# Note: a pinned dragonwell patch tag (e.g. 21.0.10-anolis) would be 
preferable for
-# reproducibility, but such tags are not reachable through the deployment 
registry
-# mirrors in use, so keep the rolling :21 tag until a verifiable pin is 
available.
+RUN mkdir -p /out \
+    && go build -trimpath -ldflags "-s -w" -o /out/rmqctl main.go \
+    && go run scripts/license-binary.go -root /src -binary /out/rmqctl -output 
/out/legal \
+    && go run scripts/license-binary.go -root /src -binary /out/rmqctl -output 
/out/legal -check
+
+# 
------------------------------------------------------------------------------
+# Stage 2: runtime-base — shared runtime layer (JDK + Node.js + rmqctl)
+# 
------------------------------------------------------------------------------
+# Rolling :21 tag until a pinned dragonwell patch tag is reachable through
+# deployment registry mirrors.
 FROM alibabadragonwell/dragonwell:21 AS runtime-base
 WORKDIR /app
-# Node.js + agent CLIs for the claude-code / qoder agent providers.
-RUN curl -fsSL 
https://npmmirror.com/mirrors/node/v20.19.2/node-v20.19.2-linux-x64.tar.gz | 
tar xz -C /opt \
-    && ln -s /opt/node-v20.19.2-linux-x64/bin/node /usr/local/bin/node \
-    && ln -s /opt/node-v20.19.2-linux-x64/bin/npm /usr/local/bin/npm \
-    && ln -s /opt/node-v20.19.2-linux-x64/bin/npx /usr/local/bin/npx \
+
+# Python3 interpreter for the license-check script (standard library only).
+RUN if ! command -v python3 >/dev/null 2>&1; then \
+      if command -v yum >/dev/null 2>&1; then \
+        yum install -y python3 && yum clean all; \
+      else \
+        apt-get update && apt-get install -y --no-install-recommends python3; \
+      fi; \
+    fi
+
+COPY server/scripts/legal.py /opt/studio-legal/scripts/legal.py
+COPY server/LICENSE server/NOTICE /opt/studio-legal/
+COPY server/LICENSE server/NOTICE /usr/share/licenses/rocketmq-studio/
+
+# Node.js + AI agent CLIs (claude-code, qodercli).
+# These packages are obtained from npm at build time by the user; they are NOT
+# distributed as part of the ASF release artifact (only this Dockerfile is).
+RUN curl -fsSL 
https://npmmirror.com/mirrors/node/v20.19.2/node-v20.19.2-linux-x64.tar.gz \
+      | tar xz -C /opt \
+    && ln -s /opt/node-v20.19.2-linux-x64/bin/node  /usr/local/bin/node \
+    && ln -s /opt/node-v20.19.2-linux-x64/bin/npm   /usr/local/bin/npm \
+    && ln -s /opt/node-v20.19.2-linux-x64/bin/npx   /usr/local/bin/npx \
     && npm config set registry https://registry.npmmirror.com \
     && npm install -g @anthropic-ai/claude-code @qoder-ai/qodercli \
-    && ln -s /opt/node-v20.19.2-linux-x64/bin/claude /usr/local/bin/claude \
+    && ln -s /opt/node-v20.19.2-linux-x64/bin/claude   /usr/local/bin/claude \
     && ln -s /opt/node-v20.19.2-linux-x64/bin/qodercli /usr/local/bin/qodercli
-# rmqctl: RocketMQ MCP server / CLI used by the agent providers.
+
+# rmqctl binary + legal materials from stage 1.
 COPY --from=rmqctl-build /out/rmqctl /usr/local/bin/rmqctl
+COPY --from=rmqctl-build /out/legal/LICENSE /out/legal/NOTICE 
/usr/share/licenses/rmqctl/
+COPY --from=rmqctl-build /out/legal /usr/share/licenses/rmqctl/legal
 RUN chmod 0755 /usr/local/bin/rmqctl
+
+# MySQL Connector/J (GPL-2.0 + Universal FOSS Exception, ASF Category X).
+# Not bundled in the fat JAR (pom.xml scope=provided); downloaded here at image
+# build time so the container has a working MySQL driver out of the box.
+# ASF distributes only this Dockerfile text, not the connector binary.
+RUN mkdir -p /app/lib \
+    && curl -fsSL -o /app/lib/mysql-connector-j-9.7.0.jar \
+       
https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/9.7.0/mysql-connector-j-9.7.0.jar
+ENV LOADER_PATH=/app/lib
+
 EXPOSE 8888
 ENTRYPOINT ["java", "-jar", "app.jar"]
 
-# deploy.sh target: package with the JAR prebuilt by a Maven container that
-# mounts the host ~/.m2 cache, avoiding dependency downloads on every build.
-# Keep this before the build stage so Docker's legacy builder can stop here.
+# 
------------------------------------------------------------------------------
+# Stage 3a: runtime-prebuilt — deploy.sh target (JAR built on host)
+# 
------------------------------------------------------------------------------
+# Expects server/target/*.jar to exist before docker build.
 FROM runtime-base AS runtime-prebuilt
 COPY server/target/*.jar app.jar
+RUN python3 /opt/studio-legal/scripts/legal.py check-jar /app/app.jar \
+      --output /usr/share/licenses/rocketmq-studio
 
-# Maven build stage used by the default CI / docker compose target.
+# 
------------------------------------------------------------------------------
+# Stage 3b: build — Maven compile inside Docker (CI / docker compose default)
+# 
------------------------------------------------------------------------------
+# mysql-connector-j is scope=provided, so `mvn package` produces a fat JAR
+# WITHOUT the GPL connector. The connector is downloaded separately in the
+# runtime-base stage above and loaded via LOADER_PATH at startup.
 FROM alibabadragonwell/dragonwell:21 AS build
-RUN curl -fsSL 
https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz
 | tar xz -C /opt \
-    && ln -s /opt/apache-maven-3.9.9/bin/mvn /usr/local/bin/mvn
 WORKDIR /app
+
+RUN curl -fsSL 
https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz
 \
+      | tar xz -C /opt \
+    && ln -s /opt/apache-maven-3.9.9/bin/mvn /usr/local/bin/mvn
+
+RUN if ! command -v python3 >/dev/null 2>&1; then \
+      if command -v yum >/dev/null 2>&1; then \
+        yum install -y python3 && yum clean all; \
+      else \
+        apt-get update && apt-get install -y --no-install-recommends python3; \
+      fi; \
+    fi
+
+# Cache dependencies separately from source changes.
 COPY server/pom.xml .
 RUN mvn dependency:go-offline
+
 COPY server/src ./src
 COPY server/style ./style
+COPY server/scripts/legal.py ./scripts/legal.py
+COPY server/LICENSE server/NOTICE ./
+
 RUN mvn package -DskipTests
 
-# Default (last) target: build the JAR inside Docker for CI / docker compose.
+# 
------------------------------------------------------------------------------
+# Stage 4: runtime — default target (JAR built inside Docker)
+# 
------------------------------------------------------------------------------
 FROM runtime-base AS runtime
 COPY --from=build /app/target/*.jar app.jar
+RUN python3 /opt/studio-legal/scripts/legal.py check-jar /app/app.jar \
+      --output /usr/share/licenses/rocketmq-studio
diff --git a/server/LICENSE b/server/LICENSE
index c8aa782be..65e3e026b 100644
--- a/server/LICENSE
+++ b/server/LICENSE
@@ -186,7 +186,7 @@
       understanding of the Apache License by reading the FAQ at
       http://www.apache.org/foundation/license-faq.html
 
-   Copyright 2026 RocketMQ Studio Contributors
+   Copyright [yyyy] [name of copyright owner]
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/server/NOTICE b/server/NOTICE
index fde8f8e31..b0e660925 100644
--- a/server/NOTICE
+++ b/server/NOTICE
@@ -1,5 +1,5 @@
-RocketMQ Studio
-Copyright 2026 RocketMQ Studio Contributors
+Apache RocketMQ Studio
+Copyright 2026 The Apache Software Foundation
 
 This product includes software developed at
-The Apache Software Foundation (http://www.apache.org/).
+The Apache Software Foundation (https://www.apache.org/).
diff --git a/server/pom.xml b/server/pom.xml
index b057278f8..98e209b38 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -1,4 +1,20 @@
 <?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
 <project xmlns="http://maven.apache.org/POM/4.0.0";
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
https://maven.apache.org/xsd/maven-4.0.0.xsd";>
@@ -13,7 +29,7 @@
 
     <groupId>com.rocketmq</groupId>
     <artifactId>rocketmq-studio</artifactId>
-    <version>1.0.0</version>
+    <version>3.0.0</version>
     <name>RocketMQ Studio</name>
 
     <properties>
@@ -21,6 +37,7 @@
         <spring-ai.version>2.0.0</spring-ai.version>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <rocketmq.version>5.5.0</rocketmq.version>
+        <license.python>python3</license.python>
     </properties>
 
     <dependencyManagement>
@@ -107,10 +124,13 @@
             <artifactId>mybatis-plus-jsqlparser</artifactId>
             <version>3.5.17</version>
         </dependency>
+        <!-- MySQL Connector/J: GPL-2.0 + Universal FOSS Exception (ASF 
Category X).
+             Scope=provided keeps it out of the distributed fat JAR; the 
Dockerfile
+             downloads it at image build time. See server/Dockerfile for 
details. -->
         <dependency>
             <groupId>com.mysql</groupId>
             <artifactId>mysql-connector-j</artifactId>
-            <scope>runtime</scope>
+            <scope>provided</scope>
         </dependency>
         <dependency>
             <groupId>com.h2database</groupId>
@@ -171,6 +191,65 @@
             <plugin>
                 <groupId>org.springframework.boot</groupId>
                 <artifactId>spring-boot-maven-plugin</artifactId>
+                <configuration>
+                    <!-- MySQL Connector/J is GPL (ASF Category X); keep it 
out of the
+                         distributed fat JAR. The Dockerfile downloads it 
separately and
+                         Spring Boot loads it via LOADER_PATH at runtime. -->
+                    <excludes>
+                        <exclude>
+                            <groupId>com.mysql</groupId>
+                            <artifactId>mysql-connector-j</artifactId>
+                        </exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-resources-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>project-legal-resources</id>
+                        <phase>process-resources</phase>
+                        <goals><goal>copy-resources</goal></goals>
+                        <configuration>
+                            
<outputDirectory>${project.build.outputDirectory}/META-INF</outputDirectory>
+                            <resources>
+                                <resource>
+                                    <directory>${project.basedir}</directory>
+                                    <filtering>false</filtering>
+                                    <includes>
+                                        <include>LICENSE</include>
+                                        <include>NOTICE</include>
+                                    </includes>
+                                </resource>
+                            </resources>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <!-- Check the actually embedded dependencies after Boot 
repackage; does not affect the test-only flow. -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-antrun-plugin</artifactId>
+                <version>3.2.0</version>
+                <executions>
+                    <execution>
+                        <id>binary-license-gate</id>
+                        <phase>package</phase>
+                        <goals><goal>run</goal></goals>
+                        <configuration>
+                            <target>
+                                <exec executable="${license.python}" 
failonerror="true">
+                                    <arg 
value="${project.basedir}/scripts/legal.py"/>
+                                    <arg value="jar"/>
+                                    <arg 
value="${project.build.directory}/${project.build.finalName}.jar"/>
+                                    <arg value="--output"/>
+                                    <arg 
value="${project.build.directory}/legal"/>
+                                </exec>
+                            </target>
+                        </configuration>
+                    </execution>
+                </executions>
             </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
diff --git a/server/scripts/legal.py b/server/scripts/legal.py
new file mode 100644
index 000000000..76a343e3a
--- /dev/null
+++ b/server/scripts/legal.py
@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Preserve the license texts of the actually distributed files using only the 
standard library; complete materials do not imply redistribution rights have 
been granted."""
+import argparse
+import hashlib
+import io
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import subprocess
+import sys
+import tempfile
+import zipfile
+
+SERVER = Path(__file__).resolve().parents[1]
+LEGAL_NAME = 
re.compile(r"^(licen[sc]e|notice|copying|copyright|patents|authors|dependencies|third[-_]party)([._-].*)?$",
 re.I)
+LICENSE_NAME = re.compile(r"^(licen[sc]e|copying)([._-].*)?$", re.I)
+NOTICE_NAME = re.compile(r"^notice([._-].*)?$", re.I)
+PERMISSIVE = {"MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", 
"0BSD"}
+
+
+def sha(data):
+    return hashlib.sha256(data).hexdigest()
+
+
+def read(path):
+    data = Path(path).read_bytes()
+    if not data.strip():
+        raise ValueError(f"empty license material: {path}")
+    return data
+
+
+def safe(name):
+    p = PurePosixPath(name)
+    if p.is_absolute() or ".." in p.parts or "\\" in name:
+        raise ValueError(f"unsafe material path: {name}")
+    return name
+
+
+def is_legal(name):
+    p = PurePosixPath(name)
+    if p.suffix.lower() in {".class", ".java", ".js", ".ts", ".go", ".py", 
".so", ".dll", ".exe"}:
+        return False
+    return bool(LEGAL_NAME.match(p.name) or p.name.lower() == "about.html"
+                or any(part.lower() in {"licenses", "legal", "license"} for 
part in p.parts[:-1]))
+
+
+def texts_from_zip(data):
+    with zipfile.ZipFile(io.BytesIO(data)) as archive:
+        if len(archive.namelist()) != len(set(archive.namelist())):
+            raise ValueError("dependency JAR contains duplicate ZIP entries")
+        return {safe(name): archive.read(name) for name in 
sorted(archive.namelist())
+                if not name.endswith("/") and is_legal(name)}
+
+
+def add_component(files, components, errors, name, payload, texts, notice, 
source):
+    prefix = f"licenses/{safe(name)}/"
+    component = {"name": name, "sha256": sha(payload), "source": source, 
"files": []}
+    license_texts = [data for filename, data in texts.items() if 
LICENSE_NAME.match(PurePosixPath(filename).name)]
+    if any(b"GNU GENERAL PUBLIC LICENSE" in data or b"GNU LESSER GENERAL 
PUBLIC LICENSE" in data
+           or b"GNU AFFERO GENERAL PUBLIC LICENSE" in data for data in 
license_texts):
+        errors.append(f"{name}: contains GPL-family terms; dual-license 
choice/exception and redistribution obligations require manual review")
+    if not license_texts or not any(len(data.strip()) >= 300 for data in 
license_texts):
+        errors.append(f"{name}: missing complete upstream LICENSE/COPYING; a 
POM name or link is not a substitute")
+    for filename, data in sorted(texts.items()):
+        if not data.strip():
+            errors.append(f"{name}: empty license file {filename}")
+            continue
+        target = prefix + safe(filename) + ".txt"
+        files[target] = data
+        component["files"].append({"path": target, "source": filename, 
"sha256": sha(data)})
+        if NOTICE_NAME.match(PurePosixPath(filename).name):
+            notice += f"\n--- {name} / {filename} ---\n".encode() + data + 
b"\n"
+    components.append(component)
+    return notice
+
+
+def finish(files, components, errors, license_text, notice, payload):
+    for component in components:
+        license_text += f"\n{component['name']}: complete upstream license, 
copyright and additional terms under 
META-INF/legal/licenses/{component['name']}/\n".encode()
+    files["LICENSE"] = license_text
+    files["NOTICE"] = notice
+    manifest = {"components": components, "payload": payload, "manualReview": 
sorted(set(errors)),
+                "files": {name: sha(data) for name, data in 
sorted(files.items())}}
+    files["manifest.json"] = (json.dumps(manifest, ensure_ascii=False, 
indent=2) + "\n").encode()
+    return files, manifest
+
+
+def generated_entry(name):
+    return name in {"META-INF/LICENSE", "META-INF/NOTICE"} or 
name.startswith("META-INF/legal/")
+
+
+def collect_jar(jar, base=SERVER):
+    files, components, errors, payload = {}, [], [], {}
+    license_text, notice = read(base / "LICENSE"), read(base / "NOTICE")
+    with zipfile.ZipFile(jar) as archive:
+        if len(archive.namelist()) != len(set(archive.namelist())):
+            raise ValueError("distributed JAR contains duplicate ZIP entries")
+        dependencies = sorted(name for name in archive.namelist() if 
name.startswith("BOOT-INF/lib/") and name.endswith(".jar"))
+        if not dependencies:
+            raise ValueError("not a Spring Boot JAR containing actual 
dependencies; refuse to generate an empty license manifest")
+        for name in archive.namelist():
+            if not name.endswith("/") and not generated_entry(name):
+                payload[safe(name)] = sha(archive.read(name))
+        for name in dependencies:
+            data = archive.read(name)
+            filename = PurePosixPath(name).name
+            texts = texts_from_zip(data)
+            notice = add_component(files, components, errors, filename, data, 
texts, notice, name)
+            if filename.startswith("mysql-connector-j-"):
+                errors.append(f"{filename}: applicability of GPLv2 + Universal 
FOSS Exception and ASF redistribution licensing pending PMC/legal review; this 
is not the Classpath Exception")
+        # The Spring Boot loader itself is also copied into the JAR, not only 
the dependencies under BOOT-INF/lib.
+        if any(name.startswith("org/springframework/boot/loader/") and 
name.endswith(".class") for name in archive.namelist()):
+            spring_license = "META-INF/LICENSE.txt"
+            spring_notice = "META-INF/NOTICE.txt"
+            if spring_license not in archive.namelist() or spring_notice not 
in archive.namelist():
+                errors.append("Spring Boot loader: missing the original 
META-INF/LICENSE.txt or NOTICE.txt")
+            else:
+                texts = {name: archive.read(name) for name in [spring_license, 
spring_notice]}
+                version = re.search(rb"(?m)^Spring-Boot-Version: ([^\r\n]+)", 
archive.read("META-INF/MANIFEST.MF"))
+                if not version:
+                    errors.append("Spring Boot loader: missing version source")
+                loader = "spring-boot-loader@" + (version.group(1).decode() if 
version else "unknown")
+                notice = add_component(files, components, errors, loader, 
b"".join(texts.values()), texts, notice, "JAR root META-INF")
+    return finish(files, components, errors, license_text, notice, payload)
+
+
+def write_bundle(directory, files):
+    directory = Path(directory)
+    for name, data in files.items():
+        target = directory / safe(name)
+        target.parent.mkdir(parents=True, exist_ok=True)
+        target.write_bytes(data)
+
+
+def embedded_path(name):
+    return f"META-INF/{name}" if name in {"LICENSE", "NOTICE"} else 
f"META-INF/legal/{name}"
+
+
+def require_complete(manifest):
+    if manifest["manualReview"]:
+        raise ValueError("release license gate failed:\n" + 
"\n".join(manifest["manualReview"]))
+
+
+def check_jar(jar, base=SERVER):
+    files, manifest = collect_jar(jar, base)
+    with zipfile.ZipFile(jar) as archive:
+        for name, expected in files.items():
+            target = embedded_path(name)
+            if target not in archive.namelist() or archive.read(target) != 
expected:
+                raise ValueError(f"JAR is missing legal materials or they do 
not match the actual dependencies: {target}")
+        actual = {name for name in archive.namelist() if generated_entry(name) 
and not name.endswith("/")}
+        if actual != {embedded_path(name) for name in files}:
+            raise ValueError("JAR has stale or unregistered legal materials")
+    require_complete(manifest)
+    print(f"JAR license check passed: {len(manifest['components'])} actual 
components")
+
+
+def package_jar(jar, output, base=SERVER):
+    jar = Path(jar)
+    files, manifest = collect_jar(jar, base)
+    write_bundle(output, files)
+    # Emit the review materials first for inspection, but do not produce a 
seemingly compliant distribution package when materials are missing or 
licensing is unresolved.
+    require_complete(manifest)
+    with tempfile.NamedTemporaryFile(dir=jar.parent, suffix=".legal.jar", 
delete=False) as temporary:
+        temporary_path = Path(temporary.name)
+    try:
+        with zipfile.ZipFile(jar) as source, zipfile.ZipFile(temporary_path, 
"w") as target:
+            target.comment = source.comment
+            for entry in source.infolist():
+                if not generated_entry(entry.filename):
+                    # Preserve attributes such as ZIP_STORED for nested JARs; 
Boot's random-access loading must not break.
+                    target.writestr(entry, source.read(entry.filename))
+            for name, data in sorted(files.items()):
+                entry = zipfile.ZipInfo(embedded_path(name), (1980, 1, 1, 0, 
0, 0))
+                entry.compress_type = zipfile.ZIP_DEFLATED
+                entry.external_attr = 0o100644 << 16
+                target.writestr(entry, data)
+        check_jar(temporary_path, base)
+        os.replace(temporary_path, jar)
+    finally:
+        if temporary_path.exists():
+            temporary_path.unlink()
+
+
+def collect_npm(directory, node_license, base=SERVER):
+    files, components, errors = {}, [], []
+    license_text, notice = read(base / "LICENSE"), read(base / "NOTICE")
+    directory = Path(directory)
+    package_files = sorted(directory.rglob("package.json"))
+    if not package_files:
+        raise ValueError("global npm directory is empty; cannot verify the 
default AI CLI")
+    seen_agents = set()
+    for package_file in package_files:
+        pkg = json.loads(read(package_file))
+        if not pkg.get("name") or not pkg.get("version"):
+            continue
+        name = pkg["name"] + "@" + pkg["version"]
+        package_root = package_file.parent
+        texts = {}
+        for current, dirs, names in os.walk(package_root):
+            # Handle each actually installed sub-package separately; a parent 
package's license must not stand in for a sub-package's.
+            dirs[:] = sorted(d for d in dirs if d != "node_modules" and not 
(Path(current) / d / "package.json").exists())
+            for filename in names:
+                relative = (Path(current) / 
filename).relative_to(package_root).as_posix()
+                if is_legal(relative):
+                    texts[relative] = read(Path(current) / filename)
+        # A package name may be installed more than once; the manifest path 
keeps the physical install location to avoid overwriting each other.
+        identity = package_file.parent.relative_to(directory).as_posix() + "@" 
+ pkg["version"]
+        notice = add_component(files, components, errors, identity, 
read(package_file), texts, notice,
+                               package_file.relative_to(directory).as_posix())
+        components[-1]["package"] = name
+        components[-1]["declaredLicense"] = pkg.get("license")
+        if not isinstance(pkg.get("license"), str) or pkg["license"] not in 
PERMISSIVE:
+            errors.append(f"{name}: declared license {pkg.get('license')!r} 
requires manual review")
+        if pkg["name"] in {"@anthropic-ai/claude-code", "@qoder-ai/qodercli"}:
+            seen_agents.add(pkg["name"])
+            errors.append(f"{name}: redistribution licensing for the default 
in-image AI CLI and its native payload pending manual review; being installable 
via npm is not treated as licensed")
+    expected_agents = {"@anthropic-ai/claude-code", "@qoder-ai/qodercli"}
+    if seen_agents != expected_agents:
+        errors.append("the default AI CLI is not fully installed; the license 
check must not be bypassed by omitting components")
+    files["licenses/node/LICENSE.txt"] = read(node_license)
+    license_text += b"\nNode.js and bundled components: 
legal/licenses/node/LICENSE.txt\n"
+    return finish(files, components, errors, license_text, notice, {})
+
+
+def scan_headers(root):
+    # Only report pre-existing missing headers in other scopes; do not modify 
Java/SQL, tests, or workflow files owned by concurrent agents.
+    names = subprocess.check_output(["git", "-C", str(root), "ls-files", 
"-z"]).decode().split("\0")
+    extensions = {".sh", ".py", ".go", ".ts", ".tsx", ".mjs", ".cjs", ".js", 
".css", ".html", ".xml", ".yml", ".yaml"}
+    missing = []
+    for name in names:
+        file = root / name
+        if not name or not file.is_file() or (file.suffix not in extensions 
and file.name not in {"Dockerfile", "Makefile"}):
+            continue
+        if name.startswith("server/src/") or name.startswith("web/src/test/") 
or name == "rmqctl/internal/catalog/catalog_gen.go":
+            continue
+        text = file.read_text(errors="replace")
+        if "Licensed to the Apache Software Foundation" not in text[:5000]:
+            missing.append(name)
+    print(json.dumps({"scope": "tracked non-Java/SQL files outside 
concurrent-agent resources/tests", "missing": missing}, ensure_ascii=False, 
indent=2))
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    sub = parser.add_subparsers(dest="command")
+    headers = sub.add_parser("headers")
+    headers.add_argument("root", type=Path)
+    package = sub.add_parser("jar")
+    package.add_argument("jar", type=Path)
+    package.add_argument("--output", type=Path, required=True)
+    check = sub.add_parser("check-jar")
+    check.add_argument("jar", type=Path)
+    check.add_argument("--output", type=Path)
+    inspect = sub.add_parser("inspect")
+    inspect.add_argument("jar", type=Path)
+    inspect.add_argument("--summary", action="store_true")
+    npm = sub.add_parser("npm")
+    npm.add_argument("directory", type=Path)
+    npm.add_argument("--node-license", type=Path, required=True)
+    npm.add_argument("--output", type=Path, required=True)
+    args = parser.parse_args()
+    if args.command is None:
+        parser.error("missing subcommand")
+    if args.command == "headers":
+        scan_headers(args.root)
+    elif args.command == "jar":
+        package_jar(args.jar, args.output)
+    elif args.command == "check-jar":
+        check_jar(args.jar)
+        if args.output:
+            files, _ = collect_jar(args.jar)
+            write_bundle(args.output / "META-INF/legal", files)
+            write_bundle(args.output, {name: files[name] for name in 
["LICENSE", "NOTICE"]})
+    elif args.command == "inspect":
+        texts = texts_from_zip(read(args.jar))
+        print(json.dumps({"jar": args.jar.name, "sha256": sha(read(args.jar)),
+                          "legalFiles": {name: {"sha256": sha(data), "bytes": 
len(data), "text": data[:1200].decode('utf-8', errors='replace') if 
args.summary else data.decode('utf-8', errors='replace')}
+                                         for name, data in texts.items()}}, 
ensure_ascii=False, indent=2))
+    elif args.command == "npm":
+        files, manifest = collect_npm(args.directory, args.node_license)
+        # Relative paths of in-image materials do not use the JAR's META-INF 
prefix.
+        files["LICENSE"] = files["LICENSE"].replace(b"META-INF/legal/", 
b"legal/")
+        manifest["files"]["LICENSE"] = sha(files["LICENSE"])
+        files["manifest.json"] = (json.dumps(manifest, ensure_ascii=False, 
indent=2) + "\n").encode()
+        write_bundle(args.output, files)
+        require_complete(manifest)
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except (ValueError, OSError, zipfile.BadZipFile, KeyError) as error:
+        print(f"license gate: {error}", file=sys.stderr)
+        sys.exit(1)
diff --git a/server/scripts/legal_test.py b/server/scripts/legal_test.py
new file mode 100644
index 000000000..5a52e0608
--- /dev/null
+++ b/server/scripts/legal_test.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+# 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 io
+import json
+from pathlib import Path
+import tempfile
+import unittest
+import zipfile
+import xml.etree.ElementTree as ET
+
+import legal
+
+
+class LegalTest(unittest.TestCase):
+    def setUp(self):
+        self.temporary = tempfile.TemporaryDirectory(dir=legal.SERVER / 
"scripts")
+        self.addCleanup(self.temporary.cleanup)
+        self.directory = Path(self.temporary.name)
+
+    def jar(self, name="fixture-1.0.jar", license_present=True):
+        dependency = io.BytesIO()
+        with zipfile.ZipFile(dependency, "w") as archive:
+            if license_present:
+                archive.writestr("META-INF/LICENSE", legal.read(legal.SERVER / 
"LICENSE"))
+            archive.writestr("META-INF/NOTICE", b"fixture required notice\n")
+            archive.writestr("META-INF/COPYRIGHT", b"fixture copyright 
statement\n")
+            archive.writestr("fixture/Notice.class", b"not a legal text")
+        jar = self.directory / "app.jar"
+        with zipfile.ZipFile(jar, "w") as archive:
+            archive.writestr("BOOT-INF/lib/" + name, dependency.getvalue(), 
compress_type=zipfile.ZIP_STORED)
+            archive.writestr("BOOT-INF/classes/fixture.txt", b"application")
+        return jar
+
+    def jarRoundTripAndCopyrightTest(self):
+        jar = self.jar()
+        legal.package_jar(jar, self.directory / "legal")
+        legal.check_jar(jar)
+        first = jar.read_bytes()
+        legal.package_jar(jar, self.directory / "legal")
+        self.assertEqual(first, jar.read_bytes())
+        with zipfile.ZipFile(jar) as archive:
+            self.assertIn(b"fixture required notice", 
archive.read("META-INF/NOTICE"))
+            self.assertEqual(b"fixture copyright statement\n", 
archive.read("META-INF/legal/licenses/fixture-1.0.jar/META-INF/COPYRIGHT.txt"))
+            self.assertEqual(zipfile.ZIP_STORED, 
archive.getinfo("BOOT-INF/lib/fixture-1.0.jar").compress_type)
+            
self.assertNotIn("META-INF/legal/licenses/fixture-1.0.jar/fixture/Notice.class.txt",
 archive.namelist())
+
+    def missingLicenseFailsWithoutReplacingJarTest(self):
+        jar = self.jar(license_present=False)
+        original = jar.read_bytes()
+        with self.assertRaisesRegex(ValueError, "missing complete upstream"):
+            legal.package_jar(jar, self.directory / "legal")
+        self.assertEqual(original, jar.read_bytes())
+        self.assertTrue((self.directory / "legal/manifest.json").is_file())
+
+    def mysqlExceptionRequiresReviewTest(self):
+        jar = self.jar("mysql-connector-j-9.7.0.jar")
+        with self.assertRaisesRegex(ValueError, "Universal FOSS Exception"):
+            legal.package_jar(jar, self.directory / "legal")
+
+    def changedPayloadFailsTest(self):
+        jar = self.jar()
+        legal.package_jar(jar, self.directory / "legal")
+        with zipfile.ZipFile(jar, "a") as archive:
+            archive.writestr("BOOT-INF/classes/extra.txt", b"new payload")
+        with self.assertRaisesRegex(ValueError, "do not match the actual 
dependencies"):
+            legal.check_jar(jar)
+
+    def defaultAiCliRequiresReviewTest(self):
+        for name in ["@anthropic-ai/claude-code", "@qoder-ai/qodercli"]:
+            directory = self.directory / "npm" / name
+            directory.mkdir(parents=True)
+            # A test fixture must not be treated as having obtained 
redistribution rights for a real product just because it declares MIT.
+            (directory / "package.json").write_text(json.dumps({"name": name, 
"version": "fixture", "license": "MIT"}))
+            (directory / "LICENSE").write_bytes(legal.read(legal.SERVER / 
"LICENSE"))
+        files, manifest = legal.collect_npm(self.directory / "npm", 
legal.SERVER / "LICENSE")
+        self.assertIn("licenses/node/LICENSE.txt", files)
+        with self.assertRaisesRegex(ValueError, "AI CLI"):
+            legal.require_complete(manifest)
+
+    def lifecycleAndDockerStaticContractTest(self):
+        pom = ET.parse(legal.SERVER / "pom.xml")
+        ns = {"m": "http://maven.apache.org/POM/4.0.0"}
+        plugins = pom.findall("m:build/m:plugins/m:plugin", ns)
+        names = [plugin.findtext("m:artifactId", namespaces=ns) for plugin in 
plugins]
+        self.assertLess(names.index("spring-boot-maven-plugin"), 
names.index("maven-antrun-plugin"))
+        gate = plugins[names.index("maven-antrun-plugin")]
+        self.assertEqual("package", 
gate.findtext("m:executions/m:execution/m:phase", namespaces=ns))
+        self.assertEqual("true", 
gate.find("m:executions/m:execution/m:configuration/m:target/m:exec", 
ns).get("failonerror"))
+        docker = (legal.SERVER / "Dockerfile").read_text()
+        self.assertEqual(2, docker.count("legal.py check-jar /app/app.jar"))
+        self.assertIn("npm install -g @anthropic-ai/claude-code 
@qoder-ai/qodercli", docker)
+        self.assertIn("COPY --from=rmqctl-build /out/legal", docker)
+
+
+def load_tests(_loader, _tests, _pattern):
+    return unittest.TestSuite(LegalTest(name) for name in LegalTest.__dict__ 
if name.endswith("Test"))
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)
diff --git a/web/Dockerfile b/web/Dockerfile
index f5beaefce..a0f2efe6b 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -1,3 +1,18 @@
+# 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.
+
 # Stage 1: Build
 # Pin Node and nginx base images to a specific minor so CI builds are
 # reproducible (the bare :20-alpine and :alpine tags are rolling).
@@ -14,6 +29,9 @@ RUN npm run build
 FROM nginx:1.27.2-alpine
 ENV NGINX_ENVSUBST_FILTER=^RESOLVER$
 COPY --from=build /app/dist /usr/share/nginx/html
+# A separate path carries the same build-checked materials; COPY must fail 
when they are missing.
+COPY --from=build /app/dist/LICENSE /app/dist/NOTICE 
/usr/share/licenses/rocketmq-studio/
+COPY --from=build /app/dist/legal /usr/share/licenses/rocketmq-studio/legal
 COPY nginx.conf /etc/nginx/templates/default.conf.template
 COPY 15-resolver.envsh /docker-entrypoint.d/15-resolver.envsh
 RUN chmod +x /docker-entrypoint.d/15-resolver.envsh
diff --git a/web/LICENSE b/web/LICENSE
index c8aa782be..cd86ab26c 100644
--- a/web/LICENSE
+++ b/web/LICENSE
@@ -186,7 +186,7 @@
       understanding of the Apache License by reading the FAQ at
       http://www.apache.org/foundation/license-faq.html
 
-   Copyright 2026 RocketMQ Studio Contributors
+   Copyright [yyyy] [name of copyright owner]
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
@@ -199,3 +199,16 @@
    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.
+
+Third-party source materials
+===========================
+This source distribution includes 17 model brand SVG icons from
+@lobehub/icons-static-svg 1.95.0 (https://github.com/lobehub/lobe-icons).
+They are licensed under MIT; the complete upstream license is reproduced in
+src/assets/model-logos/LICENSE. Copyright (c) 2023 LobeHub.
+Upstream license: https://github.com/lobehub/lobe-icons/blob/v1.95.0/LICENSE
+This does not grant rights to third-party trademarks.
+
+Production builds generate distribution-specific LICENSE, NOTICE and legal/
+from the actual bundled modules and these vendored icons. Build-only packages
+are not included unless their code or assets are actually redistributed.
diff --git a/web/NOTICE b/web/NOTICE
index fde8f8e31..7f0bec17a 100644
--- a/web/NOTICE
+++ b/web/NOTICE
@@ -1,5 +1,10 @@
-RocketMQ Studio
-Copyright 2026 RocketMQ Studio Contributors
+Apache RocketMQ Studio
+Copyright 2026 The Apache Software Foundation
 
 This product includes software developed at
-The Apache Software Foundation (http://www.apache.org/).
+The Apache Software Foundation (https://www.apache.org/).
+
+Third-party source materials
+===========================
+Model brand icons from LobeHub (https://github.com/lobehub/lobe-icons):
+Copyright (c) 2023 LobeHub.
diff --git a/web/licenses/toggle-selection-1.0.6/LICENSE 
b/web/licenses/toggle-selection-1.0.6/LICENSE
new file mode 100644
index 000000000..ccca75602
--- /dev/null
+++ b/web/licenses/toggle-selection-1.0.6/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 sudodoki <[email protected]>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/web/package-lock.json b/web/package-lock.json
index f725054c2..61d84f781 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "rocketmq-studio-web",
-  "version": "0.1.0",
+  "version": "3.0.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "rocketmq-studio-web",
-      "version": "0.1.0",
+      "version": "3.0.0",
       "dependencies": {
         "@ant-design/icons": "^5.5.0",
         "@phosphor-icons/react": "^2.1.10",
diff --git a/web/package.json b/web/package.json
index 6b375d523..79d31461e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,14 +1,16 @@
 {
   "name": "rocketmq-studio-web",
   "private": true,
-  "version": "0.1.0",
+  "version": "3.0.0",
   "type": "module",
   "engines": {
     "node": ">=20.19.0"
   },
   "scripts": {
     "dev": "vite",
-    "build": "tsc -b && vite build",
+    "build": "tsc -b && vite build && npm run license:check",
+    "license:check": "node scripts/licenses.mjs check",
+    "license:test": "node --test scripts/licenses.test.mjs",
     "preview": "vite preview",
     "test": "vitest run",
     "test:watch": "vitest",
diff --git a/web/scripts/licenses.d.mts b/web/scripts/licenses.d.mts
new file mode 100644
index 000000000..483c19ce9
--- /dev/null
+++ b/web/scripts/licenses.d.mts
@@ -0,0 +1,18 @@
+/*
+ * 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 type { Plugin } from 'vite';
+export function distributionLicenses(): Plugin;
diff --git a/web/scripts/licenses.mjs b/web/scripts/licenses.mjs
new file mode 100644
index 000000000..d48d68857
--- /dev/null
+++ b/web/scripts/licenses.mjs
@@ -0,0 +1,247 @@
+/*
+ * 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 { createHash } from 'node:crypto';
+import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+const legalName = 
/^(licen[sc]e|notice|copying|copyright|patents|authors)([._-].*)?$/i;
+const approved = new Set(['MIT', 'Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 
'ISC', '0BSD']);
+const sha = (data) => createHash('sha256').update(data).digest('hex');
+const read = (name) => {
+  const data = readFileSync(name);
+  if (!data.toString().trim()) throw new Error(`empty license material: 
${name}`);
+  return data;
+};
+const json = (name) => JSON.parse(read(name));
+const slash = (name) => name.split(path.sep).join('/');
+
+function packageRoot(id, base) {
+  let dir = path.dirname(id);
+  while (dir !== base && dir !== path.dirname(dir)) {
+    const file = path.join(dir, 'package.json');
+    if (existsSync(file) && json(file).name) return dir;
+    dir = path.dirname(dir);
+  }
+  throw new Error(`cannot find the package.json of an actually bundled module: 
${id}`);
+}
+
+function legalFiles(dir) {
+  const result = [];
+  function walk(current) {
+    for (const entry of readdirSync(current, { withFileTypes: true })) {
+      if (['node_modules', '.git', 'test', 'tests', 
'__tests__'].includes(entry.name)) continue;
+      const file = path.join(current, entry.name);
+      if (entry.isDirectory()) walk(file);
+      else if (legalName.test(entry.name) && 
!/\.(js|cjs|mjs|ts|tsx|jsx|class|map|svg|png|jpg|gif|woff2?)$/i.test(entry.name))
 {
+        if (!entry.isFile()) throw new Error(`license file must be a regular 
file: ${file}`);
+        result.push(file);
+      }
+    }
+  }
+  walk(dir);
+  return result.sort();
+}
+
+export function collectLicenses(moduleIds, base = root) {
+  const packages = new Map();
+  const files = new Map();
+  const components = [];
+  const baseText = (name) => read(path.join(base, 
name)).toString().split('\nThird-party source materials\n')[0];
+  let license = baseText('LICENSE');
+  let notice = baseText('NOTICE');
+  const ids = [...new Set(moduleIds)].sort();
+  let hasIcons = false;
+  for (const original of ids) {
+    const id = original.replace(/^\0/, '').split('?')[0];
+    if (id.includes('src/assets/model-logos/') && id.endsWith('.svg')) 
hasIcons = true;
+    if (id.includes('node_modules/')) {
+      const dir = packageRoot(path.resolve(base, id), base);
+      packages.set(dir, 'bundled-module');
+    } else if (id.includes('vite/') || id.includes('commonjsHelpers')) {
+      packages.set(path.join(base, 'node_modules/vite'), 
'bundled-runtime-helper');
+    }
+  }
+  // Tailwind's preflight CSS ends up in the bundle; do not omit it just 
because it is listed under devDependencies.
+  for (const id of ids.filter((name) => name.endsWith('.css') && 
!name.includes('node_modules/'))) {
+    if (/@tailwind\s+base\s*;/.test(read(path.resolve(base, id)).toString())) {
+      packages.set(path.join(base, 'node_modules/tailwindcss'), 
'bundled-preflight-css');
+    }
+  }
+  if (hasIcons) packages.set(path.join(base, 
'node_modules/@lobehub/icons-static-svg'), 'vendored-svg');
+  if (packages.size === 0) throw new Error('no verifiable third-party 
component in the build; refuse to generate an empty manifest');
+
+  for (const [dir, reason] of [...packages].sort(([a], [b]) => 
a.localeCompare(b, 'en'))) {
+    const pkg = json(path.join(dir, 'package.json'));
+    if (!pkg.version || !approved.has(pkg.license)) {
+      throw new Error(`redistribution license requires manual review: 
${pkg.name}@${pkg.version}: ${JSON.stringify(pkg.license)}`);
+    }
+    let sources = legalFiles(dir);
+    const sourceURLs = {};
+    const sourceNames = {};
+    const sourceContent = {};
+    const identity = `${pkg.name}@${pkg.version}`;
+    if (identity === '@ant-design/[email protected]') {
+      // Verified against the repository-level LICENSE at the npm gitHead; 
identical to the installed icons 5.6.1 text.
+      const fallback = path.join(base, 
'node_modules/@ant-design/icons/LICENSE');
+      if (json(path.join(base, 
'node_modules/@ant-design/icons/package.json')).version !== '5.6.1') {
+        throw new Error('the icons license source version changed; 
re-verification required');
+      }
+      if (sha(read(fallback)) !== 
'5d367fb0a07340571542eb4ee8eb1add62d37b71bad6786a57c2e9a86bf76c70') {
+        throw new Error('the icons upstream license text changed; 
re-verification required');
+      }
+      sources.push(fallback);
+      sourceURLs[fallback] = 
'https://github.com/ant-design/ant-design-icons/blob/e6d33f4ba94ebe9f4b8648373342505b88e51a57/LICENSE';
+    }
+    if (['[email protected]', '[email protected]'].includes(identity)) {
+      // These two installed modules put the full MIT text in their README; 
extract only the complete license section, do not infer copyright.
+      const source = path.join(dir, 'README.md');
+      const match = read(source).toString().match(/\(The MIT 
License\)\r?\n[\s\S]*?SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
SOFTWARE\.\r?\n/);
+      if (!match) throw new Error(`the complete upstream license section is 
missing from the README: ${identity}`);
+      sources.push(source);
+      sourceNames[source] = 'LICENSE-from-README';
+      sourceContent[source] = Buffer.from(match[0]);
+    }
+    if (identity === '[email protected]') {
+      // The 1.0.6 npm package omits LICENSE; the index.js from the immutable 
upstream commit below is identical to the package, and the MIT text is supplied.
+      const fallback = path.join(base, 
'licenses/toggle-selection-1.0.6/LICENSE');
+      if (sha(read(path.join(dir, 'index.js'))) !== 
'd1a1caf366f8ae5ed3cf4a87c42c46e73bb5536acbcbc5ca479c180d4d3e7756'
+          || sha(read(fallback)) !== 
'5149051aed807f78acfbf9a43ac66368374a8fa1f9dfc092b73de5a67d42673a') {
+        throw new Error('toggle-selection code or upstream license text does 
not match the verified source');
+      }
+      sources.push(fallback, path.join(dir, 'README.md'));
+      sourceURLs[fallback] = 
'https://github.com/sudodoki/toggle-selection/blob/888650b271ee4937e2baff6a43bee744635601e3/LICENSE';
+      sourceNames[path.join(dir, 'README.md')] = 'ATTRIBUTION';
+    }
+    if (reason === 'vendored-svg') {
+      if (pkg.version !== '1.95.0') throw new Error('the LobeHub version 
changed; the vendored SVG source must be re-approved');
+      const vendoredDir = path.join(base, 'src/assets/model-logos');
+      const icons = readdirSync(vendoredDir).filter((name) => 
name.endsWith('.svg')).sort();
+      if (icons.length !== 17) throw new Error('the vendored SVG list changed; 
the source must be re-approved');
+      for (const name of icons) {
+        if (!read(path.join(vendoredDir, name)).equals(read(path.join(dir, 
'icons', name)))) {
+          throw new Error(`a vendored icon does not match the locked upstream 
text: ${name}`);
+        }
+      }
+      const fallback = path.join(vendoredDir, 'LICENSE');
+      sources = [...new Set([...sources, fallback])];
+      sourceURLs[fallback] = 
'https://github.com/lobehub/lobe-icons/blob/v1.95.0/LICENSE';
+      notice += '\nModel brand icons from LobeHub 
(https://github.com/lobehub/lobe-icons):\nCopyright (c) 2023 LobeHub.\n';
+    }
+    if (!sources.some((file) => 
/^(licen[sc]e|copying)([._-].*)?$/i.test(sourceNames[file] || 
path.basename(file)))) {
+      throw new Error(`missing the complete upstream license text: 
${pkg.name}@${pkg.version}`);
+    }
+    const component = { name: pkg.name, version: pkg.version, license: 
pkg.license, reason, files: [] };
+    if (identity === '[email protected]') {
+      component.payloadSource = { sha256: sha(read(path.join(dir, 
'index.js'))),
+        upstream: 
'https://github.com/sudodoki/toggle-selection/blob/888650b271ee4937e2baff6a43bee744635601e3/index.js'
 };
+    }
+    for (const source of sources) {
+      const data = sourceContent[source] || read(source);
+      if (/^(licen[sc]e|copying)/i.test(sourceNames[source] || 
path.basename(source)) && data.length < 300) {
+        throw new Error(`license text too short; a link or SPDX id cannot 
replace the full text: ${source}`);
+      }
+      const relative = sourceNames[source] || (sourceURLs[source] ? 'LICENSE' 
: slash(path.relative(dir, source)));
+      const target = 
`legal/licenses/${pkg.name}@${pkg.version}/${relative}.txt`;
+      if (files.has(target) && !files.get(target).equals(data)) throw new 
Error(`conflicting license file: ${target}`);
+      files.set(target, data);
+      component.files.push({ path: target, source: sourceURLs[source] || 
slash(path.relative(base, source)), sha256: sha(data) });
+      if (/^notice([._-]|$)/i.test(path.basename(source))) {
+        notice += `\n--- ${pkg.name} ${pkg.version} / ${relative} 
---\n${data}\n`;
+      }
+    }
+    components.push(component);
+    license += `\n${pkg.name} ${pkg.version} (${pkg.license}): 
legal/licenses/${pkg.name}@${pkg.version}/\n`;
+  }
+  files.set('LICENSE', Buffer.from(license));
+  files.set('NOTICE', Buffer.from(notice));
+  return { files, components, modules: ids };
+}
+
+export function distributionLicenses() {
+  let base;
+  return {
+    name: 'distribution-licenses',
+    apply: 'build',
+    enforce: 'post',
+    configResolved(config) { base = config.root; },
+    generateBundle(_options, bundle) {
+      const ids = new Set();
+      for (const item of Object.values(bundle)) {
+        if (item.type !== 'chunk') continue;
+        for (const [id, module] of Object.entries(item.modules)) {
+          if (module.renderedLength > 0 || /\.(css|svg)(\?|$)/.test(id)) {
+            ids.add(id.startsWith('\0') ? id : slash(path.relative(base, id)));
+          }
+        }
+      }
+      const result = collectLicenses([...ids], base);
+      const outputFiles = {};
+      for (const [name, item] of Object.entries(bundle)) {
+        outputFiles[name] = sha(item.type === 'chunk' ? item.code : 
item.source);
+      }
+      const manifest = { modules: result.modules, components: 
result.components, outputFiles, files: {} };
+      for (const [name, data] of result.files) {
+        manifest.files[name] = sha(data);
+        this.emitFile({ type: 'asset', fileName: name, source: data });
+      }
+      this.emitFile({ type: 'asset', fileName: 'legal/manifest.json', source: 
`${JSON.stringify(manifest, null, 2)}\n` });
+    },
+  };
+}
+
+export function checkDistribution(directory = path.join(root, 'dist'), base = 
root) {
+  const manifest = json(path.join(directory, 'legal/manifest.json'));
+  const result = collectLicenses(manifest.modules, base);
+  if (JSON.stringify(manifest.components) !== 
JSON.stringify(result.components)) throw new Error('dependency versions/license 
texts changed; a rebuild is required');
+  if (Object.keys(manifest.files).length !== result.files.size) throw new 
Error('the license manifest is incomplete');
+  for (const [name, data] of result.files) {
+    if (manifest.files[name] !== sha(data) || !read(path.join(directory, 
name)).equals(data)) throw new Error(`legal material modified or missing: 
${name}`);
+  }
+  for (const [name, checksum] of Object.entries(manifest.outputFiles)) {
+    const file = path.resolve(directory, name);
+    if (!file.startsWith(`${path.resolve(directory)}${path.sep}`) || 
sha(read(file)) !== checksum) throw new Error(`build artifact verification 
failed: ${name}`);
+  }
+  if (!statSync(path.join(directory, 'LICENSE')).isFile()) throw new 
Error('missing LICENSE');
+  console.log(`web license check passed: ${result.components.length} actual 
components, ${result.files.size} license files`);
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === 
fileURLToPath(import.meta.url)) {
+  try {
+    if (process.argv[2] === 'audit') {
+      // Only pre-check the materials of installed candidate dependencies; 
this set is not used for the binary attribution manifest.
+      const lock = json(path.join(root, 'package-lock.json'));
+      const candidates = Object.entries(lock.packages)
+        .filter(([name, pkg]) => name.includes('node_modules/') && !pkg.dev && 
existsSync(path.join(root, name, 'package.json')))
+        .map(([name]) => `${name}/index.js`);
+      candidates.push('src/assets/model-logos/openai.svg', 
'node_modules/vite/index.js', 'node_modules/tailwindcss/index.js');
+      const errors = [];
+      for (const id of candidates) {
+        try { collectLicenses([id]); } catch (error) { 
errors.push(error.message); }
+      }
+      console.log(`license pre-check: ${candidates.length} candidate 
components, ${errors.length} gaps; the final distribution manifest is still 
based on the actually bundled modules`);
+      if (errors.length) throw new Error(errors.join('\n'));
+    } else {
+      if (process.argv[2] !== 'check') throw new Error('usage: node 
scripts/licenses.mjs check [dist] | audit');
+      checkDistribution(process.argv[3]);
+    }
+  } catch (error) {
+    console.error(`license gate: ${error.message}`);
+    process.exitCode = 1;
+  }
+}
diff --git a/web/scripts/licenses.test.mjs b/web/scripts/licenses.test.mjs
new file mode 100644
index 000000000..75bb3188d
--- /dev/null
+++ b/web/scripts/licenses.test.mjs
@@ -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.
+ */
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 
'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import test from 'node:test';
+import { build } from 'vite';
+import { checkDistribution, collectLicenses, distributionLicenses } from 
'./licenses.mjs';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+function temporary(t) {
+  const directory = mkdtempSync(path.join(root, 
'node_modules/.license-test-'));
+  t.after(() => rmSync(directory, { recursive: true, force: true }));
+  return directory;
+}
+function write(directory, name, content) {
+  const target = path.join(directory, name);
+  mkdirSync(path.dirname(target), { recursive: true });
+  writeFileSync(target, content);
+}
+
+test('vendoredSvgAndRuntimeOnlyTest', () => {
+  const result = collectLicenses(['src/assets/model-logos/openai.svg', 
'node_modules/react/index.js']);
+  assert.deepEqual(result.components.map((c) => c.name), 
['@lobehub/icons-static-svg', 'react']);
+  assert.match(result.files.get('NOTICE').toString(), /Copyright \(c\) 2023 
LobeHub/);
+  
assert.match(result.files.get('legal/licenses/@lobehub/[email protected]/LICENSE.txt').toString(),
 /THE SOFTWARE IS PROVIDED "AS IS"/);
+  assert.doesNotMatch(result.files.get('LICENSE').toString(), 
/src\/assets\/model-logos/);
+});
+
+test('upstreamFallbacksKeepCompleteTextTest', () => {
+  const result = 
collectLicenses(['node_modules/@ant-design/icons-svg/index.js', 
'node_modules/toggle-selection/index.js',
+    'node_modules/agent-base/index.js', 
'node_modules/https-proxy-agent/index.js']);
+  assert([...result.files.keys()].every((name) => !name.endsWith('.svg.txt')));
+  
assert.match(result.files.get('legal/licenses/[email protected]/LICENSE-from-README.txt').toString(),
 /Copyright \(c\) 2013 Nathan Rajlich/);
+  
assert.match(result.files.get('legal/licenses/[email protected]/ATTRIBUTION.txt').toString(),
 /shvaikalesh/);
+  
assert.match(result.files.get('legal/licenses/@ant-design/[email protected]/LICENSE.txt').toString(),
 /2018-present Ant UED/);
+});
+
+test('missingOrUnknownLicenseFailsTest', (t) => {
+  const directory = temporary(t);
+  for (const name of ['LICENSE', 'NOTICE']) write(directory, name, 
readFileSync(path.join(root, name)));
+  write(directory, 'node_modules/example/package.json', JSON.stringify({ name: 
'example', version: '1', license: 'MIT' }));
+  assert.throws(() => collectLicenses(['node_modules/example/index.js'], 
directory), /missing the complete upstream license/);
+  write(directory, 'node_modules/example/LICENSE', 'MIT');
+  assert.throws(() => collectLicenses(['node_modules/example/index.js'], 
directory), /license text too short/);
+  write(directory, 'node_modules/example/package.json', JSON.stringify({ name: 
'example', version: '1', license: 'UNLICENSED' }));
+  assert.throws(() => collectLicenses(['node_modules/example/index.js'], 
directory), /manual review/);
+});
+
+test('vitePackagingAndTamperGateTest', async (t) => {
+  const directory = temporary(t);
+  // Build only fixtures for React, CSS, SVG and small dependencies; do not 
build the app or run app tests.
+  const result = await build({
+    root,
+    configFile: false,
+    logLevel: 'error',
+    plugins: [
+      {
+        name: 'license-fixture',
+        resolveId(id) { if (id === 'license-fixture') return 
'\0license-fixture'; },
+        load(id) {
+          if (id === '\0license-fixture') return `import React from 
'${root}node_modules/react/index.js'; import logo from 
'${root}src/assets/model-logos/openai.svg'; import '${root}src/index.css'; 
import toggle from '${root}node_modules/toggle-selection/index.js'; 
console.log(React, logo, toggle);`;
+        },
+      },
+      distributionLicenses(),
+    ],
+    build: { write: false, minify: false, rollupOptions: { input: 
'license-fixture' } },
+  });
+  for (const output of result.output) write(directory, output.fileName, 
output.type === 'chunk' ? output.code : output.source);
+  checkDistribution(directory);
+  const manifest = JSON.parse(readFileSync(path.join(directory, 
'legal/manifest.json')));
+  assert(manifest.components.some((c) => c.name === 'react'));
+  assert(manifest.components.some((c) => c.name === 
'@lobehub/icons-static-svg'));
+  assert(manifest.components.some((c) => c.name === 'tailwindcss' && c.reason 
=== 'bundled-preflight-css'));
+  assert(manifest.components.some((c) => c.name === 'toggle-selection'));
+  assert(!manifest.components.some((c) => c.name === 'vitest' || c.name === 
'eslint'));
+  write(directory, 'NOTICE', 'tampered');
+  assert.throws(() => checkDistribution(directory), /modified or missing/);
+  write(directory, 'NOTICE', result.output.find((item) => item.fileName === 
'NOTICE').source);
+  const output = Object.keys(manifest.outputFiles)[0];
+  write(directory, output, 'tampered');
+  assert.throws(() => checkDistribution(directory), /build artifact 
verification failed/);
+});
diff --git a/web/src/assets/model-logos/LICENSE 
b/web/src/assets/model-logos/LICENSE
index 758e68d9a..7db4f140a 100644
--- a/web/src/assets/model-logos/LICENSE
+++ b/web/src/assets/model-logos/LICENSE
@@ -1,4 +1,21 @@
-Model brand icons in this directory are from lobehub/lobe-icons
-(https://github.com/lobehub/lobe-icons), licensed under the MIT License.
-Copyright (c) 2023-2026 LobeHub. All brand marks remain the property of
-their respective owners and are used here as small status badges only.
+MIT License
+
+Copyright (c) 2023 LobeHub
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 6cf189487..09bcb39fc 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { App, Modal, message } from 'antd';
+import { App, Modal } from 'antd';
 import { act, cleanup, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import type React from 'react';
@@ -325,10 +325,12 @@ describe('Consumer page', () => {
     expect(screen.queryByRole('button', { name: /删除 \(1\)$/ 
})).not.toBeInTheDocument();
   });
 
-  afterEach(() => {
-    cleanup();
-    Modal.destroyAll();
-    message.destroy();
+  afterEach(async () => {
+    await act(async () => {
+      cleanup();
+      Modal.destroyAll();
+    });
+    // 静态提示由全局清理卸载,避免冷调用 destroy 再创建异步 root。
   });
 
   it('clamps back to a valid page when the current page becomes empty after a 
delete', async () => {
diff --git a/web/src/pages/settings/AboutTab.tsx 
b/web/src/pages/settings/AboutTab.tsx
index f08ab7473..3042044fc 100644
--- a/web/src/pages/settings/AboutTab.tsx
+++ b/web/src/pages/settings/AboutTab.tsx
@@ -23,12 +23,12 @@ const { Title, Text, Link: TypoLink } = Typography;
 export const AboutTab = () => (
   <div style={{ maxWidth: 800 }}>
     <Descriptions column={1} bordered size="small">
-      <Descriptions.Item label="版本">0.1.0</Descriptions.Item>
+      <Descriptions.Item label="版本">3.0.0</Descriptions.Item>
       <Descriptions.Item label="构建提交">{__BUILD_COMMIT__}</Descriptions.Item>
       <Descriptions.Item label="构建时间">{__BUILD_TIME__}</Descriptions.Item>
       <Descriptions.Item label="RocketMQ 支持版本">4.x / 5.x</Descriptions.Item>
       <Descriptions.Item label="前端框架">React 18 + Ant Design 
5</Descriptions.Item>
-      <Descriptions.Item label="后端框架">Spring Boot 3 + RocketMQ MCP 
Server</Descriptions.Item>
+      <Descriptions.Item label="后端框架">Spring Boot 4.1 + RocketMQ MCP 
Server</Descriptions.Item>
       <Descriptions.Item label="License">Apache 2.0</Descriptions.Item>
     </Descriptions>
 
diff --git a/web/src/test/setup.test.ts b/web/src/test/setup.test.ts
new file mode 100644
index 000000000..2aa92aa34
--- /dev/null
+++ b/web/src/test/setup.test.ts
@@ -0,0 +1,134 @@
+/*
+ * 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 { act, renderHook } from '@testing-library/react';
+import { message, notification } from 'antd';
+import { createElement, useEffect } from 'react';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+
+const realSetTimeout = globalThis.setTimeout;
+
+// 故意跨用例检查全局 afterEach 的结果,不能在断言前主动清理或用 act 冲掉遗留任务。
+describe.sequential('全局提示清理生命周期', () => {
+  const errors = vi.spyOn(console, 'error');
+  const warnings = vi.spyOn(console, 'warn');
+  const fragments = vi.spyOn(document, 'createDocumentFragment');
+  const onClose = vi.fn();
+  const onUnmount = vi.fn();
+
+  function Notice({ text }: { text: string }) {
+    useEffect(() => () => onUnmount(), []);
+    return text;
+  }
+
+  beforeAll(() => {
+    // 保留真实微任务与 React 调度器,仅记录提示定时器和动画帧。
+    vi.useFakeTimers({
+      toFake: [
+        'setTimeout',
+        'clearTimeout',
+        'setInterval',
+        'clearInterval',
+        'requestAnimationFrame',
+        'cancelAnimationFrame',
+      ],
+    });
+  });
+
+  function expectClean() {
+    expect(document.querySelector('.ant-message, 
.ant-notification')).toBeNull();
+    expect(vi.getTimerCount()).toBe(0);
+    // spy 默认透传日志;任何警告仍会打印并导致回归失败。
+    expect(errors).not.toHaveBeenCalled();
+    expect(warnings).not.toHaveBeenCalled();
+    expect(onClose).not.toHaveBeenCalled();
+  }
+
+  async function expectSettled() {
+    expectClean();
+    // 给微任务和真实事件循环一次运行机会,确认不是仅在清理返回瞬间为空。
+    await new Promise<void>((resolve) => realSetTimeout(resolve, 0));
+    await vi.advanceTimersByTimeAsync(10_000);
+    expectClean();
+  }
+
+  afterAll(async () => {
+    try {
+      await expectSettled();
+    } finally {
+      vi.useRealTimers();
+      errors.mockRestore();
+      warnings.mockRestore();
+      fragments.mockRestore();
+    }
+  });
+
+  it('coldCleanupWithoutOpeningNoticesTest', () => {
+    expectClean();
+  });
+
+  it('coldCleanupLeavesNoAsyncWorkTest', async () => {
+    await expectSettled();
+    expect(fragments).not.toHaveBeenCalled();
+  });
+
+  it('opensTimedNoticesForGlobalCleanupTest', async () => {
+    await act(async () => {
+      message.info({
+        content: createElement(Notice, { text: '定时消息' }),
+        duration: 3,
+        onClose,
+      });
+      notification.open({
+        message: createElement(Notice, { text: '定时通知' }),
+        duration: 3,
+        onClose,
+      });
+    });
+    expect(document.querySelector('.ant-message')).not.toBeNull();
+    expect(document.querySelector('.ant-notification')).not.toBeNull();
+    expect(vi.getTimerCount()).toBeGreaterThan(0);
+  });
+
+  it('timedCleanupLeavesNoAsyncWorkAndSupportsReuseTest', async () => {
+    await expectSettled();
+    expect(onUnmount).toHaveBeenCalledTimes(2);
+    await act(async () => {
+      message.loading({ content: '常驻消息', duration: 0, onClose });
+      notification.open({ message: '常驻通知', duration: 0, onClose });
+    });
+    expect(document.body).toHaveTextContent('常驻消息');
+    expect(document.body).toHaveTextContent('常驻通知');
+  });
+
+  it('cleansNoticesCreatedDuringComponentUnmountTest', async () => {
+    await expectSettled();
+    renderHook(() =>
+      useEffect(
+        () => () => {
+          message.info({ content: '卸载消息', duration: 3, onClose });
+          notification.open({ message: '卸载通知', duration: 3, onClose });
+        },
+        [],
+      ),
+    );
+  });
+
+  it('unmountCleanupLeavesNoAsyncWorkTest', async () => {
+    await expectSettled();
+  });
+});
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
index 02fa8a0ef..4617a8cf9 100644
--- a/web/src/test/setup.ts
+++ b/web/src/test/setup.ts
@@ -1,22 +1,58 @@
+/*
+ * 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.
+ */
+
 /// <reference types="vitest/globals" />
 import '@testing-library/jest-dom/vitest';
-import { configure } from '@testing-library/react';
-import { message, notification } from 'antd';
+import { act, cleanup, configure } from '@testing-library/react';
+import { unstableSetRender } from 'antd';
+import { actDestroy as resetMessage } from 'antd/lib/message';
+import { actDestroy as resetNotification } from 'antd/lib/notification';
+
+// 透传 antd 原始渲染,仅保存它返回的卸载函数,让静态 holder 也遵循测试生命周期。
+const render = unstableSetRender();
+const unmounts = new Set<() => Promise<void>>();
+unstableSetRender((node, container) => {
+  const unmount = render(node, container);
+  const dispose = async () => {
+    await unmount();
+    unmounts.delete(dispose);
+  };
+  unmounts.add(dispose);
+  return dispose;
+});
 
-// findBy*/waitFor default to 1s, which antd's async rendering exceeds once 
the whole
-// suite runs in parallel.
+// 全量并行时 antd 异步渲染可能超过 findBy*/waitFor 默认的 1 秒。
 configure({ asyncUtilTimeout: 5000 });
 
-// Clean up localStorage between tests
+// 测试之间清理本地存储。
 beforeEach(() => {
   localStorage.clear();
 });
 
-// The static `message`/`notification` APIs render into body-level holders 
that RTL's cleanup
-// does not own: their notices (3s duration plus a leave animation) survive 
into later tests,
-// and their rc-notification timers fire mid-test on an orphaned React root — 
the source of
-// cross-test DOM pollution and "window is not defined" teardown errors under 
parallel load.
-afterEach(() => {
-  message.destroy();
-  notification.destroy();
+// destroy 冷调用也会创建 root,热调用仅开始退场动画;不能用它代替卸载。
+// 先等待组件卸载及其提示队列初始化,再卸载静态 root,取消动画与自动关闭定时器。
+afterEach(async () => {
+  await act(async () => {
+    cleanup();
+  });
+  await act(async () => {
+    await Promise.all([...unmounts].map((unmount) => unmount()));
+  });
+  // 使用 antd 的测试专用重置入口;lib 与 antd 主入口共用同一组静态实例。
+  resetMessage();
+  resetNotification();
 });
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 8cc69c618..70944843f 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,6 +1,23 @@
+/*
+ * 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 { defineConfig } from 'vitest/config';
 import { loadEnv } from 'vite';
 import react from '@vitejs/plugin-react';
+import { distributionLicenses } from './scripts/licenses.mjs';
 
 function formatBuildTime(date: Date): string {
   // Build runs in a UTC container; render the timestamp in UTC+8.
@@ -17,7 +34,7 @@ export default defineConfig(({ mode }) => {
   const buildCommit = env.VITE_GIT_COMMIT || 'dev';
   const buildTime = formatBuildTime(new Date());
   return {
-    plugins: [react()],
+    plugins: [react(), distributionLicenses()],
     define: {
       __BUILD_COMMIT__: JSON.stringify(buildCommit),
       __BUILD_TIME__: JSON.stringify(buildTime),
@@ -53,6 +70,8 @@ export default defineConfig(({ mode }) => {
       globals: true,
       environment: 'jsdom',
       setupFiles: './src/test/setup.ts',
+      // Scope vitest to src/; scripts/*.test.mjs are node:test files run by 
`npm run license:test`.
+      include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
       css: true,
       // antd interactions driven through userEvent are slow in jsdom, and the 
default
       // 5s budget is exceeded once the whole suite runs in parallel.

Reply via email to