diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff76cbe --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# MyScaleDB Config +MYSCALE_HOST=127.0.0.1 +MYSCALE_PORT=8123 +MYSCALE_USER=default +MYSCALE_DATABASE=default +MYSCALE_PASSWORD="" +MYSCALE_SECURE=false +MYSCALE_VERIFY=false + +# Pgvector Config +PGVECTOR_ENABLED=False +PGVECTOR_HOST=localhost +PGVECTOR_PORT=5432 +PGVECTOR_USER=postgres +PGVECTOR_PASSWORD=postgres +PGVECTOR_DATABASE=vectordb +PGVECTOR_SSLMODE=disable + +# CHDB Config +CHDB_ENABLED=False +CHDB_DATA_PATH=:memory: + +# Mcp Config +MCP_SERVER_TRANSPORT=http +MCP_BIND_HOST=0.0.0.0 +MCP_BIND_PORT=4200 + +# TextToVectorSql Config +TEXT2VECSQL_ENABLED=False +TEXT2VEC_SQL_API="" +TEXT2VEC_SQL_URL="" diff --git a/Dockerfile b/Dockerfile index bd04dab..204f313 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# origin-hub-ai-registry.cn-shanghai.cr.aliyuncs.com/component/mcp-sqlvectordb:0.0.6 # Build stage - Use a Python image with uv pre-installed FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder diff --git a/README.md b/README.md index e69de29..6bc8889 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,21 @@ +# How To Run + +1. Configuare local service env: +```bash +cp .env.example .env +``` + +Modify ref env config + + +2. Run Mcp Server + +```bash +# init runtime env +uv sync --all-extras --dev + +# run mcp server +uv run python -m mcp_server.main +``` + +3. Regist Mcp Tools in Dify diff --git a/deploy/README.md b/deploy/README.md index ac45f66..4e41f53 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -8,9 +8,7 @@ deploy/ ├── README.md # 本文件 ├── myscale.yaml # MyScale 向量数据库部署 -├── pgvector.yaml # PgVector 向量数据库部署 -├── mcp-server.yaml # MCP Server 服务部署 -└── dify.yaml # Dify AI 应用部署 +└── mcp-server.yaml # MCP Server 服务部署 ``` ## 组件说明 @@ -21,47 +19,34 @@ deploy/ - **端口**: 8123 (HTTP), 9000 (Native) - **存储**: 20Gi PVC -### 2. PgVector (pgvector.yaml) -- **用途**: PostgreSQL + pgvector 扩展的向量数据库 -- **镜像**: `pgvector/pgvector:pg16` -- **端口**: 5432 -- **存储**: 10Gi PVC -- **特性**: 包含初始化脚本,自动创建示例表和索引 ### 3. MCP Server (mcp-server.yaml) - **用途**: Model Context Protocol 服务器 -- **镜像**: `origin-hub-ai-registry.cn-shanghai.cr.aliyuncs.com/component/mcp-sqlvectordb:0.0.1` +- **镜像**: `origin-hub-ai-registry.cn-shanghai.cr.aliyuncs.com/component/mcp-sqlvectordb:0.0.6` - **端口**: 4200 - **副本数**: 2 - **健康检查**: `/health` 端点 -### 4. Dify (dify.yaml) -- **用途**: AI 应用开发平台 -- **镜像**: `langgenius/dify-api:0.6.13`, `langgenius/dify-web:0.6.13` -- **组件**: - - dify-postgres: PostgreSQL 数据库 - - dify-redis: Redis 缓存 - - dify-api: API 服务 (2 副本) - - dify-worker: 后台任务处理 (2 副本) - - dify-web: Web 前端 (2 副本) - - dify-nginx: 反向代理 (2 副本) - ## 部署步骤 ### 前置要求 1. 一个运行中的 Kubernetes 集群 (v1.19+) 2. 配置好的 `kubectl` 命令行工具 -3. 集群中已安装 StorageClass (默认使用 `standard`) -4. (可选) 安装了 cert-manager 用于自动 TLS 证书管理 -5. (可选) 安装了 Nginx Ingress Controller ### 1. 创建命名空间 -所有服务都部署在 `mcp-system` 命名空间中: +所有服务都部署在 `mcp-sqlvdb` 命名空间中: + +```bash +kubectl create namespace mcp-sqlvdb +``` +### 2. 创建配置文件 configmap + +TextToVectrorSql 需要使用 Api 访问,我们需要将一些敏感信息配置到 config map 中 ```bash -kubectl create namespace mcp-system +kubectl apply -f secrets.yaml ``` ### 2. 部署数据库服务 @@ -71,17 +56,9 @@ kubectl create namespace mcp-system ```bash # 部署 MyScale kubectl apply -f myscale.yaml - -# 部署 PgVector -kubectl apply -f pgvector.yaml ``` -等待数据库服务就绪: - -```bash -kubectl wait --for=condition=ready pod -l app=myscale -n mcp-system --timeout=300s -kubectl wait --for=condition=ready pod -l app=pgvector -n mcp-system --timeout=300s -``` +等待数据库服务就绪 ### 3. 部署 MCP Server @@ -89,344 +66,16 @@ kubectl wait --for=condition=ready pod -l app=pgvector -n mcp-system --timeout=3 kubectl apply -f mcp-server.yaml ``` -等待 MCP Server 就绪: - -```bash -kubectl wait --for=condition=ready pod -l app=mcp-server -n mcp-system --timeout=120s -``` - -### 4. 部署 Dify - -```bash -kubectl apply -f dify.yaml -``` - -等待所有 Dify 组件就绪: - -```bash -kubectl wait --for=condition=ready pod -l app=dify-postgres -n mcp-system --timeout=120s -kubectl wait --for=condition=ready pod -l app=dify-redis -n mcp-system --timeout=120s -kubectl wait --for=condition=ready pod -l app=dify-api -n mcp-system --timeout=180s -kubectl wait --for=condition=ready pod -l app=dify-web -n mcp-system --timeout=120s -kubectl wait --for=condition=ready pod -l app=dify-nginx -n mcp-system --timeout=120s -``` - ## 验证部署 -### 检查所有 Pod 状态 - -```bash -kubectl get pods -n mcp-system -``` - -预期输出应该显示所有 Pod 都处于 `Running` 状态。 - -### 检查服务 - -```bash -kubectl get svc -n mcp-system -``` - ### 测试 MCP Server ```bash -# 端口转发 -kubectl port-forward svc/mcp-server 4200:4200 -n mcp-system - -# 在另一个终端测试健康检查 -curl http://localhost:4200/health -``` - -### 测试 Dify - -```bash -# 端口转发 -kubectl port-forward svc/dify-nginx 8080:80 -n mcp-system - -# 在浏览器中访问 -# http://localhost:8080 -``` - -## 配置 Ingress - -### MCP Server Ingress - -编辑 `mcp-server.yaml` 中的 Ingress 配置: - -```yaml -spec: - tls: - - hosts: - - mcp-server.yourdomain.com # 修改为你的域名 - secretName: mcp-server-tls - rules: - - host: mcp-server.yourdomain.com # 修改为你的域名 -``` - -### Dify Ingress - -编辑 `dify.yaml` 中的 Ingress 配置和环境变量: - -```yaml -# 在 dify-config ConfigMap 中更新 -NEXT_PUBLIC_PUBLIC_API_URL: "https://dify.yourdomain.com" # 修改为你的域名 - -# 在 Ingress 中更新 -spec: - tls: - - hosts: - - dify.yourdomain.com # 修改为你的域名 -``` - -重新应用配置: - -```bash -kubectl apply -f mcp-server.yaml -kubectl apply -f dify.yaml -``` - -## 持久化存储 - -所有服务都使用 PersistentVolumeClaim (PVC) 来持久化数据: - -- **myscale-data-pvc**: 20Gi -- **pgvector-data-pvc**: 10Gi -- **dify-postgres-pvc**: 5Gi -- **dify-redis-pvc**: 2Gi -- **dify-storage-pvc**: 10Gi - -### 查看 PVC - -```bash -kubectl get pvc -n mcp-system -``` - -### 修改存储大小 - -如果需要更大的存储空间,编辑对应的 yaml 文件中的存储请求: - -```yaml -spec: - resources: - requests: - storage: 50Gi # 修改为所需大小 -``` - -## 资源配额 - -### 当前配置 - -| 服务 | CPU 请求 | CPU 限制 | 内存请求 | 内存限制 | -|------|----------|----------|----------|----------| -| MyScale | 1000m | 2000m | 2Gi | 4Gi | -| PgVector | 500m | 1000m | 1Gi | 2Gi | -| MCP Server | 200m | 500m | 256Mi | 512Mi | -| Dify API | 250m | 1000m | 512Mi | 2Gi | -| Dify Worker | 250m | 1000m | 512Mi | 2Gi | -| Dify Web | 100m | 500m | 256Mi | 512Mi | -| Dify Nginx | 100m | 200m | 128Mi | 256Mi | - -### 调整资源 - -根据实际负载,可以在各个 Deployment 中调整资源配额: - -```yaml -resources: - requests: - memory: "512Mi" - cpu: "250m" - limits: - memory: "1Gi" - cpu: "500m" -``` - -## 扩容 - -### 水平扩容 - -```bash -# 扩展 MCP Server -kubectl scale deployment mcp-server --replicas=5 -n mcp-system - -# 扩展 Dify API -kubectl scale deployment dify-api --replicas=4 -n mcp-system -``` - -### 自动扩容 (HPA) - -创建 HorizontalPodAutoscaler: - -```bash -# MCP Server 自动扩容 -kubectl autoscale deployment mcp-server \ - --cpu-percent=70 \ - --min=2 \ - --max=10 \ - -n mcp-system +# 查看 Mcp server ip 信息 +kubectl get svc -n mcp-sqlvdb -# Dify API 自动扩容 -kubectl autoscale deployment dify-api \ - --cpu-percent=70 \ - --min=2 \ - --max=10 \ - -n mcp-system +# 检测 Mcp server 是否可用 +curl http://x.x.x.x:4200/health ``` -## 监控和日志 - -### 查看日志 - -```bash -# MCP Server 日志 -kubectl logs -f deployment/mcp-server -n mcp-system - -# Dify API 日志 -kubectl logs -f deployment/dify-api -n mcp-system - -# MyScale 日志 -kubectl logs -f deployment/myscale -n mcp-system -``` - -### 查看事件 - -```bash -kubectl get events -n mcp-system --sort-by='.lastTimestamp' -``` - -## 备份和恢复 - -### 备份数据库 - -```bash -# 备份 PgVector -kubectl exec -it deployment/pgvector -n mcp-system -- \ - pg_dump -U postgres vectordb > pgvector_backup.sql - -# 备份 Dify PostgreSQL -kubectl exec -it deployment/dify-postgres -n mcp-system -- \ - pg_dump -U postgres dify > dify_backup.sql -``` - -### 恢复数据库 - -```bash -# 恢复 PgVector -cat pgvector_backup.sql | kubectl exec -i deployment/pgvector -n mcp-system -- \ - psql -U postgres vectordb - -# 恢复 Dify PostgreSQL -cat dify_backup.sql | kubectl exec -i deployment/dify-postgres -n mcp-system -- \ - psql -U postgres dify -``` - -## 清理 - -### 删除所有部署 - -```bash -kubectl delete -f dify.yaml -kubectl delete -f mcp-server.yaml -kubectl delete -f pgvector.yaml -kubectl delete -f myscale.yaml -``` - -### 删除命名空间 (会删除所有资源,包括 PVC) - -```bash -kubectl delete namespace mcp-system -``` - -⚠️ **警告**: 删除 PVC 会永久删除所有数据! - -## 故障排查 - -### Pod 无法启动 - -```bash -# 查看 Pod 详情 -kubectl describe pod -n mcp-system - -# 查看 Pod 日志 -kubectl logs -n mcp-system -``` - -### 服务连接问题 - -```bash -# 测试服务连接 -kubectl run -it --rm debug --image=busybox --restart=Never -n mcp-system -- sh - -# 在 debug pod 中测试 -nc -zv myscale.mcp-system.svc.cluster.local 8123 -nc -zv pgvector.mcp-system.svc.cluster.local 5432 -nc -zv mcp-server.mcp-system.svc.cluster.local 4200 -``` - -### PVC 挂载问题 - -```bash -# 检查 PVC 状态 -kubectl get pvc -n mcp-system - -# 检查 StorageClass -kubectl get storageclass -``` - -## 安全建议 - -1. **修改默认密码**: 在生产环境中,务必修改所有默认密码 -2. **使用 Secrets**: 将敏感信息从 ConfigMap 迁移到 Secrets -3. **网络策略**: 配置 NetworkPolicy 限制 Pod 之间的通信 -4. **TLS 加密**: 为所有服务启用 TLS -5. **RBAC**: 配置适当的角色和权限 - -### 示例: 创建 Secret - -```bash -# 创建 PgVector 密码 Secret -kubectl create secret generic pgvector-secret \ - --from-literal=password=your-strong-password \ - -n mcp-system - -# 在 Deployment 中使用 -env: -- name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: pgvector-secret - key: password -``` - -## 性能优化 - -1. **数据库连接池**: 配置适当的连接池大小 -2. **缓存策略**: 利用 Redis 缓存频繁访问的数据 -3. **索引优化**: 为常用查询创建合适的索引 -4. **资源限制**: 根据实际负载调整资源配额 -5. **水平扩展**: 使用 HPA 自动扩展服务 - -## 更新策略 - -所有 Deployment 默认使用 RollingUpdate 策略: - -```yaml -strategy: - type: RollingUpdate - rollingUpdate: - maxSurge: 1 - maxUnavailable: 0 -``` - -这确保了零停机时间的更新。 - -## 支持 - -如有问题,请: -1. 检查日志和事件 -2. 参考故障排查部分 -3. 提交 Issue 到项目仓库 - -## 许可证 - -请参考项目根目录的 LICENSE 文件。 - +验证 Mcp Server 可用之后,即可在 dify 中注册 mcp 服务,注册地址为:`http://x.x.x.x:4200/mcp` \ No newline at end of file diff --git a/deploy/get_helm.sh b/deploy/get_helm.sh deleted file mode 100755 index e4b12c2..0000000 --- a/deploy/get_helm.sh +++ /dev/null @@ -1,347 +0,0 @@ -#!/usr/bin/env bash - -# Copyright The Helm Authors. -# -# Licensed 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. - -# The install script is based off of the MIT-licensed script from glide, -# the package manager for Go: https://github.com/Masterminds/glide.sh/blob/master/get - -: ${BINARY_NAME:="helm"} -: ${USE_SUDO:="true"} -: ${DEBUG:="false"} -: ${VERIFY_CHECKSUM:="true"} -: ${VERIFY_SIGNATURES:="false"} -: ${HELM_INSTALL_DIR:="/usr/local/bin"} -: ${GPG_PUBRING:="pubring.kbx"} - -HAS_CURL="$(type "curl" &> /dev/null && echo true || echo false)" -HAS_WGET="$(type "wget" &> /dev/null && echo true || echo false)" -HAS_OPENSSL="$(type "openssl" &> /dev/null && echo true || echo false)" -HAS_GPG="$(type "gpg" &> /dev/null && echo true || echo false)" -HAS_GIT="$(type "git" &> /dev/null && echo true || echo false)" -HAS_TAR="$(type "tar" &> /dev/null && echo true || echo false)" - -# initArch discovers the architecture for this system. -initArch() { - ARCH=$(uname -m) - case $ARCH in - armv5*) ARCH="armv5";; - armv6*) ARCH="armv6";; - armv7*) ARCH="arm";; - aarch64) ARCH="arm64";; - x86) ARCH="386";; - x86_64) ARCH="amd64";; - i686) ARCH="386";; - i386) ARCH="386";; - esac -} - -# initOS discovers the operating system for this system. -initOS() { - OS=$(echo `uname`|tr '[:upper:]' '[:lower:]') - - case "$OS" in - # Minimalist GNU for Windows - mingw*|cygwin*) OS='windows';; - esac -} - -# runs the given command as root (detects if we are root already) -runAsRoot() { - if [ $EUID -ne 0 -a "$USE_SUDO" = "true" ]; then - sudo "${@}" - else - "${@}" - fi -} - -# verifySupported checks that the os/arch combination is supported for -# binary builds, as well whether or not necessary tools are present. -verifySupported() { - local supported="darwin-amd64\ndarwin-arm64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-loong64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64\nwindows-arm64" - if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then - echo "No prebuilt binary for ${OS}-${ARCH}." - echo "To build from source, go to https://github.com/helm/helm" - exit 1 - fi - - if [ "${HAS_CURL}" != "true" ] && [ "${HAS_WGET}" != "true" ]; then - echo "Either curl or wget is required" - exit 1 - fi - - if [ "${VERIFY_CHECKSUM}" == "true" ] && [ "${HAS_OPENSSL}" != "true" ]; then - echo "In order to verify checksum, openssl must first be installed." - echo "Please install openssl or set VERIFY_CHECKSUM=false in your environment." - exit 1 - fi - - if [ "${VERIFY_SIGNATURES}" == "true" ]; then - if [ "${HAS_GPG}" != "true" ]; then - echo "In order to verify signatures, gpg must first be installed." - echo "Please install gpg or set VERIFY_SIGNATURES=false in your environment." - exit 1 - fi - if [ "${OS}" != "linux" ]; then - echo "Signature verification is currently only supported on Linux." - echo "Please set VERIFY_SIGNATURES=false or verify the signatures manually." - exit 1 - fi - fi - - if [ "${HAS_GIT}" != "true" ]; then - echo "[WARNING] Could not find git. It is required for plugin installation." - fi - - if [ "${HAS_TAR}" != "true" ]; then - echo "[ERROR] Could not find tar. It is required to extract the helm binary archive." - exit 1 - fi -} - -# checkDesiredVersion checks if the desired version is available. -checkDesiredVersion() { - if [ "x$DESIRED_VERSION" == "x" ]; then - # Get tag from release URL - local latest_release_url="https://get.helm.sh/helm-latest-version" - local latest_release_response="" - if [ "${HAS_CURL}" == "true" ]; then - latest_release_response=$( curl -L --silent --show-error --fail "$latest_release_url" 2>&1 || true ) - elif [ "${HAS_WGET}" == "true" ]; then - latest_release_response=$( wget "$latest_release_url" -q -O - 2>&1 || true ) - fi - TAG=$( echo "$latest_release_response" | grep '^v[0-9]' ) - if [ "x$TAG" == "x" ]; then - printf "Could not retrieve the latest release tag information from %s: %s\n" "${latest_release_url}" "${latest_release_response}" - exit 1 - fi - else - TAG=$DESIRED_VERSION - fi -} - -# checkHelmInstalledVersion checks which version of helm is installed and -# if it needs to be changed. -checkHelmInstalledVersion() { - if [[ -f "${HELM_INSTALL_DIR}/${BINARY_NAME}" ]]; then - local version=$("${HELM_INSTALL_DIR}/${BINARY_NAME}" version --template="{{ .Version }}") - if [[ "$version" == "$TAG" ]]; then - echo "Helm ${version} is already ${DESIRED_VERSION:-latest}" - return 0 - else - echo "Helm ${TAG} is available. Changing from version ${version}." - return 1 - fi - else - return 1 - fi -} - -# downloadFile downloads the latest binary package and also the checksum -# for that binary. -downloadFile() { - HELM_DIST="helm-$TAG-$OS-$ARCH.tar.gz" - DOWNLOAD_URL="https://get.helm.sh/$HELM_DIST" - CHECKSUM_URL="$DOWNLOAD_URL.sha256" - HELM_TMP_ROOT="$(mktemp -dt helm-installer-XXXXXX)" - HELM_TMP_FILE="$HELM_TMP_ROOT/$HELM_DIST" - HELM_SUM_FILE="$HELM_TMP_ROOT/$HELM_DIST.sha256" - echo "Downloading $DOWNLOAD_URL" - if [ "${HAS_CURL}" == "true" ]; then - curl -SsL "$CHECKSUM_URL" -o "$HELM_SUM_FILE" - curl -SsL "$DOWNLOAD_URL" -o "$HELM_TMP_FILE" - elif [ "${HAS_WGET}" == "true" ]; then - wget -q -O "$HELM_SUM_FILE" "$CHECKSUM_URL" - wget -q -O "$HELM_TMP_FILE" "$DOWNLOAD_URL" - fi -} - -# verifyFile verifies the SHA256 checksum of the binary package -# and the GPG signatures for both the package and checksum file -# (depending on settings in environment). -verifyFile() { - if [ "${VERIFY_CHECKSUM}" == "true" ]; then - verifyChecksum - fi - if [ "${VERIFY_SIGNATURES}" == "true" ]; then - verifySignatures - fi -} - -# installFile installs the Helm binary. -installFile() { - HELM_TMP="$HELM_TMP_ROOT/$BINARY_NAME" - mkdir -p "$HELM_TMP" - tar xf "$HELM_TMP_FILE" -C "$HELM_TMP" - HELM_TMP_BIN="$HELM_TMP/$OS-$ARCH/helm" - echo "Preparing to install $BINARY_NAME into ${HELM_INSTALL_DIR}" - runAsRoot cp "$HELM_TMP_BIN" "$HELM_INSTALL_DIR/$BINARY_NAME" - echo "$BINARY_NAME installed into $HELM_INSTALL_DIR/$BINARY_NAME" -} - -# verifyChecksum verifies the SHA256 checksum of the binary package. -verifyChecksum() { - printf "Verifying checksum... " - local sum=$(openssl sha1 -sha256 ${HELM_TMP_FILE} | awk '{print $2}') - local expected_sum=$(cat ${HELM_SUM_FILE}) - if [ "$sum" != "$expected_sum" ]; then - echo "SHA sum of ${HELM_TMP_FILE} does not match. Aborting." - exit 1 - fi - echo "Done." -} - -# verifySignatures obtains the latest KEYS file from GitHub main branch -# as well as the signature .asc files from the specific GitHub release, -# then verifies that the release artifacts were signed by a maintainer's key. -verifySignatures() { - printf "Verifying signatures... " - local keys_filename="KEYS" - local github_keys_url="https://raw.githubusercontent.com/helm/helm/main/${keys_filename}" - if [ "${HAS_CURL}" == "true" ]; then - curl -SsL "${github_keys_url}" -o "${HELM_TMP_ROOT}/${keys_filename}" - elif [ "${HAS_WGET}" == "true" ]; then - wget -q -O "${HELM_TMP_ROOT}/${keys_filename}" "${github_keys_url}" - fi - local gpg_keyring="${HELM_TMP_ROOT}/keyring.gpg" - local gpg_homedir="${HELM_TMP_ROOT}/gnupg" - mkdir -p -m 0700 "${gpg_homedir}" - local gpg_stderr_device="/dev/null" - if [ "${DEBUG}" == "true" ]; then - gpg_stderr_device="/dev/stderr" - fi - gpg --batch --quiet --homedir="${gpg_homedir}" --import "${HELM_TMP_ROOT}/${keys_filename}" 2> "${gpg_stderr_device}" - gpg --batch --no-default-keyring --keyring "${gpg_homedir}/${GPG_PUBRING}" --export > "${gpg_keyring}" - local github_release_url="https://github.com/helm/helm/releases/download/${TAG}" - if [ "${HAS_CURL}" == "true" ]; then - curl -SsL "${github_release_url}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" -o "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" - curl -SsL "${github_release_url}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" -o "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" - elif [ "${HAS_WGET}" == "true" ]; then - wget -q -O "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" "${github_release_url}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" - wget -q -O "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" "${github_release_url}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" - fi - local error_text="If you think this might be a potential security issue," - error_text="${error_text}\nplease see here: https://github.com/helm/community/blob/master/SECURITY.md" - local num_goodlines_sha=$(gpg --verify --keyring="${gpg_keyring}" --status-fd=1 "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" 2> "${gpg_stderr_device}" | grep -c -E '^\[GNUPG:\] (GOODSIG|VALIDSIG)') - if [[ ${num_goodlines_sha} -lt 2 ]]; then - echo "Unable to verify the signature of helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256!" - echo -e "${error_text}" - exit 1 - fi - local num_goodlines_tar=$(gpg --verify --keyring="${gpg_keyring}" --status-fd=1 "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" 2> "${gpg_stderr_device}" | grep -c -E '^\[GNUPG:\] (GOODSIG|VALIDSIG)') - if [[ ${num_goodlines_tar} -lt 2 ]]; then - echo "Unable to verify the signature of helm-${TAG}-${OS}-${ARCH}.tar.gz!" - echo -e "${error_text}" - exit 1 - fi - echo "Done." -} - -# fail_trap is executed if an error occurs. -fail_trap() { - result=$? - if [ "$result" != "0" ]; then - if [[ -n "$INPUT_ARGUMENTS" ]]; then - echo "Failed to install $BINARY_NAME with the arguments provided: $INPUT_ARGUMENTS" - help - else - echo "Failed to install $BINARY_NAME" - fi - echo -e "\tFor support, go to https://github.com/helm/helm." - fi - cleanup - exit $result -} - -# testVersion tests the installed client to make sure it is working. -testVersion() { - set +e - HELM="$(command -v $BINARY_NAME)" - if [ "$?" = "1" ]; then - echo "$BINARY_NAME not found. Is $HELM_INSTALL_DIR on your "'$PATH?' - exit 1 - fi - set -e -} - -# help provides possible cli installation arguments -help () { - echo "Accepted cli arguments are:" - echo -e "\t[--help|-h ] ->> prints this help" - echo -e "\t[--version|-v ] . When not defined it fetches the latest release tag from the Helm CDN" - echo -e "\te.g. --version v3.0.0 or -v canary" - echo -e "\t[--no-sudo] ->> install without sudo" -} - -# cleanup temporary files to avoid https://github.com/helm/helm/issues/2977 -cleanup() { - if [[ -d "${HELM_TMP_ROOT:-}" ]]; then - rm -rf "$HELM_TMP_ROOT" - fi -} - -# Execution - -#Stop execution on any error -trap "fail_trap" EXIT -set -e - -# Set debug if desired -if [ "${DEBUG}" == "true" ]; then - set -x -fi - -# Parsing input arguments (if any) -export INPUT_ARGUMENTS="${@}" -set -u -while [[ $# -gt 0 ]]; do - case $1 in - '--version'|-v) - shift - if [[ $# -ne 0 ]]; then - export DESIRED_VERSION="${1}" - if [[ "$1" != "v"* ]]; then - echo "Expected version arg ('${DESIRED_VERSION}') to begin with 'v', fixing..." - export DESIRED_VERSION="v${1}" - fi - else - echo -e "Please provide the desired version. e.g. --version v3.0.0 or -v canary" - exit 0 - fi - ;; - '--no-sudo') - USE_SUDO="false" - ;; - '--help'|-h) - help - exit 0 - ;; - *) exit 1 - ;; - esac - shift -done -set +u - -initArch -initOS -verifySupported -checkDesiredVersion -if ! checkHelmInstalledVersion; then - downloadFile - verifyFile - installFile -fi -testVersion -cleanup diff --git a/deploy/mcp-server.yaml b/deploy/mcp-server.yaml index d32ca55..615c0cb 100644 --- a/deploy/mcp-server.yaml +++ b/deploy/mcp-server.yaml @@ -1,34 +1,3 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: mcp-server-config - namespace: mcp-sqlvdb -data: - # MyScaleDB configuration - MYSCALE_ENABLED: "true" - MYSCALE_HOST: "chi-mcp-sqlvdb-single-node-0-0-0.chi-mcp-sqlvdb-single-node-0-0.mcp-sqlvdb.svc.cluster.local" - MYSCALE_USER: "default" - MYSCALE_PASSWORD: "" - MYSCALE_PORT: "8123" - MYSCALE_SECURE: "false" - MYSCALE_VERIFY: "false" - MYSCALE_DATABASE: "default" - - # PgVector configuration - PGVECTOR_ENABLED: "false" - PGVECTOR_HOST: "pgvector.mcp-system.svc.cluster.local" - PGVECTOR_PORT: "5432" - PGVECTOR_USER: "postgres" - PGVECTOR_PASSWORD: "postgres" - PGVECTOR_DATABASE: "vectordb" - PGVECTOR_SSLMODE: "disable" - - # MCP server configuration - MCP_SERVER_TRANSPORT: "http" - MCP_BIND_HOST: "0.0.0.0" - MCP_BIND_PORT: "4200" - ---- apiVersion: apps/v1 kind: Deployment metadata: @@ -50,7 +19,7 @@ spec: - name: aliyun-registry-secret containers: - name: mcp-server - image: origin-hub-ai-registry.cn-shanghai.cr.aliyuncs.com/component/mcp-sqlvectordb:0.0.4 + image: origin-hub-ai-registry.cn-shanghai.cr.aliyuncs.com/component/mcp-sqlvectordb:0.0.6 ports: - containerPort: 4200 name: http diff --git a/deploy/myscale.yaml b/deploy/myscale.yaml index a3c6ad2..017e602 100644 --- a/deploy/myscale.yaml +++ b/deploy/myscale.yaml @@ -39,6 +39,8 @@ spec: trace_log/database: system trace_log/table: trace_log trace_log/flush_interval_milliseconds: 7500 + user_defined_executable_functions_config: /var/lib/clickhouse/udf_configs/functions.xml + user_scripts_path: /var/lib/clickhouse/user_scripts users: default/networks/ip: - "127.0.0.1" @@ -73,13 +75,50 @@ spec: + functions.xml: |- + + + EmbedText + executable_pool + 32 + true + JSONEachRow + Array(Float32) + embedding + + text + String + + + provider + String + + + base_url + String + + + api_key + String + + + others + JSON + + embedding + throw + 10000000 + 10000000 + 10000000 + + templates: serviceTemplates: - name: service-template metadata: annotations: - service.beta.kubernetes.io/alibaba-cloud-loadbalancer-address-type: "internet" + service.beta.kubernetes.io/alibaba-cloud-loadbalancer-address-type: "intranet" service.beta.kubernetes.io/alibaba-cloud-loadbalancer-health-check-switch: "off" service.beta.kubernetes.io/alibaba-cloud-loadbalancer-health-check-flag: "off" service.beta.kubernetes.io/alibaba-cloud-loadbalancer-health-check-type: "http" @@ -148,6 +187,9 @@ spec: - containerPort: 9363 name: metrics protocol: TCP + envFrom: + - configMapRef: + name: http-proxy-config resources: requests: cpu: 2 @@ -155,6 +197,13 @@ spec: limits: cpu: 4 memory: 8Gi + initContainers: + - image: origin-hub-ai-registry.cn-shanghai.cr.aliyuncs.com/component/udfs:1.0.1 + name: init-myscale + resources: {} + volumeMounts: + - mountPath: /var/lib/clickhouse + name: data-volume-claim-template volumeClaimTemplates: - name: data-volume-claim-template spec: diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py index bb408ae..82ecc4f 100644 --- a/mcp_server/__init__.py +++ b/mcp_server/__init__.py @@ -8,6 +8,7 @@ get_chdb_config, get_pgvector_config, get_mcp_config, + get_text_to_vec_sql_config, TransportType, ) @@ -15,7 +16,7 @@ from . import myscaledb from . import chdb from . import pgvector - +from . import text2vecsql # Handle truststore if os.getenv("MCP_TRUSTSTORE_DISABLE", None) != "1": @@ -31,9 +32,11 @@ "myscaledb", "chdb", "pgvector", + "text2vecsql", "get_myscale_config", "get_chdb_config", "get_pgvector_config", "get_mcp_config", + "get_text_to_vec_sql_config", "TransportType", ] diff --git a/mcp_server/config.py b/mcp_server/config.py index ff70f25..390e7a8 100644 --- a/mcp_server/config.py +++ b/mcp_server/config.py @@ -88,7 +88,7 @@ def password(self) -> str: @property def database(self) -> Optional[str]: """Get the default database name if set.""" - return os.getenv("MYSCALE_DATABASE") + return os.getenv("MYSCALE_DATABASE", "default") @property def secure(self) -> bool: @@ -368,11 +368,37 @@ def query_timeout(self) -> int: return int(os.getenv("MCP_QUERY_TIMEOUT", "30")) +@dataclass +class TextToVecSQLConfig: + """Text to Vector SQL configuration.""" + + def __init__(self): + """Initialize configuration from environment variables.""" + if self.enabled: + self._validate_required_vars() + + @property + def enabled(self) -> bool: + """Get whether Text to Vector SQL is enabled.""" + return os.getenv("TEXT2VEC_SQL_ENABLED", "false").lower() == "true" + + @property + def url(self) -> str: + """Get the URL of the Text to Vector SQL server.""" + return os.getenv("TEXT2VEC_SQL_URL") + + @property + def api_key(self) -> str: + """Get the API key of the Text to Vector SQL server.""" + return os.getenv("TEXT2VEC_SQL_API") + + # Global instance placeholders for the singleton pattern _MYSCALE_CONFIG_INSTANCE = None _CHDB_CONFIG_INSTANCE = None _PGVECTOR_CONFIG_INSTANCE = None _MCP_CONFIG_INSTANCE = None +_TEXT2VEC_SQL_CONFIG_INSTANCE = None def get_myscale_config() -> MyScaleConfig: @@ -421,3 +447,11 @@ def get_mcp_config() -> MCPServerConfig: if _MCP_CONFIG_INSTANCE is None: _MCP_CONFIG_INSTANCE = MCPServerConfig() return _MCP_CONFIG_INSTANCE + + +def get_text_to_vec_sql_config() -> TextToVecSQLConfig: + """Gets the singleton instance of TextToVecSQLConfig.""" + global _TEXT2VEC_SQL_CONFIG_INSTANCE + if _TEXT2VEC_SQL_CONFIG_INSTANCE is None: + _TEXT2VEC_SQL_CONFIG_INSTANCE = TextToVecSQLConfig() + return _TEXT2VEC_SQL_CONFIG_INSTANCE diff --git a/mcp_server/main.py b/mcp_server/main.py index 2f8b6a6..7a1529e 100644 --- a/mcp_server/main.py +++ b/mcp_server/main.py @@ -94,6 +94,14 @@ def register_services(): register_pgvector_tools(mcp) logger.info("pgvector service registered") + if os.getenv("TEXT2VECSQL_ENABLED", "false").lower() == "true": + try: + from .text2vecsql import register_tools as register_text2vecsql_tools + except ImportError: + from mcp_server.text2vecsql import register_tools as register_text2vecsql_tools + register_text2vecsql_tools(mcp) + logger.info("Text to Vector SQL service registered") + def main(): """Start the MCP server.""" diff --git a/mcp_server/myscaledb/server.py b/mcp_server/myscaledb/server.py index 6c3225a..c922b7d 100644 --- a/mcp_server/myscaledb/server.py +++ b/mcp_server/myscaledb/server.py @@ -119,9 +119,8 @@ def list_databases(): """List available MyScaleDB databases""" logger.info("Listing all databases") client = create_myscale_client() - result = client.command( - "SELECT name FROM system.databases WHERE name != 'system' and name != 'INFORMATION_SCHEMA' and name != 'information_schema'" - ) + database = get_myscale_config().database + result = client.command(f"SELECT name FROM system.databases WHERE name = '{database}'") # Convert newline-separated string to list and trim whitespace if isinstance(result, str): diff --git a/mcp_server/text2vecsql/__init__.py b/mcp_server/text2vecsql/__init__.py new file mode 100644 index 0000000..0e12052 --- /dev/null +++ b/mcp_server/text2vecsql/__init__.py @@ -0,0 +1,15 @@ +"""Text to Vector SQL module.""" + +from .server import ( + get_vector_query, + register_tools, +) +from .prompts import ( + TEXT2VEC_SQL_PROMPT, +) + +__all__ = [ + "get_vector_query", + "register_tools", + "TEXT2VEC_SQL_PROMPT", +] diff --git a/mcp_server/text2vecsql/prompts.py b/mcp_server/text2vecsql/prompts.py new file mode 100644 index 0000000..8a4d528 --- /dev/null +++ b/mcp_server/text2vecsql/prompts.py @@ -0,0 +1,77 @@ +TEXT2VEC_SQL_PROMPT = """ +# Text to Vector SQL Prompt + +## Avaliable Tools +- **get_vector_query**: Generate a vector query from a natural language question and table schema. + +## Core Principles +You are a senior SQL engineer. Your task is to generate a single, correct, and executable SQL query to answer the user's question based on the provided database context. + +### 🚨 Important Constraints +#### Data Processing Constraints +- **No large data display**: Don't show more than 10 rows of raw data in responses +- **Use analysis tool**: All data processing must be completed in the analysis tool +- **Result-oriented output**: Only provide query results and key insights +- **Vector handling**: Display vectors in a compact format or show only dimensions + +#### Query Strategy Constraints +- **table schema awareness**: Always consider the table schema when generating a vector query. +""" + +TEXT_TO_MYSCALE_VEC_SQL_PROMPT = """ +You are a senior SQL engineer. Your task is to generate a single, correct, and executable SQL query to answer the user's question based on the provided database context. +## INSTRUCTIONS +1. **Backend Adherence**: The query MUST be written for the `myscale` database backend. This is a strict requirement. +2. **Follow Special Notes**: You MUST strictly follow all syntax, functions, or constraints described in the [Database Backend Notes]. Pay extremely close attention to this section, as it contains critical, non-standard rules. +3. **Schema Integrity**: The query MUST ONLY use the tables and columns provided in the [Database Schema]. Do not invent or guess table or column names. +4. **Answer the Question**: The query must directly and accurately answer the [Natural Language Question]. +5. **Output Format**: Enclose the final SQL query in a single Markdown code block formatted for SQL (` ```sql ... ``` `). +6. **Embedding Match**: If the [EMBEDDING_MODEL_NAME] parameter is a valid string (e.g., 'intfloat/E5-Mistral-7B-Instruct'), you MUST generate a query that includes the WHERE [EMBEDDING_COLUMN_NAME] MATCH lembed(...) clause for vector search. Otherwise, if embedding model name below the [EMBEDDING MODEL NAME] is None, , you MUST generate a standard SQL query that OMITS the entire MATCH lembed(...) clause. The query should not perform any vector search. +7. **Embedding Name**: If a value is provided for the parameter `[EMBEDDING_MODEL_NAME]`, your generated query must contain a `lembed` function call. The first parameter to the `lembed` function MUST be the exact value of `[EMBEDDING_MODEL_NAME]`, formatted as a string literal (enclosed in single quotes). For example, if `[EMBEDDING_MODEL_NAME]` is `laion/CLIP-ViT-B-32-laion2B-s34B-b79K`, the generated SQL must include `MATCH lembed('laion/CLIP-ViT-B-32-laion2B-s34B-b79K', ...)`. +## DATABASE CONTEXT +[DATABASE BACKEND]: +myscale +[DATABASE SCHEMA]: +{TableSchema} +[DATABASE BACKEND NOTES]: +There are a few requirements you should comply with in addition: +1. When generating SQL queries, you should prioritize utilizing K-Nearest Neighbor (KNN) searches whenever contextually appropriate. However, you must avoid unnecessary/forced KNN implementations for: +-- Traditional relational data queries (especially for columns like: id, age, price). +-- Cases where standard SQL operators (equality, range, or aggregation functions) are more efficient and semantically appropriate. +2. Only columns with a vector type (like: Array(Float32) or FixedString) support KNN queries. The names of these vector columns often end with "_embedding". You can perform KNN searches when the column name you need to query ends with "_embedding" or is otherwise identified as a vector column. +3. In MyScale, vector similarity search is performed using the `distance()` function. You must explicitly calculate the distance in the SELECT clause and give it an alias, typically "AS distance". This distance alias will not be implicitly generated. +4. **MyScale Specific Syntax:** When providing a query vector (the "needle") for an `Array(Float32)` column, at least one number in the array *must* contain a decimal point (e.g., `[3.0, 9, 45]`). This prevents the database from misinterpreting the vector as `Array(UInt64)`, which would cause an error. +5. The `lembed` function is used to transform a string into a semantic vector. This function should be used within a WITH clause to define the reference vector. The lembed function has two parameters: the first is the name of the embedding model used (default value: '[embedding_model]'), and the second is the string content to embed. The resulting vector should be given an alias in the WITH clause. +6. You must generate plausible and semantically relevant words or sentences for the second parameter of the `lembed` function based on the column's name, type, and comment. For example, if a column is named `product_description_embedding` and its comment is "Embedding of the product's features and marketing text", you could generate text like "durable and waterproof outdoor adventure camera". +7. Every KNN search query MUST conclude with "ORDER BY distance LIMIT N" to retrieve the top-N most similar results. The LIMIT clause is mandatory for performing a KNN search and ensuring predictable performance. +8. When combining a vector search with JOIN operations, the standard `WHERE` clause should be used to apply filters from any of the joined tables. The `ORDER BY distance LIMIT N` clause is applied after all filtering and joins are resolved. +9. A SELECT statement should typically be ordered by a single distance calculation to perform one primary KNN search. However, subqueries can perform their own independent KNN searches, each with its own WITH clause, distance calculation, and `ORDER BY distance LIMIT N` clause. +## Example of a MyScale KNN Query +DB Schema: Some table on `articles` with a column `abstract_embedding` `Array(Float32)`. +Query Task: Identify the article ID of the single most relevant article discussing innovative algorithms in graph theory. +Generated SQL: +``` + WITH + lembed('all-MiniLM-L6-v2', 'innovative algorithms in graph theory.') AS ref_vec_0 + SELECT id, distance(articles.abstract_embedding, ref_vec_0) AS distance + FROM articles + ORDER BY distance + LIMIT 1; +``` +[EMBEDDING MODEL NAME]: +{embedding_model} +## NATURAL LANGUAGE QUESTION +In vector searches, the `MATCH` operator performs an approximate nearest neighbor (ANN) search, which identifies items based on their similarity to a given vector. +The `lembed()` function is used to convert text phrases into vector representations using a specific model, in this case, `{embedding_model}`. +This helps in finding items that align closely with the concept of "{NaturalLanguageQuestion}" The parameter `k = 5` specifies that only the top 5 categories, +which are most similar in terms of the embedding, should be considered. The similarity is determined by calculating the Euclidean distance between vectors, where a smaller distance indicates higher similarity. +Let's think step by step! +""" + +TEXT_TO_CHDB_VEC_SQL_PROMPT = """ +You are a helpful assistant that can generate SQL queries to query the chDB database. +""" + +TEXT_TO_PGVECTOR_VEC_SQL_PROMPT = """ +You are a helpful assistant that can generate SQL queries to query the PostgreSQL database with pgvector extension. +""" diff --git a/mcp_server/text2vecsql/server.py b/mcp_server/text2vecsql/server.py new file mode 100644 index 0000000..c7fd617 --- /dev/null +++ b/mcp_server/text2vecsql/server.py @@ -0,0 +1,141 @@ +"""Text to Vector SQL.""" + +from dataclasses import dataclass +import logging +import requests +import json +from typing import Optional + +from fastmcp import FastMCP +from fastmcp.tools import Tool +from fastmcp.prompts import Prompt + +from ..config import get_text_to_vec_sql_config +from .prompts import TEXT_TO_MYSCALE_VEC_SQL_PROMPT, TEXT2VEC_SQL_PROMPT + +logger = logging.getLogger("mcp-text-to-vec-sql") + + +@dataclass +class TextToVecSQLResponse: + """Text to Vector SQL response.""" + + results: json + error_message: Optional[str] = None + error_code: Optional[int] = None + + @classmethod + def handle_response(cls, response: requests.Response) -> "TextToVecSQLResponse": + """Handle a response from the Text to Vector SQL server.""" + assert response.status_code == 200, f"Error: {response.json()['error_message']}" + results = response.json()["result"] + handle_step = "" + sql = "" + next_is_sql = False + for result in results.split("\n"): + if result.startswith("```sql"): + next_is_sql = True + continue + elif result.startswith("```"): + next_is_sql = False + elif next_is_sql: + sql += result + "\n" + else: + handle_step += result + "\n" + return cls( + results={"handle_step": handle_step, "sql": sql.strip()}, + error_message=None, + error_code=None, + ) + + +@dataclass +class TextToVecSQLRequest: + """Text to Vector SQL request.""" + + table_schema: str + natural_language_question: str + prompt: str + + +@dataclass +class TextToVecSQLConfig: + """Text to Vector SQL config.""" + + url: str + api_key: str + + +def do_request(url: str, api_key: str, request: TextToVecSQLRequest) -> TextToVecSQLResponse: + """Do a request to the Text to Vector SQL server.""" + try: + response = requests.post( + url, json={"text_input": request.prompt}, headers={"Authorization": f"Bearer {api_key}"} + ) + return TextToVecSQLResponse.handle_response(response) + except Exception as e: + return TextToVecSQLResponse(sql="", error_message=str(e), error_code=response.status_code) + + +def get_vector_query(natural_language_question: str, table_schema: str) -> str: + """Get a vector query from a natural language question and table schema. + + IMPORTANT: Before calling this tool, you MUST translate the natural_language_question to English if it is not already in English. + This tool requires English input for optimal performance. + + Use this tool for natural language questions that require a vector query. + You can use this tool to generate a vector query for a natural language question, + and then execute the query on the database. And return the results to the user. + + Suitable for: + - Questions that require a vector query + - Questions that require a standard SQL query + + Best practices: + - ALWAYS translate the question to English before calling this tool + - Use the vector query tool for natural language questions that require a vector query + - Use the standard SQL query tool for natural language questions that require a standard SQL query + + Use this tool when you need to generate a vector query for a natural language question. And then execute the query on the database. + Example: + - Can you unveil the crown jewel of our vegetarian delights, the one that has soared to the top of the sales charts from the elite circle of our most cherished categories this year? + """ + prompt = TEXT_TO_MYSCALE_VEC_SQL_PROMPT.format( + TableSchema=table_schema, + NaturalLanguageQuestion=natural_language_question, + embedding_model="intfloat/E5-Mistral-7B-Instruct", + ) + config = get_text_to_vec_sql_config() + response = do_request( + config.url, + config.api_key, + TextToVecSQLRequest( + table_schema=table_schema, + natural_language_question=natural_language_question, + prompt=prompt, + ), + ) + + # Return formatted string instead of dict for MCP compatibility + if response.error_message: + return f"Error: {response.error_message}" + + result = response.results + return f"{result['handle_step']}\n\n```sql\n{result['sql']}\n```" + + +def text_to_vec_sql_initial_prompt() -> str: + """Text to Vector SQL initial prompt.""" + return TEXT2VEC_SQL_PROMPT + + +def register_tools(mcp: FastMCP): + """Register Text to Vector SQL tools to MCP instance.""" + mcp.add_tool(Tool.from_function(get_vector_query)) + text_to_vec_sql_prompt = Prompt.from_function( + text_to_vec_sql_initial_prompt, + name="text_to_vec_sql_initial_prompt", + description="This prompt helps users understand how to interact with Text to Vector SQL and perform operations.", + ) + mcp.add_prompt(text_to_vec_sql_prompt) + logger.info("Text to Vector SQL tools and prompts registered") diff --git a/tests/api_test.py b/tests/api_test.py new file mode 100644 index 0000000..f26253c --- /dev/null +++ b/tests/api_test.py @@ -0,0 +1,259 @@ +import requests +import os +import dotenv + +dotenv.load_dotenv() + +API_KEY = os.getenv("TEXT2VEC_SQL_API") +URL = os.getenv("TEXT2VEC_SQL_URL") + + +# 完整的提示符 (与 Gradio 示例相同) +example = """You are a senior SQL engineer. Your task is to generate a single, correct, and executable SQL query to answer the user's question based on the provided database context. +## INSTRUCTIONS +1. **Backend Adherence**: The query MUST be written for the `myscale` database backend. This is a strict requirement. +2. **Follow Special Notes**: You MUST strictly follow all syntax, functions, or constraints described in the [Database Backend Notes]. Pay extremely close attention to this section, as it contains critical, non-standard rules. +3. **Schema Integrity**: The query MUST ONLY use the tables and columns provided in the [Database Schema]. Do not invent or guess table or column names. +4. **Answer the Question**: The query must directly and accurately answer the [Natural Language Question]. +5. **Output Format**: Enclose the final SQL query in a single Markdown code block formatted for SQL (` ```sql ... ``` `). +6. **Embedding Match**: If the [EMBEDDING_MODEL_NAME] parameter is a valid string (e.g., 'intfloat/E5-Mistral-7B-Instruct'), you MUST generate a query that includes the WHERE [EMBEDDING_COLUMN_NAME] MATCH lembed(...) clause for vector search. Otherwise, if embedding model name below the [EMBEDDING MODEL NAME] is None, , you MUST generate a standard SQL query that OMITS the entire MATCH lembed(...) clause. The query should not perform any vector search. +7. **Embedding Name**: If a value is provided for the parameter `[EMBEDDING_MODEL_NAME]`, your generated query must contain a `lembed` function call. The first parameter to the `lembed` function MUST be the exact value of `[EMBEDDING_MODEL_NAME]`, formatted as a string literal (enclosed in single quotes). For example, if `[EMBEDDING_MODEL_NAME]` is `laion/CLIP-ViT-B-32-laion2B-s34B-b79K`, the generated SQL must include `MATCH lembed('laion/CLIP-ViT-B-32-laion2B-s34B-b79K', ...)`. +## DATABASE CONTEXT +[DATABASE BACKEND]: +myscale +[DATABASE SCHEMA]: +CREATE TABLE CAMPAIGN_RESULTS ( + `result_id` Nullable(Int64), + `campaign_id` Nullable(Int64), + `territory_id` Nullable(Int64), + `menu_item_id` Nullable(Int64), + `sales_increase_percentage` Nullable(Float64), + `customer_engagement_score` Nullable(Float64), + `feedback_improvement` Nullable(Float64) +); +CREATE TABLE CUSTOMERS ( + `customer_id` Nullable(Int64), + `customer_name` Nullable(String), + `email` Nullable(String), + `phone_number` Nullable(String), + `loyalty_points` Nullable(Int64) +); +CREATE TABLE CUSTOMER_FEEDBACK ( + `feedback_id` Nullable(Int64), + `menu_item_id` Nullable(Int64), + `customer_id` Nullable(Int64), + `feedback_date` Nullable(String), + `rating` Nullable(Float64), + `comments` Nullable(String), + `feedback_type` Nullable(String), + `comments_embedding` Array(Float32) +); +CREATE TABLE INGREDIENTS ( + `ingredient_id` Nullable(Int64), + `name` Nullable(String), + `description` Nullable(String), + `supplier_id` Nullable(Int64), + `cost_per_unit` Nullable(Float64), + `description_embedding` Array(Float32) +); +CREATE TABLE MARKETING_CAMPAIGNS ( + `campaign_id` Nullable(Int64), + `campaign_name` Nullable(String), + `start_date` Nullable(String), + `end_date` Nullable(String), + `budget` Nullable(Float64), + `objective` Nullable(String), + `territory_id` Nullable(Int64) +); +CREATE TABLE MENU_CATEGORIES ( + `category_id` Nullable(Int64), + `category_name` Nullable(String), + `description` Nullable(String), + `parent_category_id` Nullable(Int64), + `description_embedding` Array(Float32) +); +CREATE TABLE MENU_ITEMS ( + `menu_item_id` Nullable(Int64), + `territory_id` Nullable(Int64), + `name` Nullable(String), + `price_inr` Nullable(Float64), + `price_usd` Nullable(Float64), + `price_eur` Nullable(Float64), + `category_id` Nullable(Int64), + `menu_type` Nullable(String), + `calories` Nullable(Int64), + `is_vegetarian` Nullable(Int64), + `promotion_id` Nullable(Int64) +); +CREATE TABLE MENU_ITEM_INGREDIENTS ( + `menu_item_id` Nullable(Int64), + `ingredient_id` Nullable(Int64), + `quantity` Nullable(Float64), + `unit_of_measurement` Nullable(String) +); +CREATE TABLE PRICING_STRATEGIES ( + `strategy_id` Nullable(Int64), + `strategy_name` Nullable(String), + `description` Nullable(String), + `territory_id` Nullable(Int64), + `effective_date` Nullable(String), + `end_date` Nullable(String), + `description_embedding` Array(Float32) +); +CREATE TABLE PROMOTIONS ( + `promotion_id` Nullable(Int64), + `promotion_name` Nullable(String), + `start_date` Nullable(String), + `end_date` Nullable(String), + `discount_percentage` Nullable(Float64), + `category_id` Nullable(Int64), + `territory_id` Nullable(Int64) +); +CREATE TABLE SALES_DATA ( + `sale_id` Nullable(Int64), + `menu_item_id` Nullable(Int64), + `territory_id` Nullable(Int64), + `sale_date` Nullable(String), + `quantity_sold` Nullable(Int64), + `total_revenue` Nullable(Float64), + `discount_applied` Nullable(Float64), + `customer_id` Nullable(Int64) +); +CREATE TABLE SALES_FORECAST ( + `forecast_id` Nullable(Int64), + `menu_item_id` Nullable(Int64), + `territory_id` Nullable(Int64), + `forecast_date` Nullable(String), + `forecast_quantity` Nullable(Int64), + `forecast_revenue` Nullable(Float64), + `prediction_accuracy` Nullable(Float64) +); +CREATE TABLE SUPPLIERS ( + `supplier_id` Nullable(Int64), + `supplier_name` Nullable(String), + `contact_email` Nullable(String), + `phone_number` Nullable(String), + `address` Nullable(String) +); +CREATE TABLE TERRITORIES ( + `territory_id` Nullable(Int64), + `territory_name` Nullable(String), + `region` Nullable(String), + `contact_email` Nullable(String), + `local_tax_rate` Nullable(Float64), + `currency_code` Nullable(String) +); +CREATE TABLE USERS ( + `user_id` Nullable(Int64), + `user_name` Nullable(String), + `email` Nullable(String), + `role_id` Nullable(Int64), + `territory_id` Nullable(Int64) +); +CREATE TABLE USER_ROLES ( + `role_id` Nullable(Int64), + `role_name` Nullable(String), + `description` Nullable(String), + `permissions` Nullable(String), + `description_embedding` Array(Float32) +); +[DATABASE BACKEND NOTES]: +There are a few requirements you should comply with in addition: +1. When generating SQL queries, you should prioritize utilizing K-Nearest Neighbor (KNN) searches whenever contextually appropriate. However, you must avoid unnecessary/forced KNN implementations for: +-- Traditional relational data queries (especially for columns like: id, age, price). +-- Cases where standard SQL operators (equality, range, or aggregation functions) are more efficient and semantically appropriate. +2. Only columns with a vector type (like: Array(Float32) or FixedString) support KNN queries. The names of these vector columns often end with "_embedding". You can perform KNN searches when the column name you need to query ends with "_embedding" or is otherwise identified as a vector column. +3. In MyScale, vector similarity search is performed using the `distance()` function. You must explicitly calculate the distance in the SELECT clause and give it an alias, typically "AS distance". This distance alias will not be implicitly generated. +4. **MyScale Specific Syntax:** When providing a query vector (the "needle") for an `Array(Float32)` column, at least one number in the array *must* contain a decimal point (e.g., `[3.0, 9, 45]`). This prevents the database from misinterpreting the vector as `Array(UInt64)`, which would cause an error. +5. The `lembed` function is used to transform a string into a semantic vector. This function should be used within a WITH clause to define the reference vector. The lembed function has two parameters: the first is the name of the embedding model used (default value: '{embedding_model}'), and the second is the string content to embed. The resulting vector should be given an alias in the WITH clause. +6. You must generate plausible and semantically relevant words or sentences for the second parameter of the `lembed` function based on the column's name, type, and comment. For example, if a column is named `product_description_embedding` and its comment is "Embedding of the product's features and marketing text", you could generate text like "durable and waterproof outdoor adventure camera". +7. Every KNN search query MUST conclude with "ORDER BY distance LIMIT N" to retrieve the top-N most similar results. The LIMIT clause is mandatory for performing a KNN search and ensuring predictable performance. +8. When combining a vector search with JOIN operations, the standard `WHERE` clause should be used to apply filters from any of the joined tables. The `ORDER BY distance LIMIT N` clause is applied after all filtering and joins are resolved. +9. A SELECT statement should typically be ordered by a single distance calculation to perform one primary KNN search. However, subqueries can perform their own independent KNN searches, each with its own WITH clause, distance calculation, and `ORDER BY distance LIMIT N` clause. +## Example of a MyScale KNN Query +DB Schema: Some table on `articles` with a column `abstract_embedding` `Array(Float32)`. +Query Task: Identify the article ID of the single most relevant article discussing innovative algorithms in graph theory. +Generated SQL: +``` + WITH + lembed('all-MiniLM-L6-v2', 'innovative algorithms in graph theory.') AS ref_vec_0 + SELECT id, distance(articles.abstract_embedding, ref_vec_0) AS distance + FROM articles + ORDER BY distance + LIMIT 1; +``` +[EMBEDDING MODEL NAME]: +intfloat/E5-Mistral-7B-Instruct +## NATURAL LANGUAGE QUESTION +In vector searches, the `MATCH` operator performs an approximate nearest neighbor (ANN) search, which identifies items based on their similarity to a given vector. The `lembed()` function is used to convert text phrases into vector representations using a specific model, in this case, `intfloat/E5-Mistral-7B-Instruct`. This helps in finding items that align closely with the concept of "Popular menu items based on sales." The parameter `k = 5` specifies that only the top 5 categories, which are most similar in terms of the embedding, should be considered. The similarity is determined by calculating the Euclidean distance between vectors, where a smaller distance indicates higher similarity. +Can you unveil the crown jewel of our vegetarian delights, the one that has soared to the top of the sales charts from the elite circle of our most cherished categories this year? +Let's think step by step! +""" + +if __name__ == "__main__": + # example = "Hi" + + response = requests.post( + URL, json={"text_input": example}, headers={"Authorization": f"Bearer {API_KEY}"} + ) + + if response.status_code == 200: + print("Get Response Successfully") + print(response.json()["result"]) + else: + print(f"Error: {response.status_code}") + print(response.text) + +# Response: +# Get Response Successfully +# To address the question, we need to identify the top vegetarian menu item that has performed the best in terms of sales from the most popular categories. Here's a step-by-step breakdown of how to construct the SQL query: + +# 1. **Identify the Most Popular Categories**: +# - We need to find the top 5 categories based on their similarity to the concept "Popular menu items based on sales." +# - This involves using the `lembed` function to generate a reference vector for the concept and then calculating the distance between this vector and the `description_embedding` of each category. +# - We will use a `WITH` clause to define the reference vector and then select the top 5 categories based on the smallest distance. + +# 2. **Filter Vegetarian Menu Items**: +# - We need to filter the menu items to only include those that are vegetarian (`is_vegetarian = 1`). + +# 3. **Join with Sales Data**: +# - We need to join the filtered vegetarian menu items with the `SALES_DATA` table to get the sales information for these items. + +# 4. **Aggregate Sales Data**: +# - We will aggregate the sales data to calculate the total quantity sold and total revenue for each vegetarian menu item. + +# 5. **Order and Limit the Results**: +# - Finally, we will order the results by total revenue in descending order and limit the output to the top item. + +# Here is the SQL query that implements the above steps: + +# ```sql +# WITH +# lembed('intfloat/E5-Mistral-7B-Instruct', 'Popular menu items based on sales') AS ref_vec_0, + +# PopularCategories AS ( +# SELECT category_id, distance(description_embedding, ref_vec_0) AS distance +# FROM MENU_CATEGORIES +# ORDER BY distance +# LIMIT 5 +# ), + +# VegetarianMenuItems AS ( +# SELECT menu_item_id, name, category_id +# FROM MENU_ITEMS +# WHERE is_vegetarian = 1 +# ), + +# SalesData AS ( +# SELECT vm.menu_item_id, vm.name, SUM(sd.quantity_sold) AS total_quantity_sold, SUM(sd.total_revenue) AS total_revenue +# FROM VegetarianMenuItems vm +# JOIN SALES_DATA sd ON vm.menu_item_id = sd.menu_item_id +# JOIN PopularCategories pc ON vm.category_id = pc.category_id +# GROUP BY vm.menu_item_id, vm.name +# ) + +# SELECT name, total_revenue +# FROM SalesData +# ORDER BY total_revenue DESC +# LIMIT 1; +# ``` diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index ef0cc8d..b283d9b 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -7,6 +7,7 @@ from mcp_server.myscaledb import create_myscale_client from dotenv import load_dotenv import json +import os # Load environment variables load_dotenv() @@ -29,7 +30,7 @@ async def setup_test_database(): client = create_myscale_client() # Test database and table names - test_db = "test_mcp_db" + test_db = os.getenv("MYSCALE_DATABASE", "default") test_table = "test_table" test_table2 = "another_test_table" @@ -82,7 +83,8 @@ async def setup_test_database(): yield test_db, test_table, test_table2 # Cleanup after tests - client.command(f"DROP DATABASE IF EXISTS {test_db}") + client.command(f"DROP TABLE IF EXISTS {test_db}.{test_table}") + client.command(f"DROP TABLE IF EXISTS {test_db}.{test_table2}") @pytest.fixture diff --git a/tests/test_tool.py b/tests/test_tool.py index c455777..e6bcd1d 100644 --- a/tests/test_tool.py +++ b/tests/test_tool.py @@ -1,4 +1,5 @@ import unittest +import os from dotenv import load_dotenv from fastmcp.exceptions import ToolError @@ -20,7 +21,7 @@ def setUpClass(cls): cls.client = create_myscale_client() # Prepare test database and table - cls.test_db = "test_tool_db" + cls.test_db = os.getenv("MYSCALE_DATABASE", "default") cls.test_table = "test_table" cls.client.command(f"CREATE DATABASE IF NOT EXISTS {cls.test_db}") @@ -43,7 +44,7 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): """Clean up the environment after tests.""" - cls.client.command(f"DROP DATABASE IF EXISTS {cls.test_db}") + cls.client.command(f"DROP TABLE IF EXISTS {cls.test_db}.{cls.test_table}") def test_list_databases(self): """Test listing databases."""