Compare commits

..

63 Commits

Author SHA1 Message Date
johnlanni
4f47d3fc12 rel: Release 2.1.1 2025-04-18 16:47:07 +08:00
rinfx
6773482300 Enhance the compatibility of AI observability plugins with different LLM suppliers (#2088) 2025-04-18 16:19:59 +08:00
johnlanni
b6d61f9568 update README 2025-04-18 13:43:33 +08:00
Jingze
1834d4acef fix: support mcp server database reconnect and fix tool/list method denied (#2074) 2025-04-18 11:19:56 +08:00
johnlanni
7f9ae38e51 update all-in-one mcp-server depenednecy 2025-04-17 16:25:00 +08:00
mirror
b13bce6a36 add mcp descriptions (#2080) 2025-04-17 13:46:31 +08:00
liseri
275cac9dbb fix wasm-go/jwt-auth claims_to_headers bug (#2057)
Co-authored-by: Kent Dong <ch3cho@qq.com>
2025-04-16 20:25:01 +08:00
澄潭
8cce7f5d50 add mcp servers (#2076) 2025-04-16 14:48:53 +08:00
rinfx
4f0834d817 rm plugin id after use (#2070) 2025-04-15 19:47:43 +08:00
Kent Dong
7cf0dae824 feat: Support building waf plugin using Makefile (#2061) 2025-04-15 10:25:59 +08:00
johnlanni
707061fb68 release 2.1.1-rc.1 2025-04-14 21:05:00 +08:00
zty98751
3255925bf0 update submodule commit 2025-04-14 20:51:10 +08:00
zty98751
a44f7ef76e update submodule commit 2025-04-14 20:48:42 +08:00
Jingze
c7abfb8aff feat: support config store and redis configuration optional in mcp server (#2035) 2025-04-14 20:52:48 +08:00
johnlanni
ed925ddf84 update amap tools mcp server 2025-04-14 19:41:00 +08:00
johnlanni
1301af4638 remove useless log 2025-04-14 19:14:30 +08:00
johnlanni
de6144439f update all-in-one mcp server 2025-04-14 19:10:02 +08:00
澄潭
e37c4dc286 Fix the issue of traps caused by gc in wasm plugins compiled with go 1.24 (#2054) 2025-04-14 14:46:54 +08:00
小小hao
b8e0baa5ab feat:add GetContextId func for HttpContext (#2043) 2025-04-14 14:40:24 +08:00
Kent Dong
4a157e98e9 fix: Escape asterisk characters in ai-proxy documents (#1999) 2025-04-12 11:14:32 +08:00
澄潭
6af8b17216 Update README.md 2025-04-11 20:07:16 +08:00
Xin Luo
4500b10a42 fix: fix param mapping use %v instead of %s (#2046) 2025-04-11 15:07:46 +08:00
澄潭
c5a86b5298 Update README.md 2025-04-11 14:18:10 +08:00
Xin Luo
36806d9e5c support nacos namespace (#2045) 2025-04-11 14:12:51 +08:00
mamba
d1700009e8 [frontend-gray] 重构业务逻辑,对于微前端和多版本支持更加友好 (#2011) 2025-04-11 10:35:18 +08:00
澄潭
2c3188dad7 Update README.md 2025-04-10 17:32:51 +08:00
澄潭
7d423cddbd Update README.md 2025-04-10 17:29:06 +08:00
澄潭
0e94e1a58a mcp: support amap auto ip detection (#2041) 2025-04-10 17:08:01 +08:00
Se7en
b1307ba97e fix: ai statistics doc (#2040) 2025-04-10 15:38:19 +08:00
Xin Luo
8ae810b01a Feat dynamic tool reset (#2031) 2025-04-09 10:46:36 +08:00
johnlanni
83b38b896c update mcp server readme 2025-04-07 21:06:04 +08:00
johnlanni
1385028f01 update mcp server dependency 2025-04-07 21:04:29 +08:00
littlejian
af663b701a polish translate-readme action (#2020) 2025-04-07 20:23:45 +08:00
DefNed
e5c24a10fb feat: update custom-response plugin to returns different contents for different response status (#2002) 2025-04-06 09:04:40 +08:00
澄潭
ea85ccb694 Update README.md 2025-04-04 13:45:08 +08:00
澄潭
2467004dc9 release 2.1.0 (#2008) 2025-04-02 17:04:13 +08:00
澄潭
5af818a94e optimize: Support viewing MCP debug information in the response code details log field (#2007) 2025-04-02 15:31:09 +08:00
johnlanni
728a9de165 update mcp-server plugin dependency 2025-04-02 13:41:49 +08:00
johnlanni
823527ab94 update higress console to 2.1.0 2025-04-01 23:34:56 +08:00
johnlanni
cb7f6ccd0f Revert "release 2.1.0 (#1998)"
This reverts commit 3c73976130.
2025-04-01 23:33:30 +08:00
澄潭
5107ce5137 fix poll_oneof (#2003) 2025-04-01 23:06:14 +08:00
johnlanni
e6d32aa1cf fix helm README 2025-04-01 23:05:08 +08:00
Jingze
3c73976130 release 2.1.0 (#1998) 2025-04-01 18:45:36 +08:00
澄潭
639956c0b8 release 2.1.0 rc.2 (#1995) 2025-04-01 15:32:33 +08:00
Jingze
a602f7a725 fix: Golang filter supports skipping processing at the body stage. (#1989) 2025-04-01 15:27:38 +08:00
澄潭
7b6e4154f4 update proxy-wasm-cpp-host (#1993) 2025-04-01 14:59:46 +08:00
Xin Luo
12e3f34c0b use custom nacos go sdk for disable log (#1991) 2025-04-01 14:56:55 +08:00
Xin Luo
bdd802f44f feat: support service delete event trigger for tool and some fix (#1987) 2025-04-01 09:43:43 +08:00
littlejian
d58b66df8f feat: Add an optional Redis component to the Higress helm package (#1973) 2025-04-01 09:29:46 +08:00
rinfx
5d99c7d80a rename redis key (#1986) 2025-03-31 22:28:06 +08:00
johnlanni
3428932aca update mcp-server README 2025-03-31 21:51:50 +08:00
澄潭
7ba3f75d41 support rest to mcp (#1988) 2025-03-31 21:41:38 +08:00
Jingze
ae9a06b05c fix: mcp proxy eventData (#1985) 2025-03-31 18:38:52 +08:00
DefNed
9ebe968921 fix: 修复envoy.yaml配置文件中几处问题 (#1983) 2025-03-31 17:06:36 +08:00
Jingze
93e3b086ce fix: fix bug of mcp server proxy (#1981) 2025-03-31 15:40:36 +08:00
小小hao
20dfc3d64f fix inclusionRegexps not working (#1972) 2025-03-30 10:41:01 +08:00
澄潭
492c5d350a Add all in one mcp (#1978) 2025-03-30 00:25:21 +08:00
澄潭
037c71a320 refactor mcp sdk (#1977) 2025-03-29 20:28:10 +08:00
Yiiong
9a07c50f44 docs: 添加Azure OpenAI配置说明 (#1976) 2025-03-29 20:11:48 +08:00
Yiiong
b86e9fc938 feat: add azure embedding to ai-cache (#1975) 2025-03-29 18:08:37 +08:00
Se7en
2014234356 doc: add ai statistics metric doc (#1889) 2025-03-29 16:21:49 +08:00
Jingze
83f69a0186 fix: mcp server config map (#1969)
Co-authored-by: 澄潭 <zty98751@alibaba-inc.com>
2025-03-27 18:13:40 +08:00
Jingze
8495d17070 fix: add match list and wasm mcp-server message pub in redis (#1963) 2025-03-27 17:00:32 +08:00
282 changed files with 43184 additions and 1916 deletions

View File

@@ -6,7 +6,7 @@ on:
- "*"
paths:
- 'helm/**'
workflow_dispatch: ~
workflow_dispatch: ~
push:
branches: [ main ]
paths:
@@ -39,7 +39,6 @@ jobs:
rm -f ./helm-docs
translate-readme:
if: ${{ ! always() }}
needs: helm
runs-on: ubuntu-latest
@@ -52,7 +51,26 @@ jobs:
sudo apt-get update
sudo apt-get install -y jq
- name: Compare README.md
id: compare_readme
run: |
cd ./helm/higress
BASE_BRANCH=main
UPSTREAM_REPO=https://github.com/alibaba/higress.git
TEMP_DIR=$(mktemp -d)
git clone --depth 1 --branch $BASE_BRANCH $UPSTREAM_REPO $TEMP_DIR
if diff -q "$TEMP_DIR/README.md" README.md > /dev/null; then
echo "README.md has no changes in comparison to base branch. Skipping translation."
echo "skip_translation=true" >> $GITHUB_ENV
else
echo "README.md has changed in comparison to base branch. Proceeding with translation."
echo "skip_translation=false" >> $GITHUB_ENV
fi
- name: Translate README.md to Chinese
if: env.skip_translation == 'false'
env:
API_URL: ${{ secrets.HIGRESS_OPENAI_API_URL }}
API_KEY: ${{ secrets.HIGRESS_OPENAI_API_KEY }}
@@ -79,37 +97,30 @@ jobs:
-H "Authorization: Bearer $API_KEY" \
-d "$PAYLOAD")
echo "response: $RESPONSE"
echo "Response: $RESPONSE"
TRANSLATED_CONTENT=$(echo "$RESPONSE" | jq -r '.choices[0].message.content')
echo "$RESPONSE" | jq -c -r '.choices[] | .message.content' > README.zh.new.md
if [ -z "$TRANSLATED_CONTENT" ]; then
echo "Translation failed! Response: $RESPONSE"
if [ -f "README.zh.new.md" ]; then
echo "Translation completed and saved to README.zh.new.md."
else
echo "Translation failed or no content returned!"
exit 1
fi
echo "$TRANSLATED_CONTENT" > README.zh.new.md
echo "Translation completed and saved to README.zh.new.md."
mv README.zh.new.md README.zh.md
- name: Compare README.zh.md
id: compare
run: |
cd ./helm/higress
NEW_README_ZH="README.zh.new.md"
EXISTING_README_ZH="README.zh.md"
- name: Create Pull Request
if: env.skip_translation == 'false'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "Update helm translated README.zh.md"
branch: update-helm-readme-zh
title: "Update helm translated README.zh.md"
body: |
This PR updates the translated README.zh.md file.
if [ ! -f "$EXISTING_README_ZH" ]; then
echo "Add README.zh.md."
mv "$NEW_README_ZH" "$EXISTING_README_ZH"
echo "updated=true" >> $GITHUB_ENV
exit 0
fi
if ! diff -q "$NEW_README_ZH" "$EXISTING_README_ZH"; then
echo "Files are different. Updating README.zh.md."
mv "$NEW_README_ZH" "$EXISTING_README_ZH"
echo "updated=true" >> $GITHUB_ENV
else
echo "Files are identical. No update needed."
echo "updated=false" >> $GITHUB_ENV
fi
- Automatically generated by GitHub Actions
labels: translation, automated
base: main

View File

@@ -144,7 +144,7 @@ docker-buildx-push: clean-env docker.higress-buildx
export PARENT_GIT_TAG:=$(shell cat VERSION)
export PARENT_GIT_REVISION:=$(TAG)
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.1.2/envoy-symbol-ARCH.tar.gz
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.1.4/envoy-symbol-ARCH.tar.gz
build-envoy: prebuild
./tools/hack/build-envoy.sh
@@ -235,8 +235,7 @@ clean-gateway: clean-istio
rm -rf external/proxy
rm -rf external/go-control-plane
rm -rf external/package/envoy.tar.gz
rm -rf external/package/mcp-server_amd64.so
rm -rf external/package/mcp-server_arm64.so
rm -rf external/package/*.so
clean-env:
rm -rf out/

View File

@@ -14,12 +14,9 @@
<a href="https://trendshift.io/repositories/10918" target="_blank"><img src="https://trendshift.io/api/badge/repositories/10918" alt="alibaba%2Fhigress | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
[**Official Site**](https://higress.io/en-us/) &nbsp; |
&nbsp; [**Docs**](https://higress.io/en-us/docs/overview/what-is-higress) &nbsp; |
&nbsp; [**Blog**](https://higress.io/en-us/blog) &nbsp; |
&nbsp; [**Developer**](https://higress.io/en-us/docs/developers/developers_dev) &nbsp; |
&nbsp; [**Higress in Cloud**](https://www.alibabacloud.com/product/microservices-engine?spm=higress-website.topbar.0.0.0) &nbsp;
[**Official Site**](https://higress.ai/en/) &nbsp; |
&nbsp; [**MCP Server QuickStart**](https://higress.cn/en/ai/mcp-quick-start/) &nbsp; |
&nbsp; [**Wasm Plugin Hub**](https://higress.cn/en/plugin/) &nbsp; |
<p>
English | <a href="README_ZH.md">中文<a/> | <a href="README_JP.md">日本語<a/>
@@ -60,7 +57,8 @@ Port descriptions:
- Port 8080: Gateway HTTP protocol entry
- Port 8443: Gateway HTTPS protocol entry
**All Higress Docker images use their own dedicated repository, unaffected by Docker Hub access restrictions in certain regions**
> All Higress Docker images use Higress's own image repository and are not affected by Docker Hub rate limits.
> In addition, the submission and updates of the images are protected by a security scanning mechanism (powered by Alibaba Cloud ACR), making them very secure for use in production environments.
For other installation methods such as Helm deployment under K8s, please refer to the official [Quick Start documentation](https://higress.io/en-us/docs/user/quickstart).
@@ -78,6 +76,10 @@ For other installation methods such as Helm deployment under K8s, please refer t
![](https://img.alicdn.com/imgextra/i1/O1CN01wv8H4g1mS4MUzC1QC_!!6000000004952-2-tps-1764-597.png)
**🌟 Try it now!** Experience Higress-hosted Remote MCP Servers at [https://mcp.higress.ai/](https://mcp.higress.ai/)
![Higress MCP Server Platform](https://img.alicdn.com/imgextra/i2/O1CN01nmVa0a1aChgpyyWOX_!!6000000003294-0-tps-3430-1742.jpg)
By hosting MCP Servers with Higress, you can achieve:
- Unified authentication and authorization mechanisms, ensuring the security of AI tool calls
- Fine-grained rate limiting to prevent abuse and resource exhaustion
@@ -86,6 +88,8 @@ For other installation methods such as Helm deployment under K8s, please refer t
- Simplified deployment and management through Higress's plugin mechanism for quickly adding new MCP Servers
- Dynamic updates without disruption: Thanks to Envoy's friendly handling of long connections and Wasm plugin's dynamic update mechanism, MCP Server logic can be updated on-the-fly without any traffic disruption or connection drops
[Learn more...](https://higress.cn/en/ai/mcp-quick-start/?spm=36971b57.7beea2de.0.0.d85f20a94jsWGm)
- **Kubernetes ingress controller**:
Higress can function as a feature-rich ingress controller, which is compatible with many annotations of K8s' nginx ingress controller.

View File

@@ -73,6 +73,24 @@ K8sでのHelmデプロイなどの他のインストール方法については
![](https://img.alicdn.com/imgextra/i1/O1CN01fNnhCp1cV8mYPRFeS_!!6000000003605-0-tps-1080-608.jpg)
- **MCP Server ホスティング**:
Higressは、EnvoyベースのAPIゲートウェイとして、プラグインメカニズムを通じてMCP Serverをホストすることができます。MCPModel Context Protocolは本質的にAIにより親和性の高いAPIであり、AI Agentが様々なツールやサービスを簡単に呼び出せるようにします。Higressはツール呼び出しの認証、認可、レート制限、可観測性などの統一機能を提供し、AIアプリケーションの開発とデプロイを簡素化します。
![](https://img.alicdn.com/imgextra/i3/O1CN01K4qPUX1OliZa8KIPw_!!6000000001746-2-tps-1581-615.png)
**🌟 今すぐ試してみよう!** [https://mcp.higress.ai/](https://mcp.higress.ai/) でHigressがホストするリモートMCPサーバーを体験できます。このプラットフォームでは、HigressがどのようにリモートMCPサーバーをホストおよび管理するかを直接体験できます。
![Higress MCP サーバープラットフォーム](https://img.alicdn.com/imgextra/i2/O1CN01nmVa0a1aChgpyyWOX_!!6000000003294-0-tps-3430-1742.jpg)
Higressを使用してMCP Serverをホストすることで、以下のことが実現できます
- 統一された認証と認可メカニズム、AIツール呼び出しのセキュリティを確保
- きめ細かいレート制限、乱用やリソース枯渇を防止
- 包括的な監査ログ、すべてのツール呼び出し行動を記録
- 豊富な可観測性、ツール呼び出しのパフォーマンスと健全性を監視
- 簡素化されたデプロイと管理、Higressのプラグインメカニズムを通じて新しいMCP Serverを迅速に追加
- 動的更新による無停止Envoyの長時間接続に対する友好的なサポートとWasmプラグインの動的更新メカニズムにより、MCP Serverのロジックをリアルタイムで更新でき、トラフィックに完全に影響を与えず、接続が切断されることはありません
- **Kubernetes Ingressゲートウェイ**:
HigressはK8sクラスターのIngressエントリーポイントゲートウェイとして機能し、多くのK8s Nginx Ingressの注釈に対応しています。K8s Nginx IngressからHigressへのスムーズな移行が可能です。
@@ -203,4 +221,4 @@ WeChat公式アカウント
<a href="#readme-top" style="text-decoration: none; color: #007bff; font-weight: bold;">
↑ トップに戻る ↑
</a>
</p>
</p>

View File

@@ -89,6 +89,10 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start
![](https://img.alicdn.com/imgextra/i3/O1CN01K4qPUX1OliZa8KIPw_!!6000000001746-2-tps-1581-615.png)
**🌟 立即体验!** 在 [https://mcp.higress.ai/](https://mcp.higress.ai/) 体验 Higress 托管的远程 MCP 服务器。这个平台让您可以体验基于 Higress 托管的远程 MCP 服务器的效果。
![Higress MCP 服务器平台](https://img.alicdn.com/imgextra/i2/O1CN01nmVa0a1aChgpyyWOX_!!6000000003294-0-tps-3430-1742.jpg)
通过 Higress 托管 MCP Server可以实现
- 统一的认证和鉴权机制,确保 AI 工具调用的安全性
- 精细化的速率限制,防止滥用和资源耗尽

View File

@@ -1 +1 @@
v2.1.0-rc.1
v2.1.1

View File

@@ -1,13 +1,18 @@
apiVersion: v2
appVersion: 2.1.0-rc.1
appVersion: 2.1.1
description: Helm chart for deploying higress gateways
icon: https://higress.io/img/higress_logo_small.png
home: http://higress.io/
keywords:
- higress
- gateways
- higress
- gateways
name: higress-core
sources:
- http://github.com/alibaba/higress
- http://github.com/alibaba/higress
dependencies:
- condition: global.enableRedis
name: redis
repository: "file://../redis"
version: 0.0.1
type: application
version: 2.1.0-rc.1
version: 2.1.1

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -0,0 +1,24 @@
apiVersion: v2
name: redis
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.0.1
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "7.4.0-v3"

View File

@@ -0,0 +1,34 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "redis.name" -}}
{{- .Values.redis.name | default "redis-stack-server" -}}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "redis.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "redis.labels" -}}
helm.sh/chart: {{ include "redis.chart" . }}
{{ include "redis.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "redis.selectorLabels" -}}
app.kubernetes.io/name: {{ include "redis.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "redis.name" . }}
namespace: {{ .Release.Namespace }}
data:
redis-stack.conf: |
{{- if .Values.redis.password }}
requirepass {{ .Values.redis.password }}
{{- end }}

View File

@@ -0,0 +1,16 @@
{{- if .Values.redis.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "redis.name" . }}
namespace: {{ .Release.Namespace }}
spec:
accessModes:
{{- range .Values.redis.persistence.accessModes }}
- {{ . | quote }}
{{- end }}
storageClassName: {{ .Values.redis.persistence.storageClass }}
resources:
requests:
storage: {{ .Values.redis.persistence.size | quote }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "redis.name" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "redis.labels" . | nindent 4 }}
spec:
type: {{ .Values.redis.service.type }}
ports:
- port: {{ .Values.redis.service.port }}
targetPort: 6379
protocol: TCP
selector:
{{- include "redis.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,74 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "redis.name" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "redis.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.redis.replicas }}
serviceName: {{ include "redis.name" . }}
selector:
matchLabels:
{{- include "redis.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "redis.selectorLabels" . | nindent 8 }}
spec:
terminationGracePeriodSeconds: 10
{{- with .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.global.hub }}/{{ .Values.redis.image | default "redis-stack-server" }}:{{ .Values.redis.tag | default .Chart.AppVersion }}"
{{- if .Values.global.imagePullPolicy }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
{{- end }}
ports:
- name: http
containerPort: 6379
protocol: TCP
livenessProbe:
tcpSocket:
port: 6379
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
tcpSocket:
port: 6379
initialDelaySeconds: 15
periodSeconds: 10
resources:
{{- toYaml .Values.redis.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /redis-stack.conf
subPath: redis-stack.conf
{{- if .Values.redis.persistence.enabled }}
- name: db
mountPath: /data
{{- end }}
{{- with .Values.redis.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.redis.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.redis.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: config
configMap:
name: {{ include "redis.name" . }}
{{- if .Values.redis.persistence.enabled }}
- name: db
persistentVolumeClaim:
claimName: {{ include "redis.name" . }}
{{- end }}

View File

@@ -0,0 +1,48 @@
# Default values for redis.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
global:
# -- Specify the image registry and pull policy
hub: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress
# -- Specify image pull policy if default behavior isn't desired.
# Default behavior: latest images will be Always else IfNotPresent.
imagePullPolicy: ""
# -- Specify the image pull secrets
imagePullSecrets: []
redis:
# -- Specify the name
name: redis-stack-server
# -- Specify the image
image: "redis-stack-server"
# -- Specify the tag
tag: "7.4.0-v3"
# -- Specify the number of replicas
replicas: 1
# -- Specify the password, if not set, no password is used
password: ""
# -- Service parameters
service:
# -- Exporter service type
type: ClusterIP
# -- Exporter service port
port: 6379
# -- Specify the resources
resources: {}
# -- NodeSelector Node labels for Redis
nodeSelector: {}
# -- Tolerations for Redis
tolerations: []
# -- Affinity for Redis
affinity: {}
persistence:
# -- Enable persistence on Redis
enabled: false
# -- If defined, storageClassName: <storageClass>
# -- If undefined (the default) or set to null, no storageClassName spec is set, choosing the default provisioner
storageClass: ""
# -- Persistent Volume access modes
accessModes:
- ReadWriteOnce
# -- Persistent Volume size
size: 1Gi

View File

@@ -85,7 +85,7 @@
{{- end }}
proxyStatsMatcher:
inclusionRegexps:
- ".*"
{{ toYaml .Values.global.proxy.proxyStatsMatcher.inclusionRegexps | indent 8 }}
{{- end }}
{{/* We take the mesh config above, defined with individual values.yaml, and merge with .Values.meshConfig */}}

View File

@@ -9,6 +9,8 @@ global:
xdsMaxRecvMsgSize: "104857600"
defaultUpstreamConcurrencyThreshold: 10000
enableSRDS: true
# -- Whether to enable Redis(redis-stack-server) for Higress, default is false.
enableRedis: false
onDemandRDS: false
hostRDSMergeSubset: false
onlyPushRouteCluster: true
@@ -199,6 +201,11 @@ global:
# -- Controls if sidecar is injected at the front of the container list and blocks the start of the other containers until the proxy is ready
holdApplicationUntilProxyStarts: false
# -- Proxy stats name regexps matcher for inclusion
proxyStatsMatcher:
inclusionRegexps:
- ".*"
proxy_init:
# -- Base name for the proxy_init container, used to configure iptables.
image: proxyv2
@@ -575,7 +582,7 @@ controller:
podSecurityContext:
{}
# fsGroup: 2000
# fsGroup: 2000
ports:
- name: http
@@ -599,9 +606,9 @@ controller:
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
resources:
requests:
@@ -724,3 +731,40 @@ downstream:
upstream:
idleTimeout: 10
connectionBufferLimits: 10485760
redis:
redis:
name: redis-stack-server
# -- Specify the image
image: "redis-stack-server"
# -- Specify the tag
tag: "7.4.0-v3"
# -- Specify the number of replicas
replicas: 1
# -- Specify the password, if not set, no password is used
password: ""
# -- Service parameters
service:
# -- Exporter service type
type: ClusterIP
# -- Exporter service port
port: 6379
# -- Specify the resources
resources: {}
# -- NodeSelector Node labels for Redis
nodeSelector: {}
# -- Tolerations for Redis
tolerations: []
# -- Affinity for Redis
affinity: {}
persistence:
# -- Enable persistence on Redis, default is false
enabled: false
# -- If defined, storageClassName: <storageClass>
# -- If undefined (the default) or set to null, no storageClassName spec is set, choosing the default provisioner
storageClass: ""
# -- Persistent Volume access modes
accessModes:
- ReadWriteOnce
# -- Persistent Volume size
size: 1Gi

View File

@@ -1,9 +1,9 @@
dependencies:
- name: higress-core
repository: file://../core
version: 2.1.0-rc.1
version: 2.1.1
- name: higress-console
repository: https://higress.io/helm-charts/
version: 2.0.4
digest: sha256:9341e3c410e41bcb681e63c8ff60361af46a22a34463bfb919d2e062b47ad072
generated: "2025-03-26T21:02:19.645626545+08:00"
version: 2.1.1
digest: sha256:a40f56252d0aa995fbf62952a6d23304dd84845caf5766bc64f7270cb00f01e9
generated: "2025-04-18T16:45:23.961507+08:00"

View File

@@ -1,5 +1,5 @@
apiVersion: v2
appVersion: 2.1.0-rc.1
appVersion: 2.1.1
description: Helm chart for deploying Higress gateways
icon: https://higress.io/img/higress_logo_small.png
home: http://higress.io/
@@ -12,9 +12,9 @@ sources:
dependencies:
- name: higress-core
repository: "file://../core"
version: 2.1.0-rc.1
version: 2.1.1
- name: higress-console
repository: "https://higress.io/helm-charts/"
version: 2.0.4
version: 2.1.1
type: application
version: 2.1.0-rc.1
version: 2.1.1

View File

@@ -167,6 +167,7 @@ The command removes all the Kubernetes components associated with the chart and
| global.enableLDSCache | bool | `false` | |
| global.enableProxyProtocol | bool | `false` | |
| global.enablePushAllMCPClusters | bool | `true` | |
| global.enableRedis | bool | `false` | Whether to enable Redis(redis-stack-server) for Higress, default is false. |
| global.enableSRDS | bool | `true` | |
| global.enableStatus | bool | `true` | If true, Higress Controller will update the status field of Ingress resources. When migrating from Nginx Ingress, in order to avoid status field of Ingress objects being overwritten, this parameter needs to be set to false, so Higress won't write the entry IP to the status field of the corresponding Ingress object. |
| global.externalIstiod | bool | `false` | Configure a remote cluster data plane controlled by an external istiod. When set to true, istiod is not deployed locally and only a subset of the other discovery charts are enabled. |
@@ -211,6 +212,7 @@ The command removes all the Kubernetes components associated with the chart and
| global.proxy.includeOutboundPorts | string | `""` | |
| global.proxy.logLevel | string | `"warning"` | Log level for proxy, applies to gateways and sidecars. Expected values are: trace|debug|info|warning|error|critical|off |
| global.proxy.privileged | bool | `false` | If set to true, istio-proxy container will have privileged securityContext |
| global.proxy.proxyStatsMatcher | object | `{"inclusionRegexps":[".*"]}` | Proxy stats name regexps matcher for inclusion |
| global.proxy.readinessFailureThreshold | int | `30` | The number of successive failed probes before indicating readiness failure. |
| global.proxy.readinessInitialDelaySeconds | int | `1` | The initial delay for readiness probes in seconds. |
| global.proxy.readinessPeriodSeconds | int | `2` | The period between readiness probes. |
@@ -271,6 +273,22 @@ The command removes all the Kubernetes components associated with the chart and
| pilot.serviceAnnotations | object | `{}` | |
| pilot.tag | string | `""` | |
| pilot.traceSampling | float | `1` | |
| redis.redis.affinity | object | `{}` | Affinity for Redis |
| redis.redis.image | string | `"redis-stack-server"` | Specify the image |
| redis.redis.name | string | `"redis-stack-server"` | |
| redis.redis.nodeSelector | object | `{}` | NodeSelector Node labels for Redis |
| redis.redis.password | string | `""` | Specify the password, if not set, no password is used |
| redis.redis.persistence.accessModes | list | `["ReadWriteOnce"]` | Persistent Volume access modes |
| redis.redis.persistence.enabled | bool | `false` | Enable persistence on Redis, default is false |
| redis.redis.persistence.size | string | `"1Gi"` | Persistent Volume size |
| redis.redis.persistence.storageClass | string | `""` | If undefined (the default) or set to null, no storageClassName spec is set, choosing the default provisioner |
| redis.redis.replicas | int | `1` | Specify the number of replicas |
| redis.redis.resources | object | `{}` | Specify the resources |
| redis.redis.service | object | `{"port":6379,"type":"ClusterIP"}` | Service parameters |
| redis.redis.service.port | int | `6379` | Exporter service port |
| redis.redis.service.type | string | `"ClusterIP"` | Exporter service type |
| redis.redis.tag | string | `"7.4.0-v3"` | Specify the tag |
| redis.redis.tolerations | list | `[]` | Tolerations for Redis |
| revision | string | `""` | |
| tracing.enable | bool | `false` | |
| tracing.sampling | int | `100` | |

View File

@@ -41,6 +41,16 @@ type RedisConfig struct {
DB int `json:"db,omitempty"`
}
// MCPRatelimitConfig defines the configuration for rate limit
type MCPRatelimitConfig struct {
// The limit of the rate limit
Limit int64 `json:"limit,omitempty"`
// The window of the rate limit
Window int64 `json:"window,omitempty"`
// The white list of the rate limit
WhiteList []string `json:"white_list,omitempty"`
}
// SSEServer defines the configuration for Server-Sent Events (SSE) server
type SSEServer struct {
// The name of the SSE server
@@ -53,6 +63,16 @@ type SSEServer struct {
Config map[string]interface{} `json:"config,omitempty"`
}
// MatchRule defines a rule for matching requests
type MatchRule struct {
// Domain pattern, supports wildcards
MatchRuleDomain string `json:"match_rule_domain,omitempty"`
// Path pattern to match
MatchRulePath string `json:"match_rule_path,omitempty"`
// Type of match rule: exact, prefix, suffix, contains, regex
MatchRuleType string `json:"match_rule_type,omitempty"`
}
// McpServer defines the configuration for MCP (Model Context Protocol) server
type McpServer struct {
// Flag to control whether MCP server is enabled
@@ -63,10 +83,21 @@ type McpServer struct {
SsePathSuffix string `json:"sse_path_suffix,omitempty"`
// List of SSE servers Configs
Servers []*SSEServer `json:"servers,omitempty"`
// List of match rules for filtering requests
MatchList []*MatchRule `json:"match_list,omitempty"`
// Flag to control whether user level server is enabled
EnableUserLevelServer bool `json:"enable_user_level_server,omitempty"`
// Rate limit config for MCP server
Ratelimit *MCPRatelimitConfig `json:"rate_limit,omitempty"`
}
func NewDefaultMcpServer() *McpServer {
return &McpServer{Enable: false}
return &McpServer{
Enable: false,
Servers: make([]*SSEServer, 0),
MatchList: make([]*MatchRule, 0),
EnableUserLevelServer: false,
}
}
const (
@@ -78,8 +109,28 @@ func validMcpServer(m *McpServer) error {
return nil
}
if m.Enable && m.Redis == nil {
return errors.New("redis config cannot be empty when mcp server is enabled")
if m.EnableUserLevelServer && m.Redis == nil {
return errors.New("redis config cannot be empty when user level server is enabled")
}
// Validate match rule types
if m.MatchList != nil {
validTypes := map[string]bool{
"exact": true,
"prefix": true,
"suffix": true,
"contains": true,
"regex": true,
}
for _, rule := range m.MatchList {
if rule.MatchRuleType == "" {
return errors.New("match_rule_type cannot be empty, must be one of: exact, prefix, suffix, contains, regex")
}
if !validTypes[rule.MatchRuleType] {
return fmt.Errorf("invalid match_rule_type: %s, must be one of: exact, prefix, suffix, contains, regex", rule.MatchRuleType)
}
}
}
return nil
@@ -113,9 +164,17 @@ func deepCopyMcpServer(mcp *McpServer) (*McpServer, error) {
DB: mcp.Redis.DB,
}
}
if mcp.Ratelimit != nil {
newMcp.Ratelimit = &MCPRatelimitConfig{
Limit: mcp.Ratelimit.Limit,
Window: mcp.Ratelimit.Window,
WhiteList: mcp.Ratelimit.WhiteList,
}
}
newMcp.SsePathSuffix = mcp.SsePathSuffix
newMcp.EnableUserLevelServer = mcp.EnableUserLevelServer
if len(mcp.Servers) > 0 {
newMcp.Servers = make([]*SSEServer, len(mcp.Servers))
for i, server := range mcp.Servers {
@@ -134,6 +193,17 @@ func deepCopyMcpServer(mcp *McpServer) (*McpServer, error) {
}
}
if len(mcp.MatchList) > 0 {
newMcp.MatchList = make([]*MatchRule, len(mcp.MatchList))
for i, rule := range mcp.MatchList {
newMcp.MatchList[i] = &MatchRule{
MatchRuleDomain: rule.MatchRuleDomain,
MatchRulePath: rule.MatchRulePath,
MatchRuleType: rule.MatchRuleType,
}
}
}
return newMcp, nil
}
@@ -268,7 +338,7 @@ func (m *McpServerController) ConstructEnvoyFilters() ([]*config.Config, error)
}
func (m *McpServerController) constructMcpServerStruct(mcp *McpServer) string {
// 构建 servers 配置
// Build servers configuration
servers := "[]"
if len(mcp.Servers) > 0 {
serverConfigs := make([]string, len(mcp.Servers))
@@ -291,37 +361,73 @@ func (m *McpServerController) constructMcpServerStruct(mcp *McpServer) string {
servers = fmt.Sprintf("[%s]", strings.Join(serverConfigs, ","))
}
// 构建完整的配置结构
structFmt := `{
"name": "envoy.filters.http.golang",
"typed_config": {
"@type": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config",
"value": {
"library_id": "mcp-server",
"library_path": "/var/lib/istio/envoy/mcp-server.so",
"plugin_name": "mcp-server",
"plugin_config": {
"@type": "type.googleapis.com/xds.type.v3.TypedStruct",
"value": {
"redis": {
// Build match_list configuration
matchList := "[]"
if len(mcp.MatchList) > 0 {
matchConfigs := make([]string, len(mcp.MatchList))
for i, rule := range mcp.MatchList {
matchConfigs[i] = fmt.Sprintf(`{
"match_rule_domain": "%s",
"match_rule_path": "%s",
"match_rule_type": "%s"
}`, rule.MatchRuleDomain, rule.MatchRulePath, rule.MatchRuleType)
}
matchList = fmt.Sprintf("[%s]", strings.Join(matchConfigs, ","))
}
// 构建 Redis 配置
redisConfig := "null"
if mcp.Redis != nil {
redisConfig = fmt.Sprintf(`{
"address": "%s",
"username": "%s",
"password": "%s",
"db": %d
},
}`, mcp.Redis.Address, mcp.Redis.Username, mcp.Redis.Password, mcp.Redis.DB)
}
// 构建限流配置
rateLimitConfig := "null"
if mcp.Ratelimit != nil {
whiteList := "[]"
if len(mcp.Ratelimit.WhiteList) > 0 {
whiteList = fmt.Sprintf(`["%s"]`, strings.Join(mcp.Ratelimit.WhiteList, `","`))
}
rateLimitConfig = fmt.Sprintf(`{
"limit": %d,
"window": %d,
"white_list": %s
}`, mcp.Ratelimit.Limit, mcp.Ratelimit.Window, whiteList)
}
// Build complete configuration structure
return fmt.Sprintf(`{
"name": "envoy.filters.http.golang",
"typed_config": {
"@type": "type.googleapis.com/udpa.type.v1.TypedStruct",
"type_url": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config",
"value": {
"library_id": "mcp-session",
"library_path": "/var/lib/istio/envoy/golang-filter.so",
"plugin_name": "mcp-session",
"plugin_config": {
"@type": "type.googleapis.com/xds.type.v3.TypedStruct",
"value": {
"redis": %s,
"rate_limit": %s,
"sse_path_suffix": "%s",
"servers": %s
"match_list": %s,
"servers": %s,
"enable_user_level_server": %t
}
}
}
}
}`
return fmt.Sprintf(structFmt,
mcp.Redis.Address,
mcp.Redis.Username,
mcp.Redis.Password,
mcp.Redis.DB,
}`,
redisConfig,
rateLimitConfig,
mcp.SsePathSuffix,
servers)
matchList,
servers,
mcp.EnableUserLevelServer)
}

View File

@@ -31,7 +31,9 @@ func Test_validMcpServer(t *testing.T) {
{
name: "default",
mcp: &McpServer{
Enable: false,
Enable: false,
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantErr: nil,
},
@@ -43,15 +45,30 @@ func Test_validMcpServer(t *testing.T) {
{
name: "enabled but no redis config",
mcp: &McpServer{
Enable: true,
Redis: nil,
Enable: true,
EnableUserLevelServer: false,
Redis: nil,
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantErr: errors.New("redis config cannot be empty when mcp server is enabled"),
wantErr: nil,
},
{
name: "enabled with user level server but no redis config",
mcp: &McpServer{
Enable: true,
EnableUserLevelServer: true,
Redis: nil,
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantErr: errors.New("redis config cannot be empty when user level server is enabled"),
},
{
name: "valid config with redis",
mcp: &McpServer{
Enable: true,
Enable: true,
EnableUserLevelServer: true,
Redis: &RedisConfig{
Address: "localhost:6379",
Username: "default",
@@ -59,6 +76,13 @@ func Test_validMcpServer(t *testing.T) {
DB: 0,
},
SsePathSuffix: "/sse",
MatchList: []*MatchRule{
{
MatchRuleDomain: "*",
MatchRulePath: "*",
MatchRuleType: "exact",
},
},
Servers: []*SSEServer{
{
Name: "test-server",
@@ -104,6 +128,8 @@ func Test_compareMcpServer(t *testing.T) {
Redis: &RedisConfig{
Address: "localhost:6379",
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
new: nil,
wantResult: ResultDelete,
@@ -116,12 +142,16 @@ func Test_compareMcpServer(t *testing.T) {
Redis: &RedisConfig{
Address: "localhost:6379",
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
new: &McpServer{
Enable: true,
Redis: &RedisConfig{
Address: "localhost:6379",
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantResult: ResultNothing,
wantErr: nil,
@@ -133,12 +163,22 @@ func Test_compareMcpServer(t *testing.T) {
Redis: &RedisConfig{
Address: "localhost:6379",
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
new: &McpServer{
Enable: true,
Redis: &RedisConfig{
Address: "redis:6379",
},
MatchList: []*MatchRule{
{
MatchRuleDomain: "*",
MatchRulePath: "/test",
MatchRuleType: "exact",
},
},
Servers: []*SSEServer{},
},
wantResult: ResultReplace,
wantErr: nil,
@@ -171,6 +211,8 @@ func Test_deepCopyMcpServer(t *testing.T) {
Password: "password",
DB: 0,
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantMcp: &McpServer{
Enable: true,
@@ -180,6 +222,8 @@ func Test_deepCopyMcpServer(t *testing.T) {
Password: "password",
DB: 0,
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantErr: nil,
},
@@ -194,6 +238,13 @@ func Test_deepCopyMcpServer(t *testing.T) {
DB: 0,
},
SsePathSuffix: "/sse",
MatchList: []*MatchRule{
{
MatchRuleDomain: "*",
MatchRulePath: "*",
MatchRuleType: "exact",
},
},
Servers: []*SSEServer{
{
Name: "test-server",
@@ -214,6 +265,13 @@ func Test_deepCopyMcpServer(t *testing.T) {
DB: 0,
},
SsePathSuffix: "/sse",
MatchList: []*MatchRule{
{
MatchRuleDomain: "*",
MatchRulePath: "*",
MatchRuleType: "exact",
},
},
Servers: []*SSEServer{
{
Name: "test-server",
@@ -280,6 +338,8 @@ func TestMcpServerController_AddOrUpdateHigressConfig(t *testing.T) {
Password: "password",
DB: 0,
},
Servers: []*SSEServer{},
MatchList: []*MatchRule{},
},
},
wantErr: nil,
@@ -292,6 +352,8 @@ func TestMcpServerController_AddOrUpdateHigressConfig(t *testing.T) {
Password: "password",
DB: 0,
},
Servers: []*SSEServer{},
MatchList: []*MatchRule{},
},
},
{
@@ -302,6 +364,8 @@ func TestMcpServerController_AddOrUpdateHigressConfig(t *testing.T) {
Redis: &RedisConfig{
Address: "localhost:6379",
},
Servers: []*SSEServer{},
MatchList: []*MatchRule{},
},
},
new: &HigressConfig{
@@ -310,6 +374,8 @@ func TestMcpServerController_AddOrUpdateHigressConfig(t *testing.T) {
Redis: &RedisConfig{
Address: "redis:6379",
},
Servers: []*SSEServer{},
MatchList: []*MatchRule{},
},
},
wantErr: nil,
@@ -319,6 +385,8 @@ func TestMcpServerController_AddOrUpdateHigressConfig(t *testing.T) {
Redis: &RedisConfig{
Address: "redis:6379",
},
Servers: []*SSEServer{},
MatchList: []*MatchRule{},
},
},
{
@@ -329,6 +397,8 @@ func TestMcpServerController_AddOrUpdateHigressConfig(t *testing.T) {
Redis: &RedisConfig{
Address: "localhost:6379",
},
Servers: []*SSEServer{},
MatchList: []*MatchRule{},
},
},
new: &HigressConfig{

View File

@@ -36,4 +36,4 @@ RUN if [ "$GOARCH" = "arm64" ]; then \
FROM scratch AS output
ARG GO_FILTER_NAME
ARG GOARCH
COPY --from=golang-base /${GO_FILTER_NAME}.so ${GO_FILTER_NAME}_${GOARCH}.so
COPY --from=golang-base /${GO_FILTER_NAME}.so golang-filter_${GOARCH}.so

View File

@@ -3,9 +3,13 @@ package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
xds "github.com/cncf/xds/go/xds/type/v3"
"google.golang.org/protobuf/types/known/anypb"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/handler"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
_ "github.com/alibaba/higress/plugins/golang-filter/mcp-server/registry/nacos"
_ "github.com/alibaba/higress/plugins/golang-filter/mcp-server/servers/gorm"
@@ -13,21 +17,41 @@ import (
envoyHttp "github.com/envoyproxy/envoy/contrib/golang/filters/http/source/go/pkg/http"
)
const Name = "mcp-server"
const Name = "mcp-session"
const Version = "1.0.0"
const DefaultServerName = "defaultServer"
const MessageEndpoint = "/message"
const ConfigPathSuffix = "/config"
func init() {
envoyHttp.RegisterHttpFilterFactoryAndConfigParser(Name, filterFactory, &parser{})
go func() {
defer func() {
if r := recover(); r != nil {
api.LogErrorf("PProf server recovered from panic: %v", r)
}
}()
api.LogError(http.ListenAndServe("localhost:6060", nil).Error())
}()
}
type config struct {
ssePathSuffix string
redisClient *internal.RedisClient
stopChan chan struct{}
servers []*internal.SSEServer
defaultServer *internal.SSEServer
ssePathSuffix string
redisClient *internal.RedisClient
servers []*internal.SSEServer
defaultServer *internal.SSEServer
matchList []internal.MatchRule
enableUserLevelServer bool
rateLimitConfig *handler.MCPRatelimitConfig
}
func (c *config) Destroy() {
if c.redisClient != nil {
api.LogDebug("Closing Redis client")
c.redisClient.Close()
}
for _, server := range c.servers {
server.Close()
}
}
type parser struct {
@@ -41,28 +65,80 @@ func (p *parser) Parse(any *anypb.Any, callbacks api.ConfigCallbackHandler) (int
}
v := configStruct.Value
conf := &config{}
conf.stopChan = make(chan struct{})
conf := &config{
matchList: make([]internal.MatchRule, 0),
servers: make([]*internal.SSEServer, 0),
}
redisConfigMap, ok := v.AsMap()["redis"].(map[string]interface{})
// Parse match_list if exists
if matchList, ok := v.AsMap()["match_list"].([]interface{}); ok {
for _, item := range matchList {
if ruleMap, ok := item.(map[string]interface{}); ok {
rule := internal.MatchRule{}
if domain, ok := ruleMap["match_rule_domain"].(string); ok {
rule.MatchRuleDomain = domain
}
if path, ok := ruleMap["match_rule_path"].(string); ok {
rule.MatchRulePath = path
}
if ruleType, ok := ruleMap["match_rule_type"].(string); ok {
rule.MatchRuleType = internal.RuleType(ruleType)
}
conf.matchList = append(conf.matchList, rule)
}
}
}
// Redis configuration is optional
if redisConfigMap, ok := v.AsMap()["redis"].(map[string]interface{}); ok {
redisConfig, err := internal.ParseRedisConfig(redisConfigMap)
if err != nil {
return nil, fmt.Errorf("failed to parse redis config: %w", err)
}
redisClient, err := internal.NewRedisClient(redisConfig)
if err != nil {
return nil, fmt.Errorf("failed to initialize RedisClient: %w", err)
}
conf.redisClient = redisClient
api.LogDebug("Redis client initialized")
} else {
api.LogDebug("Redis configuration not provided, running without Redis")
}
enableUserLevelServer, ok := v.AsMap()["enable_user_level_server"].(bool)
if !ok {
return nil, fmt.Errorf("redis config is not set")
enableUserLevelServer = false
if conf.redisClient == nil {
return nil, fmt.Errorf("redis configuration is not provided, enable_user_level_server is true")
}
}
conf.enableUserLevelServer = enableUserLevelServer
redisConfig, err := internal.ParseRedisConfig(redisConfigMap)
if err != nil {
return nil, fmt.Errorf("failed to parse redis config: %w", err)
if rateLimit, ok := v.AsMap()["rate_limit"].(map[string]interface{}); ok {
rateLimitConfig := &handler.MCPRatelimitConfig{}
if limit, ok := rateLimit["limit"].(float64); ok {
rateLimitConfig.Limit = int(limit)
}
if window, ok := rateLimit["window"].(float64); ok {
rateLimitConfig.Window = int(window)
}
if whiteList, ok := rateLimit["white_list"].([]interface{}); ok {
for _, item := range whiteList {
if uid, ok := item.(string); ok {
rateLimitConfig.Whitelist = append(rateLimitConfig.Whitelist, uid)
}
}
}
if errorText, ok := rateLimit["error_text"].(string); ok {
rateLimitConfig.ErrorText = errorText
}
conf.rateLimitConfig = rateLimitConfig
}
redisClient, err := internal.NewRedisClient(redisConfig, conf.stopChan)
if err != nil {
return nil, fmt.Errorf("failed to initialize RedisClient: %w", err)
}
conf.redisClient = redisClient
ssePathSuffix, ok := v.AsMap()["sse_path_suffix"].(string)
if !ok {
return nil, fmt.Errorf("sse path suffix is not set")
if !ok || ssePathSuffix == "" {
return nil, fmt.Errorf("sse path suffix is not set or empty")
}
conf.ssePathSuffix = ssePathSuffix
@@ -100,7 +176,7 @@ func (p *parser) Parse(any *anypb.Any, callbacks api.ConfigCallbackHandler) (int
}
api.LogDebug(fmt.Sprintf("Server config: %+v", serverConfig))
err = server.ParseConfig(serverConfig)
err := server.ParseConfig(serverConfig)
if err != nil {
return nil, fmt.Errorf("failed to parse server config: %w", err)
}
@@ -111,9 +187,9 @@ func (p *parser) Parse(any *anypb.Any, callbacks api.ConfigCallbackHandler) (int
}
conf.servers = append(conf.servers, internal.NewSSEServer(serverInstance,
internal.WithRedisClient(redisClient),
internal.WithRedisClient(conf.redisClient),
internal.WithSSEEndpoint(fmt.Sprintf("%s%s", serverPath, ssePathSuffix)),
internal.WithMessageEndpoint(fmt.Sprintf("%s%s", serverPath, MessageEndpoint))))
internal.WithMessageEndpoint(serverPath)))
api.LogDebug(fmt.Sprintf("Registered MCP Server: %s", serverType))
}
return conf, nil
@@ -131,11 +207,14 @@ func (p *parser) Merge(parent interface{}, child interface{}) interface{} {
newConfig.ssePathSuffix = childConfig.ssePathSuffix
}
if childConfig.servers != nil {
newConfig.servers = append(newConfig.servers, childConfig.servers...)
newConfig.servers = childConfig.servers
}
if childConfig.defaultServer != nil {
newConfig.defaultServer = childConfig.defaultServer
}
if childConfig.matchList != nil {
newConfig.matchList = childConfig.matchList
}
return &newConfig
}
@@ -145,8 +224,11 @@ func filterFactory(c interface{}, callbacks api.FilterCallbackHandler) api.Strea
panic("unexpected config type")
}
return &filter{
callbacks: callbacks,
config: conf,
callbacks: callbacks,
config: conf,
stopChan: make(chan struct{}),
mcpConfigHandler: handler.NewMCPConfigHandler(conf.redisClient, callbacks),
mcpRatelimitHandler: handler.NewMCPRatelimitHandler(conf.redisClient, callbacks, conf.rateLimitConfig),
}
}

View File

@@ -1,14 +1,22 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/handler"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
"github.com/envoyproxy/envoy/contrib/golang/common/go/api"
"github.com/mark3labs/mcp-go/mcp"
)
const (
RedisNotEnabledResponseBody = "Redis is not enabled, SSE connection is not supported"
)
// The callbacks in the filter, like `DecodeHeaders`, can be implemented on demand.
@@ -19,20 +27,28 @@ type filter struct {
callbacks api.FilterCallbackHandler
path string
config *config
stopChan chan struct{}
req *http.Request
serverName string
message bool
bodyBuffer []byte
proxyURL *url.URL
skip bool
userLevelConfig bool
mcpConfigHandler *handler.MCPConfigHandler
ratelimit bool
mcpRatelimitHandler *handler.MCPRatelimitHandler
}
type RequestURL struct {
method string
scheme string
host string
path string
baseURL string
parsedURL *url.URL
method string
scheme string
host string
path string
baseURL string
parsedURL *url.URL
internalIP bool
}
func NewRequestURL(header api.RequestHeaderMap) *RequestURL {
@@ -40,10 +56,11 @@ func NewRequestURL(header api.RequestHeaderMap) *RequestURL {
scheme, _ := header.Get(":scheme")
host, _ := header.Get(":authority")
path, _ := header.Get(":path")
internalIP, _ := header.Get("x-envoy-internal")
baseURL := fmt.Sprintf("%s://%s", scheme, host)
parsedURL, _ := url.Parse(path)
api.LogDebugf("RequestURL: method=%s, scheme=%s, host=%s, path=%s", method, scheme, host, path)
return &RequestURL{method: method, scheme: scheme, host: host, path: path, baseURL: baseURL, parsedURL: parsedURL}
return &RequestURL{method: method, scheme: scheme, host: host, path: path, baseURL: baseURL, parsedURL: parsedURL, internalIP: internalIP == "true"}
}
// Callbacks which are called in request path
@@ -52,6 +69,13 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
url := NewRequestURL(header)
f.path = url.parsedURL.Path
// Check if request matches any rule in match_list
if !internal.IsMatch(f.config.matchList, url.host, f.path) {
f.skip = true
api.LogDebugf("Request does not match any rule in match_list: %s", url.parsedURL.String())
return api.Continue
}
for _, server := range f.config.servers {
if f.path == server.GetSSEEndpoint() {
if url.method != http.MethodGet {
@@ -62,11 +86,11 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusOK, body, nil, 0, "")
}
api.LogDebugf("%s SSE connection started", server.GetServerName())
server.SetBaseURL(url.baseURL)
return api.LocalReply
} else if f.path == server.GetMessageEndpoint() {
if url.method != http.MethodPost {
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusMethodNotAllowed, "Method not allowed", nil, 0, "")
return api.LocalReply
}
// Create a new http.Request object
f.req = &http.Request{
@@ -88,7 +112,47 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
}
}
}
f.req = &http.Request{
Method: url.method,
URL: url.parsedURL,
}
if strings.HasSuffix(f.path, ConfigPathSuffix) && f.config.enableUserLevelServer {
if !url.internalIP {
api.LogWarnf("Access denied: non-internal IP address %s", url.parsedURL.String())
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusForbidden, "", nil, 0, "")
return api.LocalReply
}
if strings.HasSuffix(f.path, ConfigPathSuffix) && url.method == http.MethodGet {
api.LogDebugf("Handling config request: %s", f.path)
f.mcpConfigHandler.HandleConfigRequest(f.req, []byte{})
return api.LocalReply
}
f.userLevelConfig = true
if endStream {
return api.Continue
} else {
return api.StopAndBuffer
}
}
if !strings.HasSuffix(url.parsedURL.Path, f.config.ssePathSuffix) {
f.proxyURL = url.parsedURL
if f.config.enableUserLevelServer {
parts := strings.Split(url.parsedURL.Path, "/")
if len(parts) >= 3 {
serverName := parts[1]
uid := parts[2]
// Get encoded config
encodedConfig, _ := f.mcpConfigHandler.GetEncodedConfig(serverName, uid)
if encodedConfig != "" {
header.Set("x-higress-mcpserver-config", encodedConfig)
api.LogDebugf("Set x-higress-mcpserver-config Header for %s:%s", serverName, uid)
}
}
f.ratelimit = true
}
if endStream {
return api.Continue
} else {
@@ -106,7 +170,6 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
f.serverName = f.config.defaultServer.GetServerName()
body := "SSE connection create"
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusOK, body, nil, 0, "")
f.config.defaultServer.SetBaseURL(url.baseURL)
}
return api.LocalReply
}
@@ -114,25 +177,53 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
// DecodeData might be called multiple times during handling the request body.
// The endStream is true when handling the last piece of the body.
func (f *filter) DecodeData(buffer api.BufferInstance, endStream bool) api.StatusType {
if f.skip {
return api.Continue
}
if !endStream {
return api.StopAndBuffer
}
if f.message {
f.bodyBuffer = append(f.bodyBuffer, buffer.Bytes()...)
if endStream {
for _, server := range f.config.servers {
if f.path == server.GetMessageEndpoint() {
// Create a response recorder to capture the response
recorder := httptest.NewRecorder()
// Call the handleMessage method of SSEServer with complete body
server.HandleMessage(recorder, f.req, f.bodyBuffer)
f.message = false
// clear buffer
f.bodyBuffer = nil
f.callbacks.DecoderFilterCallbacks().SendLocalReply(recorder.Code, recorder.Body.String(), recorder.Header(), 0, "")
return api.LocalReply
}
for _, server := range f.config.servers {
if f.path == server.GetMessageEndpoint() {
// Create a response recorder to capture the response
recorder := httptest.NewRecorder()
// Call the handleMessage method of SSEServer with complete body
httpStatus := server.HandleMessage(recorder, f.req, buffer.Bytes())
f.message = false
f.callbacks.DecoderFilterCallbacks().SendLocalReply(httpStatus, recorder.Body.String(), recorder.Header(), 0, "")
return api.LocalReply
}
}
} else if f.userLevelConfig {
// Handle config POST request
api.LogDebugf("Handling config request: %s", f.path)
f.mcpConfigHandler.HandleConfigRequest(f.req, buffer.Bytes())
return api.LocalReply
} else if f.ratelimit {
if checkJSONRPCMethod(buffer.Bytes(), "tools/list") {
api.LogDebugf("Not a tools call request, skipping ratelimit")
return api.Continue
}
parts := strings.Split(f.req.URL.Path, "/")
if len(parts) < 3 {
api.LogWarnf("Access denied: no valid uid found")
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusForbidden, "", nil, 0, "")
return api.LocalReply
}
serverName := parts[1]
uid := parts[2]
encodedConfig, err := f.mcpConfigHandler.GetEncodedConfig(serverName, uid)
if err != nil {
api.LogWarnf("Access denied: no valid config found for uid %s", uid)
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusForbidden, "", nil, 0, "")
return api.LocalReply
} else if encodedConfig == "" && checkJSONRPCMethod(buffer.Bytes(), "tools/call") {
api.LogDebugf("Empty config found for %s:%s", serverName, uid)
if !f.mcpRatelimitHandler.HandleRatelimit(f.req, buffer.Bytes()) {
return api.LocalReply
}
}
return api.StopAndBuffer
}
return api.Continue
}
@@ -140,12 +231,19 @@ func (f *filter) DecodeData(buffer api.BufferInstance, endStream bool) api.Statu
// Callbacks which are called in response path
// The endStream is true if the response doesn't have body
func (f *filter) EncodeHeaders(header api.ResponseHeaderMap, endStream bool) api.StatusType {
if f.skip {
return api.Continue
}
if f.serverName != "" {
header.Set("Content-Type", "text/event-stream")
header.Set("Cache-Control", "no-cache")
header.Set("Connection", "keep-alive")
header.Set("Access-Control-Allow-Origin", "*")
header.Del("Content-Length")
if f.config.redisClient != nil {
header.Set("Content-Type", "text/event-stream")
header.Set("Cache-Control", "no-cache")
header.Set("Connection", "keep-alive")
header.Set("Access-Control-Allow-Origin", "*")
header.Del("Content-Length")
} else {
header.Set("Content-Length", strconv.Itoa(len(RedisNotEnabledResponseBody)))
}
return api.Continue
}
return api.Continue
@@ -154,35 +252,70 @@ func (f *filter) EncodeHeaders(header api.ResponseHeaderMap, endStream bool) api
// EncodeData might be called multiple times during handling the response body.
// The endStream is true when handling the last piece of the body.
func (f *filter) EncodeData(buffer api.BufferInstance, endStream bool) api.StatusType {
if f.serverName != "" {
// handle specific server
for _, server := range f.config.servers {
if f.serverName == server.GetServerName() {
buffer.Reset()
server.HandleSSE(f.callbacks)
return api.Running
if f.skip {
return api.Continue
}
if !endStream {
return api.StopAndBuffer
}
if f.proxyURL != nil && f.config.redisClient != nil {
sessionID := f.proxyURL.Query().Get("sessionId")
if sessionID != "" {
channel := internal.GetSSEChannelName(sessionID)
eventData := fmt.Sprintf("event: message\ndata: %s\n\n", buffer.String())
publishErr := f.config.redisClient.Publish(channel, eventData)
if publishErr != nil {
api.LogErrorf("Failed to publish wasm mcp server message to Redis: %v", publishErr)
}
}
// handle default server
if f.serverName == f.config.defaultServer.GetServerName() {
buffer.Reset()
f.config.defaultServer.HandleSSE(f.callbacks)
return api.Running
}
if f.serverName != "" {
if f.config.redisClient != nil {
// handle specific server
for _, server := range f.config.servers {
if f.serverName == server.GetServerName() {
buffer.Reset()
server.HandleSSE(f.callbacks, f.stopChan)
return api.Running
}
}
// handle default server
if f.serverName == f.config.defaultServer.GetServerName() {
buffer.Reset()
f.config.defaultServer.HandleSSE(f.callbacks, f.stopChan)
return api.Running
}
return api.Continue
} else {
buffer.SetString(RedisNotEnabledResponseBody)
return api.Continue
}
return api.Continue
}
return api.Continue
}
// OnDestroy stops the goroutine
func (f *filter) OnDestroy(reason api.DestroyReason) {
if f.serverName != "" && f.config.stopChan != nil {
api.LogDebugf("OnDestroy: reason=%v", reason)
if f.serverName != "" && f.stopChan != nil {
select {
case <-f.config.stopChan:
case <-f.stopChan:
return
default:
api.LogDebug("Stopping SSE connection")
close(f.config.stopChan)
close(f.stopChan)
}
}
}
// check if the request is a tools/call request
func checkJSONRPCMethod(body []byte, method string) bool {
var request mcp.CallToolRequest
if err := json.Unmarshal(body, &request); err != nil {
api.LogWarnf("Failed to unmarshal request body: %v, not a JSON RPC request", err)
return true
}
return request.Method == method
}

View File

@@ -4,7 +4,7 @@ go 1.23
require (
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42
github.com/envoyproxy/envoy v1.33.1-0.20250224062430-6c11eac01993
github.com/envoyproxy/envoy v1.33.1-0.20250325161043-11ab50a29d99
github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.6.0
github.com/mark3labs/mcp-go v0.12.0
@@ -16,6 +16,54 @@ require (
gorm.io/gorm v1.25.12
)
require (
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-array v0.1.0 // indirect
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 // indirect
github.com/alibabacloud-go/darabonba-map v0.0.2 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10 // indirect
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 // indirect
github.com/alibabacloud-go/darabonba-string v1.0.2 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
github.com/alibabacloud-go/kms-20160120/v3 v3.2.3 // indirect
github.com/alibabacloud-go/openapi-util v0.1.0 // indirect
github.com/alibabacloud-go/tea v1.2.2 // indirect
github.com/alibabacloud-go/tea-utils v1.4.4 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 // indirect
github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1 // indirect
github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8 // indirect
github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5 // indirect
github.com/aliyun/credentials-go v1.4.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/clbanning/mxj/v2 v2.5.5 // indirect
github.com/deckarep/golang-set v1.7.1 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/grpc v1.59.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
)
require (
cel.dev/expr v0.15.0 // indirect
github.com/ClickHouse/ch-go v0.61.5 // indirect
@@ -44,11 +92,13 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
go.opentelemetry.io/otel v1.26.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/nacos-group/nacos-sdk-go/v2 v2.2.9 => github.com/luoxiner/nacos-sdk-go/v2 v2.2.9-40

View File

@@ -1,22 +1,147 @@
cel.dev/expr v0.15.0 h1:O1jzfJCQBfL5BFoYktaxwIhuttaQPsVWerH9/EEKx0w=
cel.dev/expr v0.15.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
github.com/ClickHouse/clickhouse-go/v2 v2.23.2 h1:+DAKPMnxLS7pduQZsrJc8OhdLS2L9MfDEJ2TS+hpYDM=
github.com/ClickHouse/clickhouse-go/v2 v2.23.2/go.mod h1:aNap51J1OM3yxQJRgM+AlP/MPkGBCL8A74uQThoQhR0=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.9/go.mod h1:bb+Io8Sn2RuM3/Rpme6ll86jMyFSrD1bxeV/+v61KeU=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10 h1:GEYkMApgpKEVDn6z12DcH1EGYpDYRB8JxsazM4Rywak=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10/go.mod h1:26a14FGhZVELuz2cc2AolvW4RHmIO3/HRwsdHhaIPDE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/kms-20160120/v3 v3.2.3 h1:vamGcYQFwXVqR6RWcrVTTqlIXZVsYjaA7pZbx+Xw6zw=
github.com/alibabacloud-go/kms-20160120/v3 v3.2.3/go.mod h1:3rIyughsFDLie1ut9gQJXkWkMg/NfXBCk+OtXnPu3lw=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.1/go.mod h1:qbzof29bM/IFhLMtJPrgTGK3eauV5J2wSyEUo4OEmnA=
github.com/alibabacloud-go/tea v1.2.2 h1:aTsR6Rl3ANWPfqeQugPglfurloyBJY85eFy7Gc1+8oU=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils v1.4.4 h1:lxCDvNCdTo9FaXKKq45+4vGETQUKNOW/qKTcX9Sk53o=
github.com/alibabacloud-go/tea-utils v1.4.4/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw=
github.com/alibabacloud-go/tea-utils/v2 v2.0.3/go.mod h1:sj1PbjPodAVTqGTA3olprfeeqqmwD0A5OQz94o9EuXQ=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 h1:ie/8RxBOfKZWcrbYSJi2Z8uX8TcOlSMwPlEJh83OeOw=
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU=
github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1 h1:nJYyoFP+aqGKgPs9JeZgS1rWQ4NndNR0Zfhh161ZltU=
github.com/aliyun/alibabacloud-dkms-gcs-go-sdk v0.5.1/go.mod h1:WzGOmFFTlUzXM03CJnHWMQ85UN6QGpOXZocCjwkiyOg=
github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8 h1:QeUdR7JF7iNCvO/81EhxEr3wDwxk4YBoYZOq6E0AjHI=
github.com/aliyun/alibabacloud-dkms-transfer-go-sdk v0.1.8/go.mod h1:xP0KIZry6i7oGPF24vhAPr1Q8vLZRcMcxtft5xDKwCU=
github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5 h1:8S0mtD101RDYa0LXwdoqgN0RxdMmmJYjq8g2mk7/lQ4=
github.com/aliyun/aliyun-secretsmanager-client-go v1.1.5/go.mod h1:M19fxYz3gpm0ETnoKweYyYtqrtnVtrpKFpwsghbw+cQ=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.3.10/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/aliyun/credentials-go v1.4.3 h1:N3iHyvHRMyOwY1+0qBLSf3hb5JFiOujVSVuEpgeGttY=
github.com/aliyun/credentials-go v1.4.3/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/mxj/v2 v2.5.5 h1:oT81vUeEiQQ/DcHbzSytRngP6Ky9O+L+0Bw0zSJag9E=
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk=
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deckarep/golang-set v1.7.1 h1:SCQV0S6gTtp6itiFrTqI+pfmJ4LN85S1YzhDf9rTHJQ=
github.com/deckarep/golang-set v1.7.1/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/envoy v1.33.1-0.20250224062430-6c11eac01993 h1:98rKr5Irapq0t68+sHM78LIkflsiDVttSExZTaqsxSo=
github.com/envoyproxy/envoy v1.33.1-0.20250224062430-6c11eac01993/go.mod h1:x7d0dNbE0xGuDBUkBg19VGCgnPQ+lJ2k8lDzDzKExow=
github.com/envoyproxy/envoy v1.33.1-0.20250325161043-11ab50a29d99 h1:jih/Ieb7BFgVCStgvY5fXQ3mI9ByOt4wfwUF0d7qmqI=
github.com/envoyproxy/envoy v1.33.1-0.20250325161043-11ab50a29d99/go.mod h1:x7d0dNbE0xGuDBUkBg19VGCgnPQ+lJ2k8lDzDzKExow=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
@@ -25,21 +150,93 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@@ -52,11 +249,31 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -64,109 +281,490 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/luoxiner/nacos-sdk-go/v2 v2.2.9-40 h1:nzRTBplC0riQqQwEHZThw5H4/TH5LgWTQTm6A7t1lpY=
github.com/luoxiner/nacos-sdk-go/v2 v2.2.9-40/go.mod h1:9FKXl6FqOiVmm72i8kADtbeK71egyG9y3uRDBg41tpQ=
github.com/mark3labs/mcp-go v0.12.0 h1:Pue1Tdwqcz77GHq18uzgmLT3wmeDUxXUSAqSwhGLhVo=
github.com/mark3labs/mcp-go v0.12.0/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.24.2 h1:J/tulyYK6JwBldPViHJReihxxZ+22FHs0piGjQAvoUE=
github.com/onsi/gomega v1.24.2/go.mod h1:gs3J10IS7Z7r7eXRoNJIrNqU4ToQukCJhFtKrWgHWnk=
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc h1:Ak86L+yDSOzKFa7WM5bf5itSOo1e3Xh8bm5YCMUXIjQ=
github.com/orcaman/concurrent-map v0.0.0-20210501183033-44dafcb38ecc/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q=
google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/clickhouse v0.6.1 h1:t7JMB6sLBXxN8hEO6RdzCbJCwq/jAEVZdwXlmQs1Sd4=
@@ -180,3 +778,13 @@ gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDa
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View File

@@ -0,0 +1,162 @@
package handler
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
"github.com/envoyproxy/envoy/contrib/golang/common/go/api"
)
// MCPConfigHandler handles configuration requests for MCP server
type MCPConfigHandler struct {
configStore ConfigStore
callbacks api.FilterCallbackHandler
}
// NewMCPConfigHandler creates a new instance of MCP configuration handler
func NewMCPConfigHandler(redisClient *internal.RedisClient, callbacks api.FilterCallbackHandler) *MCPConfigHandler {
return &MCPConfigHandler{
configStore: NewRedisConfigStore(redisClient),
callbacks: callbacks,
}
}
// HandleConfigRequest processes configuration requests
func (h *MCPConfigHandler) HandleConfigRequest(req *http.Request, body []byte) bool {
// Check if it's a configuration request
if !strings.HasSuffix(req.URL.Path, "/config") {
return false
}
// Extract serverName and uid from path
pathParts := strings.Split(strings.TrimSuffix(req.URL.Path, "/config"), "/")
if len(pathParts) < 2 {
h.sendErrorResponse(http.StatusBadRequest, "INVALID_PATH", "Invalid path format")
return true
}
uid := pathParts[len(pathParts)-1]
serverName := pathParts[len(pathParts)-2]
switch req.Method {
case http.MethodGet:
return h.handleGetConfig(serverName, uid)
case http.MethodPost:
return h.handleStoreConfig(serverName, uid, body)
default:
h.sendErrorResponse(http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed")
return true
}
}
// handleGetConfig handles configuration retrieval requests
func (h *MCPConfigHandler) handleGetConfig(serverName string, uid string) bool {
config, err := h.configStore.GetConfig(serverName, uid)
if err != nil {
api.LogErrorf("Failed to get config for server %s, uid %s: %v", serverName, uid, err)
h.sendErrorResponse(http.StatusInternalServerError, "CONFIG_ERROR", fmt.Sprintf("Failed to get configuration: %s", err.Error()))
return true
}
response := struct {
Success bool `json:"success"`
Config map[string]string `json:"config"`
}{
Success: true,
Config: config,
}
responseBytes, _ := json.Marshal(response)
headers := map[string][]string{
"Content-Type": {"application/json"},
}
h.callbacks.DecoderFilterCallbacks().SendLocalReply(
http.StatusOK,
string(responseBytes),
headers, 0, "",
)
return true
}
// handleStoreConfig handles configuration storage requests
func (h *MCPConfigHandler) handleStoreConfig(serverName string, uid string, body []byte) bool {
// Parse request body
var requestBody struct {
Config map[string]string `json:"config"`
}
if err := json.Unmarshal(body, &requestBody); err != nil {
api.LogErrorf("Invalid request format for server %s, uid %s: %v", serverName, uid, err)
h.sendErrorResponse(http.StatusBadRequest, "INVALID_REQUEST", fmt.Sprintf("Invalid request format: %s", err.Error()))
return true
}
if requestBody.Config == nil {
h.sendErrorResponse(http.StatusBadRequest, "INVALID_REQUEST", "Config cannot be null")
return true
}
response, err := h.configStore.StoreConfig(serverName, uid, requestBody.Config)
if err != nil {
api.LogErrorf("Failed to store config for server %s, uid %s: %v", serverName, uid, err)
h.sendErrorResponse(http.StatusInternalServerError, "CONFIG_ERROR", fmt.Sprintf("Failed to store configuration: %s", err.Error()))
return true
}
responseBytes, _ := json.Marshal(response)
headers := map[string][]string{
"Content-Type": {"application/json"},
}
h.callbacks.DecoderFilterCallbacks().SendLocalReply(
http.StatusOK,
string(responseBytes),
headers, 0, "",
)
return true
}
// sendErrorResponse sends an error response with the specified status, code and message
func (h *MCPConfigHandler) sendErrorResponse(status int, code string, message string) {
response := &ConfigResponse{
Success: false,
Error: &struct {
Code string `json:"code"`
Message string `json:"message"`
}{
Code: code,
Message: message,
},
}
responseBytes, _ := json.Marshal(response)
headers := map[string][]string{
"Content-Type": {"application/json"},
}
h.callbacks.DecoderFilterCallbacks().SendLocalReply(
status,
string(responseBytes),
headers, 0, "",
)
}
// GetEncodedConfig retrieves and encodes the configuration for a given server and uid
func (h *MCPConfigHandler) GetEncodedConfig(serverName string, uid string) (string, error) {
conf, err := h.configStore.GetConfig(serverName, uid)
if err != nil {
return "", fmt.Errorf("failed to get config: %w", err)
}
// Check if config exists and is not empty
if len(conf) > 0 {
// Convert config map to JSON string
configBytes, err := json.Marshal(conf)
if err != nil {
return "", fmt.Errorf("failed to marshal config: %w", err)
}
// Encode JSON string to base64
return base64.StdEncoding.EncodeToString(configBytes), nil
}
return "", nil
}

View File

@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"fmt"
"time"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
)
const (
configExpiry = 7 * 24 * time.Hour
)
// GetConfigStoreKey returns the Redis channel name for the given session ID
func GetConfigStoreKey(serverName string, uid string) string {
return fmt.Sprintf("mcp-server-config:%s:%s", serverName, uid)
}
// ConfigResponse represents the response structure for configuration operations
type ConfigResponse struct {
Success bool `json:"success"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
// ConfigStore defines the interface for configuration storage operations
type ConfigStore interface {
// StoreConfig stores user configuration
StoreConfig(serverName string, uid string, config map[string]string) (*ConfigResponse, error)
// GetConfig retrieves user configuration
GetConfig(serverName string, uid string) (map[string]string, error)
}
// RedisConfigStore implements configuration storage using Redis
type RedisConfigStore struct {
redisClient *internal.RedisClient
}
// NewRedisConfigStore creates a new instance of Redis configuration storage
func NewRedisConfigStore(redisClient *internal.RedisClient) ConfigStore {
return &RedisConfigStore{
redisClient: redisClient,
}
}
// StoreConfig stores configuration in Redis
func (s *RedisConfigStore) StoreConfig(serverName string, uid string, config map[string]string) (*ConfigResponse, error) {
key := GetConfigStoreKey(serverName, uid)
// Convert config to JSON
configBytes, err := json.Marshal(config)
if err != nil {
return &ConfigResponse{
Success: false,
Error: &struct {
Code string `json:"code"`
Message string `json:"message"`
}{
Code: "MARSHAL_ERROR",
Message: "Failed to marshal configuration",
},
}, err
}
// Store in Redis with expiry
err = s.redisClient.Set(key, string(configBytes), configExpiry)
if err != nil {
return &ConfigResponse{
Success: false,
Error: &struct {
Code string `json:"code"`
Message string `json:"message"`
}{
Code: "REDIS_ERROR",
Message: "Failed to store configuration in Redis",
},
}, err
}
return &ConfigResponse{
Success: true,
}, nil
}
// GetConfig retrieves configuration from Redis
func (s *RedisConfigStore) GetConfig(serverName string, uid string) (map[string]string, error) {
key := GetConfigStoreKey(serverName, uid)
// Get from Redis
value, err := s.redisClient.Get(key)
if err != nil {
return nil, err
}
// Parse JSON
var config map[string]string
if err := json.Unmarshal([]byte(value), &config); err != nil {
return nil, err
}
return config, nil
}

View File

@@ -0,0 +1,181 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
"github.com/envoyproxy/envoy/contrib/golang/common/go/api"
"github.com/mark3labs/mcp-go/mcp"
)
type MCPRatelimitHandler struct {
redisClient *internal.RedisClient
callbacks api.FilterCallbackHandler
limit int // Maximum requests allowed per window
window int // Time window in seconds
whitelist []string // Whitelist of UIDs that bypass rate limiting
errorText string // Error text to be displayed
}
// MCPRatelimitConfig is the configuration for the rate limit handler
type MCPRatelimitConfig struct {
Limit int `json:"limit"`
Window int `json:"window"`
Whitelist []string `json:"white_list"` // List of UIDs that bypass rate limiting
ErrorText string `json:"error_text"` // Error text to be displayed
}
// NewMCPRatelimitHandler creates a new rate limit handler
func NewMCPRatelimitHandler(redisClient *internal.RedisClient, callbacks api.FilterCallbackHandler, conf *MCPRatelimitConfig) *MCPRatelimitHandler {
if conf == nil {
conf = &MCPRatelimitConfig{
Limit: 100,
Window: int(24 * time.Hour / time.Second), // 24 hours in seconds
Whitelist: []string{},
ErrorText: "API rate limit exceeded",
}
}
return &MCPRatelimitHandler{
redisClient: redisClient,
callbacks: callbacks,
limit: conf.Limit,
window: conf.Window,
whitelist: conf.Whitelist,
errorText: conf.ErrorText,
}
}
const (
// Lua script for rate limiting
LimitScript = `
local ttl = redis.call('ttl', KEYS[1])
if ttl < 0 then
redis.call('set', KEYS[1], ARGV[1] - 1, 'EX', ARGV[2])
return {ARGV[1], ARGV[1] - 1, ARGV[2]}
end
return {ARGV[1], redis.call('incrby', KEYS[1], -1), ttl}
`
)
type LimitContext struct {
Count int // Current request count
Remaining int // Remaining requests allowed
Reset int // Time until reset in seconds
}
// TODO: needs to be refactored, rate limit should be registered as a request hook in MCP server
func (h *MCPRatelimitHandler) HandleRatelimit(req *http.Request, body []byte) bool {
parts := strings.Split(req.URL.Path, "/")
if len(parts) < 3 {
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusForbidden, "", nil, 0, "")
return false
}
serverName := parts[1]
uid := parts[2]
// Check if the UID is in whitelist
for _, whitelistedUID := range h.whitelist {
if whitelistedUID == uid {
return true // Bypass rate limiting for whitelisted UIDs
}
}
// Build rate limit key using serverName, uid, window and limit
limitKey := fmt.Sprintf("mcp-server-limit:%s:%s:%d:%d", serverName, uid, h.window, h.limit)
keys := []string{limitKey}
args := []interface{}{h.limit, h.window}
result, err := h.redisClient.Eval(LimitScript, 1, keys, args)
if err != nil {
api.LogErrorf("Failed to check rate limit: %v", err)
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusInternalServerError, "", nil, 0, "")
return false
}
// Process response
resultArray, ok := result.([]interface{})
if !ok || len(resultArray) != 3 {
api.LogErrorf("Invalid response format: %v", result)
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusInternalServerError, "", nil, 0, "")
return false
}
context := LimitContext{
Count: parseRedisValue(resultArray[0]),
Remaining: parseRedisValue(resultArray[1]),
Reset: parseRedisValue(resultArray[2]),
}
if context.Remaining < 0 {
// Create error response content
errorContent := []mcp.TextContent{
{
Type: "text",
Text: h.errorText,
},
}
// Create response result
result := map[string]interface{}{
"content": errorContent,
"isError": true,
}
// Create JSON-RPC response
id := getJSONPRCID(body)
response := mcp.JSONRPCResponse{
JSONRPC: mcp.JSONRPC_VERSION,
ID: id,
Result: result,
}
// Convert response to JSON
jsonResponse, err := json.Marshal(response)
if err != nil {
api.LogErrorf("Failed to marshal JSON response: %v", err)
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusInternalServerError, "", nil, 0, "")
return false
}
// Send JSON-RPC response
sessionID := req.URL.Query().Get("sessionId")
if sessionID != "" {
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusAccepted, string(jsonResponse), nil, 0, "")
} else {
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusOK, string(jsonResponse), nil, 0, "")
}
return false
}
return true
}
func getJSONPRCID(body []byte) mcp.RequestId {
baseMessage := struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
ID interface{} `json:"id,omitempty"`
}{}
if err := json.Unmarshal(body, &baseMessage); err != nil {
api.LogWarnf("Failed to unmarshal request body: %v, not a JSON RPC request", err)
return ""
}
return baseMessage.ID
}
// parseRedisValue converts the value from Redis to an int
func parseRedisValue(value interface{}) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case string:
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return 0
}

View File

@@ -0,0 +1,76 @@
package internal
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
)
// Crypto handles encryption and decryption operations using AES-GCM
type Crypto struct {
gcm cipher.AEAD
}
func NewCrypto(secret string) (*Crypto, error) {
if secret == "" {
return nil, fmt.Errorf("secret cannot be empty")
}
// Generate a 32-byte key using SHA-256
hash := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(hash[:])
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %v", err)
}
// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %v", err)
}
return &Crypto{gcm: gcm}, nil
}
// Encrypt encrypts the plaintext data using AES-GCM
func (c *Crypto) Encrypt(plaintext []byte) (string, error) {
// Generate random nonce
nonce := make([]byte, c.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %v", err)
}
// Encrypt and authenticate data
ciphertext := c.gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts the encrypted string using AES-GCM
func (c *Crypto) Decrypt(encryptedStr string) ([]byte, error) {
// Decode base64
ciphertext, err := base64.StdEncoding.DecodeString(encryptedStr)
if err != nil {
return nil, fmt.Errorf("invalid encrypted data format")
}
// Check if the ciphertext is too short
if len(ciphertext) < c.gcm.NonceSize() {
return nil, fmt.Errorf("invalid encrypted data length")
}
// Extract nonce and ciphertext
nonce := ciphertext[:c.gcm.NonceSize()]
ciphertext = ciphertext[c.gcm.NonceSize():]
// Decrypt and verify data
plaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed")
}
return plaintext, nil
}

View File

@@ -0,0 +1,89 @@
package internal
import (
"regexp"
"strings"
)
// RuleType defines the type of matching rule
type RuleType string
const (
ExactMatch RuleType = "exact"
PrefixMatch RuleType = "prefix"
SuffixMatch RuleType = "suffix"
ContainsMatch RuleType = "contains"
RegexMatch RuleType = "regex"
)
// MatchRule defines the structure for a matching rule
type MatchRule struct {
MatchRuleDomain string `json:"match_rule_domain"` // Domain pattern, supports wildcards
MatchRulePath string `json:"match_rule_path"` // Path pattern to match
MatchRuleType RuleType `json:"match_rule_type"` // Type of match rule
}
// convertWildcardToRegex converts wildcard pattern to regex pattern
func convertWildcardToRegex(pattern string) string {
pattern = regexp.QuoteMeta(pattern)
pattern = "^" + strings.ReplaceAll(pattern, "\\*", ".*") + "$"
return pattern
}
// matchPattern checks if the target matches the pattern based on rule type
func matchPattern(pattern string, target string, ruleType RuleType) bool {
if pattern == "" {
return true
}
switch ruleType {
case ExactMatch:
return pattern == target
case PrefixMatch:
return strings.HasPrefix(target, pattern)
case SuffixMatch:
return strings.HasSuffix(target, pattern)
case ContainsMatch:
return strings.Contains(target, pattern)
case RegexMatch:
matched, err := regexp.MatchString(pattern, target)
if err != nil {
return false
}
return matched
default:
return false
}
}
// matchDomain checks if the domain matches the pattern
func matchDomain(domain string, pattern string) bool {
if pattern == "" || pattern == "*" {
return true
}
// Convert wildcard pattern to regex pattern
regexPattern := convertWildcardToRegex(pattern)
matched, _ := regexp.MatchString(regexPattern, domain)
return matched
}
// matchDomainAndPath checks if both domain and path match the rule
func matchDomainAndPath(domain, path string, rule MatchRule) bool {
return matchDomain(domain, rule.MatchRuleDomain) &&
matchPattern(rule.MatchRulePath, path, rule.MatchRuleType)
}
// IsMatch checks if the request matches any rule in the rule list
// Returns true if no rules are specified
func IsMatch(rules []MatchRule, host, path string) bool {
if len(rules) == 0 {
return true
}
for _, rule := range rules {
if matchDomainAndPath(host, path, rule) {
return true
}
}
return false
}

View File

@@ -10,35 +10,42 @@ import (
)
type RedisConfig struct {
Address string
Username string
Password string
DB int
address string
username string
password string
db int
secret string // Encryption key
}
func ParseRedisConfig(config map[string]any) (*RedisConfig, error) {
// ParseRedisConfig parses Redis configuration from a map
func ParseRedisConfig(config map[string]interface{}) (*RedisConfig, error) {
c := &RedisConfig{}
// address is required
addr, ok := config["address"].(string)
if !ok {
return nil, fmt.Errorf("address is required and must be a string")
if addr, ok := config["address"].(string); ok && addr != "" {
c.address = addr
} else {
return nil, fmt.Errorf("address is required and must be a non-empty string")
}
c.Address = addr
// username is optional
if username, ok := config["username"].(string); ok {
c.Username = username
c.username = username
}
// password is optional
if password, ok := config["password"].(string); ok {
c.Password = password
c.password = password
}
// db is optional, default to 0
if db, ok := config["db"].(int); ok {
c.DB = db
c.db = db
}
// secret is optional
if secret, ok := config["secret"].(string); ok {
c.secret = secret
}
return c, nil
@@ -46,19 +53,20 @@ func ParseRedisConfig(config map[string]any) (*RedisConfig, error) {
// RedisClient is a struct to handle Redis connections and operations
type RedisClient struct {
client *redis.Client
ctx context.Context
stopChan chan struct{}
config *RedisConfig
client *redis.Client
ctx context.Context
cancel context.CancelFunc
config *RedisConfig
crypto *Crypto
}
// NewRedisClient creates a new RedisClient instance and establishes a connection to the Redis server
func NewRedisClient(config *RedisConfig, stopChan chan struct{}) (*RedisClient, error) {
func NewRedisClient(config *RedisConfig) (*RedisClient, error) {
client := redis.NewClient(&redis.Options{
Addr: config.Address,
Username: config.Username,
Password: config.Password,
DB: config.DB,
Addr: config.address,
Username: config.username,
Password: config.password,
DB: config.db,
})
// Ping the Redis server to check the connection
@@ -68,11 +76,23 @@ func NewRedisClient(config *RedisConfig, stopChan chan struct{}) (*RedisClient,
}
api.LogDebugf("Connected to Redis: %s", pong)
ctx, cancel := context.WithCancel(context.Background())
var crypto *Crypto
if config.secret != "" {
crypto, err = NewCrypto(config.secret)
if err != nil {
cancel()
return nil, err
}
}
redisClient := &RedisClient{
client: client,
ctx: context.Background(),
stopChan: stopChan,
config: config,
client: client,
ctx: ctx,
cancel: cancel,
config: config,
crypto: crypto,
}
// Start keep-alive check
@@ -88,7 +108,7 @@ func (r *RedisClient) keepAlive() {
for {
select {
case <-r.stopChan:
case <-r.ctx.Done():
return
case <-ticker.C:
if err := r.checkConnection(); err != nil {
@@ -116,10 +136,10 @@ func (r *RedisClient) reconnect() error {
// Create new client
r.client = redis.NewClient(&redis.Options{
Addr: r.config.Address,
Username: r.config.Username,
Password: r.config.Password,
DB: r.config.DB,
Addr: r.config.address,
Username: r.config.username,
Password: r.config.password,
DB: r.config.db,
})
// Test the new connection
@@ -141,7 +161,7 @@ func (r *RedisClient) Publish(channel string, message string) error {
}
// Subscribe subscribes to a Redis channel and processes messages
func (r *RedisClient) Subscribe(channel string, callback func(message string)) error {
func (r *RedisClient) Subscribe(channel string, stopChan chan struct{}, callback func(message string)) error {
pubsub := r.client.Subscribe(r.ctx, channel)
_, err := pubsub.Receive(r.ctx)
if err != nil {
@@ -149,6 +169,12 @@ func (r *RedisClient) Subscribe(channel string, callback func(message string)) e
}
go func() {
defer func() {
if r := recover(); r != nil {
api.LogErrorf("Redis Subscribe recovered from panic: %v", r)
}
}()
defer func() {
pubsub.Close()
api.LogDebugf("Closed subscription to channel %s", channel)
@@ -157,7 +183,7 @@ func (r *RedisClient) Subscribe(channel string, callback func(message string)) e
ch := pubsub.Channel()
for {
select {
case <-r.stopChan:
case <-stopChan:
api.LogDebugf("Stopping subscription to channel %s", channel)
return
case msg, ok := <-ch:
@@ -183,7 +209,19 @@ func (r *RedisClient) Subscribe(channel string, callback func(message string)) e
// Set sets the value of a key in Redis
func (r *RedisClient) Set(key string, value string, expiration time.Duration) error {
err := r.client.Set(r.ctx, key, value, expiration).Err()
var finalValue string
if r.crypto != nil {
// Encrypt the data
encryptedValue, err := r.crypto.Encrypt([]byte(value))
if err != nil {
return fmt.Errorf("failed to encrypt value: %w", err)
}
finalValue = encryptedValue
} else {
finalValue = value
}
err := r.client.Set(r.ctx, key, finalValue, expiration).Err()
if err != nil {
return fmt.Errorf("failed to set key: %w", err)
}
@@ -192,11 +230,37 @@ func (r *RedisClient) Set(key string, value string, expiration time.Duration) er
// Get retrieves the value of a key from Redis
func (r *RedisClient) Get(key string) (string, error) {
val, err := r.client.Get(r.ctx, key).Result()
value, err := r.client.Get(r.ctx, key).Result()
if err == redis.Nil {
return "", fmt.Errorf("key does not exist")
} else if err != nil {
return "", fmt.Errorf("failed to get key: %w", err)
}
return val, nil
if r.crypto != nil {
// Decrypt the data
decryptedValue, err := r.crypto.Decrypt(value)
if err != nil {
return "", fmt.Errorf("failed to decrypt value: %w", err)
}
return string(decryptedValue), nil
}
return value, nil
}
// Close closes the Redis client and stops the keepalive goroutine
func (r *RedisClient) Close() error {
r.cancel()
return r.client.Close()
}
// Eval executes a Lua script
func (r *RedisClient) Eval(script string, numKeys int, keys []string, args []interface{}) (interface{}, error) {
result, err := r.client.Eval(r.ctx, script, keys, args...).Result()
if err != nil {
return nil, fmt.Errorf("failed to execute Lua script: %w", err)
}
return result, nil
}

View File

@@ -78,6 +78,7 @@ type MCPServer struct {
clientMu sync.Mutex // Separate mutex for client context
currentClient NotificationContext
initialized atomic.Bool // Use atomic for the initialized flag
destory chan struct{}
}
// serverKey is the context key for storing the server instance
@@ -226,6 +227,7 @@ func NewMCPServer(
prompts: nil,
logging: false,
},
destory: make(chan struct{}),
}
for _, opt := range opts {
@@ -419,6 +421,16 @@ func (s *MCPServer) HandleMessage(
)
}
return s.handleToolCall(ctx, baseMessage.ID, request)
case "":
var response mcp.JSONRPCResponse
if err := json.Unmarshal(message, &response); err != nil {
return createErrorResponse(
baseMessage.ID,
mcp.INVALID_REQUEST,
"Invalid message format",
)
}
return nil
default:
return createErrorResponse(
baseMessage.ID,
@@ -572,7 +584,7 @@ func (s *MCPServer) handleInitialize(
}
result := mcp.InitializeResult{
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
ProtocolVersion: request.Params.ProtocolVersion,
ServerInfo: mcp.Implementation{
Name: s.name,
Version: s.version,
@@ -816,6 +828,14 @@ func (s *MCPServer) handleNotification(
return nil
}
func (s *MCPServer) Close() {
close(s.destory)
}
func (s *MCPServer) GetDestoryChannel() chan struct{} {
return s.destory
}
func createResponse(id interface{}, result interface{}) mcp.JSONRPCMessage {
return mcp.JSONRPCResponse{
JSONRPC: mcp.JSONRPC_VERSION,

View File

@@ -12,6 +12,11 @@ import (
"github.com/mark3labs/mcp-go/mcp"
)
// GetSSEChannelName returns the Redis channel name for the given session ID
func GetSSEChannelName(sessionID string) string {
return fmt.Sprintf("mcp-server-sse:%s", sessionID)
}
// SSEServer implements a Server-Sent Events (SSE) based MCP server.
// It provides real-time communication capabilities over HTTP using the SSE protocol.
type SSEServer struct {
@@ -23,10 +28,6 @@ type SSEServer struct {
redisClient *RedisClient // Redis client for pub/sub
}
func (s *SSEServer) SetBaseURL(baseURL string) {
s.baseURL = baseURL
}
func (s *SSEServer) GetMessageEndpoint() string {
return s.messageEndpoint
}
@@ -86,13 +87,13 @@ func NewSSEServer(server *MCPServer, opts ...Option) *SSEServer {
// handleSSE handles incoming SSE connection requests.
// It sets up appropriate headers and creates a new session for the client.
func (s *SSEServer) HandleSSE(cb api.FilterCallbackHandler) {
func (s *SSEServer) HandleSSE(cb api.FilterCallbackHandler, stopChan chan struct{}) {
sessionID := uuid.New().String()
s.sessions.Store(sessionID, true)
defer s.sessions.Delete(sessionID)
channel := fmt.Sprintf("sse:%s", sessionID)
channel := GetSSEChannelName(sessionID)
messageEndpoint := fmt.Sprintf(
"%s%s?sessionId=%s",
@@ -125,7 +126,7 @@ func (s *SSEServer) HandleSSE(cb api.FilterCallbackHandler) {
// }
// }()
err := s.redisClient.Subscribe(channel, func(message string) {
err := s.redisClient.Subscribe(channel, stopChan, func(message string) {
defer cb.EncoderFilterCallbacks().RecoverPanic()
api.LogDebugf("SSE Send message: %s", message)
cb.EncoderFilterCallbacks().InjectData([]byte(message))
@@ -143,16 +144,31 @@ func (s *SSEServer) HandleSSE(cb api.FilterCallbackHandler) {
// Start health check handler
go func() {
ticker := time.NewTicker(time.Minute)
defer func() {
if r := recover(); r != nil {
api.LogErrorf("Health check handler recovered from panic: %v", r)
}
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.redisClient.stopChan:
case <-stopChan:
return
case <-ticker.C:
// Send health check message
healthCheckEvent := "event: health_check\ndata: ping\r\n\r\n"
currentTime := time.Now().Format(time.RFC3339)
pingRequest := mcp.JSONRPCRequest{
JSONRPC: mcp.JSONRPC_VERSION,
ID: currentTime,
Request: mcp.Request{
Method: "ping",
},
}
pingData, _ := json.Marshal(pingRequest)
healthCheckEvent := fmt.Sprintf("event: message\ndata: %s\n\n", pingData)
if err := s.redisClient.Publish(channel, healthCheckEvent); err != nil {
api.LogErrorf("Failed to send health check: %v", err)
}
@@ -163,17 +179,18 @@ func (s *SSEServer) HandleSSE(cb api.FilterCallbackHandler) {
// handleMessage processes incoming JSON-RPC messages from clients and sends responses
// back through both the SSE connection and HTTP response.
func (s *SSEServer) HandleMessage(w http.ResponseWriter, r *http.Request, body json.RawMessage) {
func (s *SSEServer) HandleMessage(w http.ResponseWriter, r *http.Request, body json.RawMessage) int {
if r.Method != http.MethodPost {
s.writeJSONRPCError(w, nil, mcp.INVALID_REQUEST, fmt.Sprintf("Method %s not allowed", r.Method))
return
return http.StatusBadRequest
}
sessionID := r.URL.Query().Get("sessionId")
if sessionID == "" {
s.writeJSONRPCError(w, nil, mcp.INVALID_PARAMS, "Missing sessionId")
return
}
// support streamable http without sessionId
// if sessionID == "" {
// s.writeJSONRPCError(w, nil, mcp.INVALID_PARAMS, "Missing sessionId")
// return
// }
// Set the client context in the server before handling the message
ctx := s.server.WithContext(r.Context(), NotificationContext{
@@ -190,27 +207,34 @@ func (s *SSEServer) HandleMessage(w http.ResponseWriter, r *http.Request, body j
// Process message through MCPServer
response := s.server.HandleMessage(ctx, body)
var status int
// Only send response if there is one (not for notifications)
if response != nil {
eventData, _ := json.Marshal(response)
if sessionID != "" {
channel := fmt.Sprintf("sse:%s", sessionID)
if sessionID != "" && s.redisClient != nil {
channel := GetSSEChannelName(sessionID)
publishErr := s.redisClient.Publish(channel, fmt.Sprintf("event: message\ndata: %s\n\n", eventData))
if publishErr != nil {
api.LogErrorf("Failed to publish message to Redis: %v", publishErr)
}
w.WriteHeader(http.StatusAccepted)
status = http.StatusAccepted
} else {
// support streamable http
w.WriteHeader(http.StatusOK)
status = http.StatusOK
}
// Send HTTP response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(response)
} else {
// For notifications, just send 202 Accepted with no body
w.WriteHeader(http.StatusAccepted)
status = http.StatusAccepted
}
return status
}
// writeJSONRPCError writes a JSON-RPC error response with the given error details.
@@ -225,3 +249,7 @@ func (s *SSEServer) writeJSONRPCError(
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(response)
}
func (s *SSEServer) Close() {
s.server.Close()
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/registry"
"github.com/envoyproxy/envoy/contrib/golang/common/go/api"
@@ -39,6 +40,9 @@ func (n *NacosMcpRegsitry) ListToolsDesciption() []*registry.ToolDescription {
}
func (n *NacosMcpRegsitry) GetToolRpcContext(toolName string) (*registry.RpcContext, bool) {
if n.toolsRpcContext == nil {
n.refreshToolsList()
}
tool, ok := n.toolsRpcContext[toolName]
return tool, ok
}
@@ -76,21 +80,77 @@ func (n *NacosMcpRegsitry) refreshToolsListForGroup(group string, serviceMatcher
api.LogErrorf("Match service error for patter %s", serviceMatcher)
return false
}
currentServiceList := map[string]bool{}
for _, service := range serviceList {
if !pattern.MatchString(service) {
continue
}
if _, ok := n.currentServiceSet[group+service]; !ok {
changed = true
n.refreshToolsListForService(group, service)
formatServiceName := getFormatServiceName(group, service)
if _, ok := n.currentServiceSet[formatServiceName]; !ok {
refreshed := n.refreshToolsListForService(group, service)
n.listenToService(group, service)
if refreshed {
changed = true
}
}
currentServiceList[formatServiceName] = true
}
serviceShouldBeDeleted := []string{}
for serviceName, _ := range n.currentServiceSet {
if !strings.HasPrefix(serviceName, group) {
continue
}
if _, ok := currentServiceList[serviceName]; !ok {
serviceShouldBeDeleted = append(serviceShouldBeDeleted, serviceName)
changed = true
toolsShouldBeDeleted := []string{}
for toolName, _ := range n.toolsDescription {
if strings.HasPrefix(toolName, serviceName) {
toolsShouldBeDeleted = append(toolsShouldBeDeleted, toolName)
}
}
for _, toolName := range toolsShouldBeDeleted {
delete(n.toolsDescription, toolName)
delete(n.toolsRpcContext, toolName)
}
}
}
for _, service := range serviceShouldBeDeleted {
delete(n.currentServiceSet, service)
}
return changed
}
func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, service string, newConfig *string, instances *[]model.Instance) {
func getFormatServiceName(group string, service string) string {
return fmt.Sprintf("%s_%s", group, service)
}
func (n *NacosMcpRegsitry) deleteToolForService(group string, service string) {
toolsNeedReset := []string{}
formatServiceName := getFormatServiceName(group, service)
for tool, _ := range n.toolsDescription {
if strings.HasPrefix(tool, formatServiceName) {
toolsNeedReset = append(toolsNeedReset, tool)
}
}
for _, tool := range toolsNeedReset {
delete(n.toolsDescription, tool)
delete(n.toolsRpcContext, tool)
}
}
func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, service string, newConfig *string, instances *[]model.Instance) bool {
if newConfig == nil {
dataId := makeToolsConfigId(service)
@@ -101,7 +161,7 @@ func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, s
if err != nil {
api.LogError(fmt.Sprintf("Get tools config for sercice %s:%s error %s", group, service, err))
return
return false
}
newConfig = &content
@@ -116,17 +176,27 @@ func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, s
if err != nil {
api.LogError(fmt.Sprintf("List instance for sercice %s:%s error %s", group, service, err))
return
return false
}
instances = &instancesFromNacos
}
var applicationDescription registry.McpApplicationDescription
if newConfig == nil {
return false
}
// config deleted, tools should be removed
if len(*newConfig) == 0 {
n.deleteToolForService(group, service)
return true
}
err := json.Unmarshal([]byte(*newConfig), &applicationDescription)
if err != nil {
api.LogError(fmt.Sprintf("Parse tools config for sercice %s:%s error, config is %s, error is %s", group, service, *newConfig, err))
return
return false
}
wrappedInstances := []registry.Instance{}
@@ -147,6 +217,8 @@ func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, s
n.toolsRpcContext = map[string]*registry.RpcContext{}
}
n.deleteToolForService(group, service)
for _, tool := range applicationDescription.ToolsDescription {
meta := applicationDescription.ToolsMeta[tool.Name]
@@ -167,8 +239,8 @@ func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, s
n.toolsDescription[tool.Name] = tool
n.toolsRpcContext[tool.Name] = &context
}
n.currentServiceSet[group+service] = true
api.LogInfo(fmt.Sprintf("Refresh tools list for service success %s:%s", group, service))
n.currentServiceSet[getFormatServiceName(group, service)] = true
return true
}
func (n *NacosMcpRegsitry) GetCredential(name string, group string) *registry.CredentialInfo {
@@ -193,8 +265,8 @@ func (n *NacosMcpRegsitry) GetCredential(name string, group string) *registry.Cr
return &credential
}
func (n *NacosMcpRegsitry) refreshToolsListForService(group string, service string) {
n.refreshToolsListForServiceWithContent(group, service, nil, nil)
func (n *NacosMcpRegsitry) refreshToolsListForService(group string, service string) bool {
return n.refreshToolsListForServiceWithContent(group, service, nil, nil)
}
func (n *NacosMcpRegsitry) listenToService(group string, service string) {

View File

@@ -45,7 +45,11 @@ func CreateNacosMcpRegsitry(config *NacosConfig) (*NacosMcpRegsitry, error) {
constant.WithTimeoutMs(5000),
constant.WithNotLoadCacheAtStart(true),
constant.WithOpenKMS(true),
constant.WithLogLevel("error"),
)
cc.AppendToStdout = true
cc.DiableLog = true
if config.Namespace != nil {
cc.NamespaceId = *config.Namespace
@@ -108,6 +112,10 @@ func (c *NacosConfig) ParseConfig(config map[string]any) error {
return errors.New("missing serviceMatcher")
}
if namespace, ok := config["namespace"].(string); ok {
c.Namespace = &namespace
}
matchers := map[string]string{}
for key, value := range serviceMatcher {
matchers[key] = value.(string)
@@ -146,11 +154,17 @@ func (c *NacosConfig) NewServer(serverName string) (*internal.MCPServer, error)
nacosRegistry.RegisterToolChangeEventListener(&listener)
go func() {
defer func() {
if r := recover(); r != nil {
api.LogErrorf("NacosToolsListRefresh recovered from panic: %v", r)
}
}()
for {
if nacosRegistry.refreshToolsList() {
resetToolsToMcpServer(mcpServer, nacosRegistry)
}
time.Sleep(time.Second * 10)
time.Sleep(time.Second * 3)
}
}()
return mcpServer, nil
@@ -166,5 +180,5 @@ func resetToolsToMcpServer(mcpServer *internal.MCPServer, reg registry.McpServer
})
}
mcpServer.SetTools(wrappedTools...)
api.LogInfo("Config changed reset tools")
api.LogInfof("Tools reset, new tools list len %d", len(wrappedTools))
}

View File

@@ -50,8 +50,11 @@ func FixedQueryToken(cred *CredentialInfo, h *HttpRemoteCallHandle) {
h.Query[key.(string)] = value.(string)
}
func newHttpRemoteCallHandle(ctx *RpcContext) *HttpRemoteCallHandle {
instance := selectOneInstance(ctx)
func newHttpRemoteCallHandle(ctx *RpcContext) (*HttpRemoteCallHandle, error) {
instance, err := selectOneInstance(ctx)
if err != nil {
return nil, err
}
method, ok := ctx.ToolMeta.InvokeContext["method"]
if !ok {
method = DEFAULT_HTTP_METHOD
@@ -64,7 +67,7 @@ func newHttpRemoteCallHandle(ctx *RpcContext) *HttpRemoteCallHandle {
return &HttpRemoteCallHandle{
CommonRemoteCallHandle: CommonRemoteCallHandle{
Instance: &instance,
Instance: instance,
},
Protocol: ctx.Protocol,
Headers: http.Header{},
@@ -72,7 +75,7 @@ func newHttpRemoteCallHandle(ctx *RpcContext) *HttpRemoteCallHandle {
Query: map[string]string{},
Path: path,
Method: method,
}
}, nil
}
// http remote handle implementation
@@ -119,14 +122,14 @@ func (h *HttpRemoteCallHandle) handleParamMapping(mapInfo *map[string]ParameterM
for param, value := range params {
if info, ok := paramMapInfo[param]; ok {
if info.Position == "Query" {
h.Query[info.BackendName] = fmt.Sprintf("%s", value)
h.Query[info.BackendName] = fmt.Sprintf("%v", value)
} else if info.Position == "Header" {
h.Headers[info.BackendName] = []string{fmt.Sprintf("%s", value)}
h.Headers[info.BackendName] = []string{fmt.Sprintf("%v", value)}
} else {
return fmt.Errorf("Unsupport position for args %s, pos is %s", param, info.Position)
}
} else {
h.Query[param] = fmt.Sprintf("%s", value)
h.Query[param] = fmt.Sprintf("%v", value)
}
}
return nil
@@ -160,20 +163,25 @@ func (h *HttpRemoteCallHandle) doHttpCall() (*http.Response, error) {
return http.DefaultClient.Do(&request)
}
func selectOneInstance(ctx *RpcContext) Instance {
func selectOneInstance(ctx *RpcContext) (*Instance, error) {
instanceId := 0
if ctx.Instances == nil || len(*ctx.Instances) == 0 {
return nil, fmt.Errorf("No instance")
}
instances := *ctx.Instances
if len(instances) != 1 {
if len(instances) > 1 {
instanceId = rand.Intn(len(instances) - 1)
}
return instances[instanceId]
select_instance := instances[instanceId]
return &select_instance, nil
}
func getRemoteCallhandle(ctx *RpcContext) RemoteCallHandle {
func getRemoteCallhandle(ctx *RpcContext) (RemoteCallHandle, error) {
if ctx.Protocol == PROTOCOL_HTTP || ctx.Protocol == PROTOCOL_HTTPS {
return newHttpRemoteCallHandle(ctx)
} else {
return nil
return nil, nil
}
}
@@ -184,11 +192,15 @@ func CommonRemoteCall(reg McpServerRegistry, toolName string, parameters map[str
return nil, fmt.Errorf("Unknown tool %s", toolName)
}
remoteHandle := getRemoteCallhandle(ctx)
remoteHandle, err := getRemoteCallhandle(ctx)
if remoteHandle == nil {
return nil, fmt.Errorf("Unknown backend protocol %s", ctx.Protocol)
}
if err != nil {
return nil, fmt.Errorf("Call backend server error: %w", err)
}
return remoteHandle.HandleToolCall(ctx, parameters)
}

View File

@@ -1,47 +1,148 @@
package gorm
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/envoyproxy/envoy/contrib/golang/common/go/api"
"gorm.io/driver/clickhouse"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DBClient is a struct to handle PostgreSQL connections and operations
// DBClient is a struct to handle database connections and operations
type DBClient struct {
db *gorm.DB
db *gorm.DB
dsn string
dbType string
reconnect chan struct{}
stop chan struct{}
panicCount int32 // Add panic counter
}
// NewDBClient creates a new DBClient instance and establishes a connection to the PostgreSQL database
func NewDBClient(dsn string, dbType string) (*DBClient, error) {
var db *gorm.DB
var err error
if dbType == "postgres" {
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
} else if dbType == "clickhouse" {
db, err = gorm.Open(clickhouse.Open(dsn), &gorm.Config{})
} else if dbType == "mysql" {
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
} else if dbType == "sqlite" {
db, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
} else {
return nil, fmt.Errorf("unsupported database type %s", dbType)
}
// Connect to the database
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
// NewDBClient creates a new DBClient instance and establishes a connection to the database
func NewDBClient(dsn string, dbType string, stop chan struct{}) *DBClient {
client := &DBClient{
dsn: dsn,
dbType: dbType,
reconnect: make(chan struct{}, 1),
stop: stop,
}
return &DBClient{db: db}, nil
// Start reconnection goroutine
go client.reconnectLoop()
// Try initial connection
if err := client.connect(); err != nil {
api.LogErrorf("Initial database connection failed: %v", err)
}
return client
}
func (c *DBClient) connect() error {
var db *gorm.DB
var err error
gormConfig := gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
switch c.dbType {
case "postgres":
db, err = gorm.Open(postgres.Open(c.dsn), &gormConfig)
case "clickhouse":
db, err = gorm.Open(clickhouse.Open(c.dsn), &gormConfig)
case "mysql":
db, err = gorm.Open(mysql.Open(c.dsn), &gormConfig)
case "sqlite":
db, err = gorm.Open(sqlite.Open(c.dsn), &gormConfig)
default:
return fmt.Errorf("unsupported database type %s", c.dbType)
}
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
c.db = db
return nil
}
func (c *DBClient) reconnectLoop() {
defer func() {
if r := recover(); r != nil {
api.LogErrorf("Recovered from panic in reconnectLoop: %v", r)
// Increment panic counter
atomic.AddInt32(&c.panicCount, 1)
// If panic count exceeds threshold, stop trying to reconnect
if atomic.LoadInt32(&c.panicCount) > 3 {
api.LogErrorf("Too many panics in reconnectLoop, stopping reconnection attempts")
return
}
// Wait for a while before restarting
time.Sleep(5 * time.Second)
// Restart the reconnect loop
go c.reconnectLoop()
}
}()
ticker := time.NewTicker(30 * time.Second) // Try to reconnect every 30 seconds
defer ticker.Stop()
for {
select {
case <-c.stop:
api.LogInfof("Database %s connection closed", c.dbType)
return
case <-ticker.C:
if c.db == nil || c.Ping() != nil {
if err := c.connect(); err != nil {
api.LogErrorf("Database reconnection failed: %v", err)
} else {
api.LogInfof("Database reconnected successfully")
// Reset panic count on successful connection
atomic.StoreInt32(&c.panicCount, 0)
}
}
case <-c.reconnect:
if err := c.connect(); err != nil {
api.LogErrorf("Database reconnection failed: %v", err)
} else {
api.LogInfof("Database reconnected successfully")
// Reset panic count on successful connection
atomic.StoreInt32(&c.panicCount, 0)
}
}
}
}
// ExecuteSQL executes a raw SQL query and returns the result as a slice of maps
func (c *DBClient) ExecuteSQL(query string, args ...interface{}) ([]map[string]interface{}, error) {
if c.db == nil {
// Trigger reconnection
select {
case c.reconnect <- struct{}{}:
default:
}
return nil, fmt.Errorf("database is not connected, attempting to reconnect")
}
rows, err := c.db.Raw(query, args...).Rows()
if err != nil {
// If execution fails, connection might be lost, trigger reconnection
select {
case c.reconnect <- struct{}{}:
default:
}
return nil, fmt.Errorf("failed to execute SQL query: %w", err)
}
defer rows.Close()
@@ -88,3 +189,21 @@ func (c *DBClient) ExecuteSQL(query string, args ...interface{}) ([]map[string]i
return results, nil
}
func (c *DBClient) Ping() error {
if c.db == nil {
return fmt.Errorf("database connection is nil")
}
// Use context to set timeout
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// Try to ping the database
sqlDB, err := c.db.DB()
if err != nil {
return fmt.Errorf("failed to get underlying *sql.DB: %v", err)
}
return sqlDB.PingContext(ctx)
}

View File

@@ -16,8 +16,9 @@ func init() {
}
type DBConfig struct {
dbType string
dsn string
dbType string
dsn string
description string
}
func (c *DBConfig) ParseConfig(config map[string]any) error {
@@ -33,6 +34,10 @@ func (c *DBConfig) ParseConfig(config map[string]any) error {
}
c.dbType = dbType
api.LogDebugf("DBConfig ParseConfig: %+v", config)
c.description, ok = config["description"].(string)
if !ok {
c.description = ""
}
return nil
}
@@ -43,14 +48,10 @@ func (c *DBConfig) NewServer(serverName string) (*internal.MCPServer, error) {
internal.WithInstructions(fmt.Sprintf("This is a %s database server", c.dbType)),
)
dbClient, err := NewDBClient(c.dsn, c.dbType)
if err != nil {
return nil, fmt.Errorf("failed to initialize DBClient: %w", err)
}
dbClient := NewDBClient(c.dsn, c.dbType, mcpServer.GetDestoryChannel())
// Add query tool
mcpServer.AddTool(
mcp.NewToolWithRawSchema("query", "Run a read-only SQL query in database", GetQueryToolSchema()),
mcp.NewToolWithRawSchema("query", fmt.Sprintf("Run a read-only SQL query in database %s. Database description: %s", c.dbType, c.description), GetQueryToolSchema()),
HandleQueryTool(dbClient),
)

View File

@@ -17,7 +17,14 @@ COPY . .
WORKDIR /workspace/extensions/$PLUGIN_NAME
RUN go mod tidy
RUN tinygo build -o /main.wasm -scheduler=none -gc=custom -tags="custommalloc nottinygc_finalizer $EXTRA_TAGS" -target=wasi ./
RUN \
if echo "$PLUGIN_NAME" | grep -Eq '^waf$'; then \
# Please use higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/wasm-go-builder:go1.19-tinygo0.28.1-oras1.0.0 as BUILDER
go run mage.go build && \
mv ./local/main.wasm /main.wasm ; \
else \
tinygo build -o /main.wasm -scheduler=none -gc=custom -tags="custommalloc nottinygc_finalizer $EXTRA_TAGS" -target=wasi ./ ; \
fi
FROM scratch as output

View File

@@ -102,6 +102,24 @@ LLM 结果缓存插件,默认配置方式可以直接用于 openai 协议的
## 文本向量化提供商特有配置
### Azure OpenAI
Azure OpenAI 所对应的 `embedding.type``azure`。它需要提前创建[Azure OpenAI 账户](https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/overview),然后您需要在[Azure AI Foundry](https://ai.azure.com/resource/deployments)中挑选一个模型并将其部署,点击您部署好的模型,您可以在终结点中看到目标 URI 以及密钥。请将 URI 中的 host 填入`embedding.serviceHost`,密钥填入`apiKey`
一个完整的 URI 示例为 https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/embeddings?api-version=2024-10-21您需要将`YOUR_RESOURCE_NAME.openai.azure.com`填入`embedding.serviceHost`
它特有的配置字段如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 填写值 |
| ---------------------- | -------- | -------- | ------ | ------- | ---------------------------- |
| `embedding.apiVersion` | string | 必填 | - | api版本 | 获取到的URI中api-version的值 |
需要注意的是您必须要指定`embedding.serviceHost`,如`YOUR_RESOURCE_NAME.openai.azure.com`。模型默认使用了`text-embedding-ada-002`,如需其他模型,请在`embedding.model`中进行指定。
### Cohere
Cohere 所对应的 `embedding.type``cohere`。它并无特有的配置字段。需要提前创建 [API Key](https://docs.cohere.com/reference/embed),并将其填入`embedding.apiKey`
### OpenAI
OpenAI 所对应的 `embedding.type``openai`。它并无特有的配置字段。需要提前创建 [API Key](https://platform.openai.com/settings/organization/api-keys),并将其填入`embedding.apiKey`,一个 API Key 的示例为` sk-xxxxxxx`
@@ -110,16 +128,11 @@ OpenAI 所对应的 `embedding.type` 为 `openai`。它并无特有的配置字
Ollama 所对应的 `embedding.type``ollama`。它并无特有的配置字段。
### 讯飞星火
### Hugging Face
讯飞星火 所对应的 `embedding.type``xfyun`。它需要提前创建[应用](https://console.xfyun.cn/services/emb),获取`APPID``APISecret``APIKey`,并将`APIKey`填入`embedding.apiKey`
Hugging Face 所对应的 `embedding.type``huggingface`。它并无特有的配置字段。需要提前创建 [hf_token](https://huggingface.co/blog/getting-started-with-embeddings),并将其填入`embedding.apiKey`,一个 hf_token 的示例为` hf_xxxxxxx`
它特有的配置字段如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 填写值 |
| --------------------- | -------- | -------- | ------ | -------------------- | ---------------- |
| `embedding.appId` | string | 必填 | - | 应用 ID | 获取的 APPID |
| `embedding.apiSecret` | string | 必填 | - | 调用 API 所需 Secret | 获取的 APISecret |
`embedding.model`默认指定为`sentence-transformers/all-MiniLM-L6-v2`
### Textln
@@ -133,15 +146,16 @@ Textln 所对应的 `embedding.type` 为 `textln`。它需要提前获取[`app-i
| `embedding.textinSecretCode` | string | 必填 | - | 调用 API 所需 Secret | 获取的 secret-code |
| `embedding.textinMatryoshkaDim` | int | 必填 | - | 返回的单个向量长度 | |
### Hugging Face
### 讯飞星火
Hugging Face 所对应的 `embedding.type``huggingface`。它并无特有的配置字段。需要提前创建 [hf_token](https://huggingface.co/blog/getting-started-with-embeddings),并将其填入`embedding.apiKey`,一个 hf_token 的示例为` hf_xxxxxxx`
讯飞星火 所对应的 `embedding.type``xfyun`。它需要提前创建[应用](https://console.xfyun.cn/services/emb),获取`APPID``APISecret``APIKey`,并将`APIKey`填入`embedding.apiKey`
`embedding.model`默认指定为`sentence-transformers/all-MiniLM-L6-v2`
它特有的配置字段如下:
### Cohere
Cohere 所对应的 `embedding.type``cohere`。它并无特有的配置字段。需要提前创建 [API Key](https://docs.cohere.com/reference/embed),并将其填入`embedding.apiKey`
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | 填写值 |
| --------------------- | -------- | -------- | ------ | -------------------- | ---------------- |
| `embedding.appId` | string | 必填 | - | 应用 ID | 获取的 APPID |
| `embedding.apiSecret` | string | 必填 | - | 调用 API 所需 Secret | 获取的 APISecret |
## 向量数据库提供商特有配置

View File

@@ -130,7 +130,7 @@ func performEmbeddingQuery(key string, ctx wrapper.HttpContext, c config.PluginC
return logAndReturnError(log, fmt.Sprintf("[performEmbeddingQuery] no embedding provider configured for similarity search"))
}
return activeEmbeddingProvider.GetEmbedding(key, ctx, log, func(textEmbedding []float64, err error) {
return activeEmbeddingProvider.GetEmbedding(key, ctx, func(textEmbedding []float64, err error) {
log.Debugf("[%s] [performEmbeddingQuery] GetEmbedding success, length of embedding: %d, error: %v", PLUGIN_NAME, len(textEmbedding), err)
if err != nil {
handleInternalError(err, fmt.Sprintf("[%s] [performEmbeddingQuery] error getting embedding for key: %s", PLUGIN_NAME, key), log)

View File

@@ -0,0 +1,172 @@
package embedding
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
AZURE_PORT = 443
AZURE_DEFAULT_MODEL_NAME = "text-embedding-ada-002"
AZURE_ENDPOINT = "/openai/deployments/{model}/embeddings"
)
type azureProviderInitializer struct {
}
var azureConfig azureProviderConfig
type azureProviderConfig struct {
// @Title zh-CN 文本特征提取服务 API Key
// @Description zh-CN 文本特征提取服务 API Key
apiKey string
// @Title zh-CN 文本特征提取 api-version
// @Description zh-CN 文本特征提取服务 api-version
apiVersion string
}
func (c *azureProviderInitializer) InitConfig(json gjson.Result) {
azureConfig.apiKey = json.Get("apiKey").String()
azureConfig.apiVersion = json.Get("apiVersion").String()
}
func (c *azureProviderInitializer) ValidateConfig() error {
if azureConfig.apiKey == "" {
return errors.New("[Azure] apiKey is required")
}
if azureConfig.apiVersion == "" {
return errors.New("[Azure] apiVersion is required")
}
return nil
}
func (t *azureProviderInitializer) CreateProvider(c ProviderConfig) (Provider, error) {
if c.servicePort == 0 {
c.servicePort = AZURE_PORT
}
if c.model == "" {
c.model = AZURE_DEFAULT_MODEL_NAME
}
return &AzureProvider{
config: c,
client: wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: c.serviceName,
Host: c.serviceHost,
Port: c.servicePort,
}),
}, nil
}
func (t *AzureProvider) GetProviderType() string {
return PROVIDER_TYPE_AZURE
}
type AzureProvider struct {
config ProviderConfig
client wrapper.HttpClient
}
type AzureEmbeddingRequest struct {
Input string `json:"input"`
}
func (t *AzureProvider) constructParameters(text string) (string, [][2]string, []byte, error) {
if text == "" {
err := errors.New("queryString text cannot be empty")
return "", nil, nil, err
}
data := AzureEmbeddingRequest{
Input: text,
}
requestBody, err := json.Marshal(data)
if err != nil {
log.Errorf("failed to marshal request data: %v", err)
return "", nil, nil, err
}
model := t.config.model
if model == "" {
model = AZURE_DEFAULT_MODEL_NAME
}
// 拼接 endpoint
endpoint := strings.Replace(AZURE_ENDPOINT, "{model}", model, 1)
endpoint = endpoint + "?" + "api-version=" + azureConfig.apiVersion
headers := [][2]string{
{"api-key", azureConfig.apiKey},
{"Content-Type", "application/json"},
}
return endpoint, headers, requestBody, err
}
type AzureEmbeddingResponse struct {
Object string `json:"object"`
Model string `json:"model"`
Data []struct {
Object string `json:"object"`
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
func (t *AzureProvider) parseTextEmbedding(responseBody []byte) (*AzureEmbeddingResponse, error) {
var resp AzureEmbeddingResponse
if err := json.Unmarshal(responseBody, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &resp, nil
}
func (t *AzureProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString)
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err
}
var resp *AzureEmbeddingResponse
err = t.client.Post(embUrl, embHeaders, embRequestBody,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
if statusCode != http.StatusOK {
err = fmt.Errorf("failed to get embedding due to status code: %d, resp: %s", statusCode, responseBody)
callback(nil, err)
return
}
resp, err = t.parseTextEmbedding(responseBody)
if err != nil {
err = fmt.Errorf("failed to parse response: %v", err)
callback(nil, err)
return
}
log.Debugf("get embedding response: %d, %s", statusCode, responseBody)
if len(resp.Data) == 0 {
err = errors.New("no embedding found in response")
callback(nil, err)
return
}
callback(resp.Data[0].Embedding, nil)
}, t.config.timeout)
return err
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
@@ -79,7 +80,7 @@ type CohereProvider struct {
func (t *CohereProvider) GetProviderType() string {
return PROVIDER_TYPE_COHERE
}
func (t *CohereProvider) constructParameters(texts []string, log wrapper.Log) (string, [][2]string, []byte, error) {
func (t *CohereProvider) constructParameters(texts []string) (string, [][2]string, []byte, error) {
model := t.config.model
if model == "" {
@@ -118,9 +119,8 @@ func (t *CohereProvider) parseTextEmbedding(responseBody []byte) (*cohereRespons
func (t *CohereProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := t.constructParameters([]string{queryString}, log)
embUrl, embHeaders, embRequestBody, err := t.constructParameters([]string{queryString})
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
@@ -103,7 +104,7 @@ type DSProvider struct {
client wrapper.HttpClient
}
func (d *DSProvider) constructParameters(texts []string, log wrapper.Log) (string, [][2]string, []byte, error) {
func (d *DSProvider) constructParameters(texts []string) (string, [][2]string, []byte, error) {
model := d.config.model
@@ -159,9 +160,8 @@ func (d *DSProvider) parseTextEmbedding(responseBody []byte) (*Response, error)
func (d *DSProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := d.constructParameters([]string{queryString}, log)
embUrl, embHeaders, embRequestBody, err := d.constructParameters([]string{queryString})
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err

View File

@@ -4,11 +4,13 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
"net/http"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
@@ -18,29 +20,29 @@ const (
HUGGINGFACE_ENDPOINT = "/pipeline/feature-extraction/{modelId}"
)
type HuggingFaceProviderInitializer struct {
type huggingfaceProviderInitializer struct {
}
var HuggingFaceConfig HuggingFaceProviderConfig
var huggingfaceConfig huggingfaceProviderConfig
type HuggingFaceProviderConfig struct {
type huggingfaceProviderConfig struct {
// @Title zh-CN 文本特征提取服务 API Key
// @Description zh-CN 文本特征提取服务 API Key。在HuggingFace定义为 hf_token
apiKey string
}
func (c *HuggingFaceProviderInitializer) InitConfig(json gjson.Result) {
HuggingFaceConfig.apiKey = json.Get("apiKey").String()
func (c *huggingfaceProviderInitializer) InitConfig(json gjson.Result) {
huggingfaceConfig.apiKey = json.Get("apiKey").String()
}
func (c *HuggingFaceProviderInitializer) ValidateConfig() error {
if HuggingFaceConfig.apiKey == "" {
func (c *huggingfaceProviderInitializer) ValidateConfig() error {
if huggingfaceConfig.apiKey == "" {
return errors.New("[HuggingFace] hfTokens is required")
}
return nil
}
func (t *HuggingFaceProviderInitializer) CreateProvider(c ProviderConfig) (Provider, error) {
func (t *huggingfaceProviderInitializer) CreateProvider(c ProviderConfig) (Provider, error) {
if c.servicePort == 0 {
c.servicePort = HUGGINGFACE_PORT
}
@@ -78,7 +80,7 @@ type HuggingFaceEmbeddingRequest struct {
} `json:"options"`
}
func (t *HuggingFaceProvider) constructParameters(text string, log wrapper.Log) (string, [][2]string, []byte, error) {
func (t *HuggingFaceProvider) constructParameters(text string) (string, [][2]string, []byte, error) {
if text == "" {
err := errors.New("queryString text cannot be empty")
return "", nil, nil, err
@@ -108,7 +110,7 @@ func (t *HuggingFaceProvider) constructParameters(text string, log wrapper.Log)
endpoint := strings.Replace(HUGGINGFACE_ENDPOINT, "{modelId}", modelId, 1)
headers := [][2]string{
{"Authorization", "Bearer " + HuggingFaceConfig.apiKey},
{"Authorization", "Bearer " + huggingfaceConfig.apiKey},
{"Content-Type", "application/json"},
}
@@ -127,9 +129,8 @@ func (t *HuggingFaceProvider) parseTextEmbedding(responseBody []byte) ([]float64
func (t *HuggingFaceProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString, log)
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString)
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err

View File

@@ -4,10 +4,12 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
"net/http"
"strconv"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
@@ -69,7 +71,7 @@ type ollamaEmbeddingRequest struct {
Model string `json:"model"`
}
func (t *ollamaProvider) constructParameters(text string, log wrapper.Log) (string, [][2]string, []byte, error) {
func (t *ollamaProvider) constructParameters(text string) (string, [][2]string, []byte, error) {
if text == "" {
err := errors.New("queryString text cannot be empty")
return "", nil, nil, err
@@ -105,9 +107,8 @@ func (t *ollamaProvider) parseTextEmbedding(responseBody []byte) (*ollamaRespons
func (t *ollamaProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString, log)
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString)
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
@@ -93,7 +94,7 @@ type OpenAIProvider struct {
client wrapper.HttpClient
}
func (t *OpenAIProvider) constructParameters(text string, log wrapper.Log) (string, [][2]string, []byte, error) {
func (t *OpenAIProvider) constructParameters(text string) (string, [][2]string, []byte, error) {
if text == "" {
err := errors.New("queryString text cannot be empty")
return "", nil, nil, err
@@ -130,9 +131,8 @@ func (t *OpenAIProvider) parseTextEmbedding(responseBody []byte) (*OpenAIRespons
func (t *OpenAIProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString, log)
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString)
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err

View File

@@ -15,6 +15,7 @@ const (
PROVIDER_TYPE_OLLAMA = "ollama"
PROVIDER_TYPE_HUGGINGFACE = "huggingface"
PROVIDER_TYPE_XFYUN = "xfyun"
PROVIDER_TYPE_AZURE = "azure"
)
type providerInitializer interface {
@@ -30,8 +31,9 @@ var (
PROVIDER_TYPE_COHERE: &cohereProviderInitializer{},
PROVIDER_TYPE_OPENAI: &openAIProviderInitializer{},
PROVIDER_TYPE_OLLAMA: &ollamaProviderInitializer{},
PROVIDER_TYPE_HUGGINGFACE: &HuggingFaceProviderInitializer{},
PROVIDER_TYPE_XFYUN: &XfyunProviderInitializer{},
PROVIDER_TYPE_HUGGINGFACE: &huggingfaceProviderInitializer{},
PROVIDER_TYPE_XFYUN: &xfyunProviderInitializer{},
PROVIDER_TYPE_AZURE: &azureProviderInitializer{},
}
)
@@ -108,6 +110,5 @@ type Provider interface {
GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
@@ -97,7 +98,7 @@ type TIProvider struct {
client wrapper.HttpClient
}
func (t *TIProvider) constructParameters(texts []string, log wrapper.Log) (string, [][2]string, []byte, error) {
func (t *TIProvider) constructParameters(texts []string) (string, [][2]string, []byte, error) {
data := TextInEmbeddingRequest{
Input: texts,
@@ -142,9 +143,8 @@ func (t *TIProvider) parseTextEmbedding(responseBody []byte) (*TextInResponse, e
func (t *TIProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := t.constructParameters([]string{queryString}, log)
embUrl, embHeaders, embRequestBody, err := t.constructParameters([]string{queryString})
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err

View File

@@ -8,13 +8,15 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
"math"
"net/http"
"net/url"
"strconv"
"time"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
@@ -22,12 +24,12 @@ const (
XFYUN_PORT = 443
)
type XfyunProviderInitializer struct {
type xfyunProviderInitializer struct {
}
var XfyunConfig XfyunProviderConfig
var xfyunConfig xfyunProviderConfig
type XfyunProviderConfig struct {
type xfyunProviderConfig struct {
// @Title zh-CN 文本特征提取服务 API Key
// @Description zh-CN 文本特征提取服务 API Key。
apiKey string
@@ -39,26 +41,26 @@ type XfyunProviderConfig struct {
xfyunApiSecret string
}
func (c *XfyunProviderInitializer) InitConfig(json gjson.Result) {
XfyunConfig.xfyunAppID = json.Get("appId").String()
XfyunConfig.xfyunApiSecret = json.Get("apiSecret").String()
XfyunConfig.apiKey = json.Get("apiKey").String()
func (c *xfyunProviderInitializer) InitConfig(json gjson.Result) {
xfyunConfig.xfyunAppID = json.Get("appId").String()
xfyunConfig.xfyunApiSecret = json.Get("apiSecret").String()
xfyunConfig.apiKey = json.Get("apiKey").String()
}
func (c *XfyunProviderInitializer) ValidateConfig() error {
if XfyunConfig.apiKey == "" {
func (c *xfyunProviderInitializer) ValidateConfig() error {
if xfyunConfig.apiKey == "" {
return errors.New("[Xfyun] apiKey is required")
}
if XfyunConfig.xfyunAppID == "" {
if xfyunConfig.xfyunAppID == "" {
return errors.New("[Xfyun] appId is required")
}
if XfyunConfig.xfyunApiSecret == "" {
if xfyunConfig.xfyunApiSecret == "" {
return errors.New("[Xfyun] apiSecret is required")
}
return nil
}
func (t *XfyunProviderInitializer) CreateProvider(c ProviderConfig) (Provider, error) {
func (t *xfyunProviderInitializer) CreateProvider(c ProviderConfig) (Provider, error) {
if c.servicePort == 0 {
c.servicePort = XFYUN_PORT
}
@@ -160,14 +162,14 @@ func constructAuth(requestURL, method, apiKey, apiSecret string) (string, error)
return "?" + params.Encode(), nil
}
func (t *XfyunProvider) constructParameters(text string, log wrapper.Log) (string, [][2]string, []byte, error) {
func (t *XfyunProvider) constructParameters(text string) (string, [][2]string, []byte, error) {
if text == "" {
err := errors.New("queryString text cannot be empty")
return "", nil, nil, err
}
host := "https://" + t.config.serviceHost + "/"
auth, err := constructAuth(host, "POST", XfyunConfig.apiKey, XfyunConfig.xfyunApiSecret)
auth, err := constructAuth(host, "POST", xfyunConfig.apiKey, xfyunConfig.xfyunApiSecret)
if err != nil {
return "", nil, nil, err
}
@@ -199,7 +201,7 @@ func (t *XfyunProvider) constructParameters(text string, log wrapper.Log) (strin
// 构建请求体
data := XfyunReqBody{
Header: XfyunHeader{
AppID: XfyunConfig.xfyunAppID,
AppID: xfyunConfig.xfyunAppID,
Status: 3,
},
Parameter: XfyunParameter{
@@ -265,9 +267,8 @@ func (t *XfyunProvider) parseTextEmbedding(responseBody []byte) ([]float32, erro
func (t *XfyunProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString, log)
embUrl, embHeaders, embRequestBody, err := t.constructParameters(queryString)
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err

View File

@@ -36,7 +36,7 @@ description: AI 代理插件配置参考
| `type` | string | 必填 | - | AI 服务提供商名称 |
| `apiTokens` | array of string | 非必填 | - | 用于在访问 AI 服务时进行认证的令牌。如果配置了多个 token插件会在请求时随机进行选择。部分服务提供商只支持配置一个 token。 |
| `timeout` | number | 非必填 | - | 访问 AI 服务的超时时间。单位为毫秒。默认值为 120000即 2 分钟。此项配置目前仅用于获取上下文信息,并不影响实际转发大模型请求。 |
| `modelMapping` | map of string | 非必填 | - | AI 模型映射表,用于将请求中的模型名称映射为服务提供商支持模型名称。<br/>1. 支持前缀匹配。例如用 "gpt-3-*" 匹配所有名称以“gpt-3-”开头的模型;<br/>2. 支持使用 "*" 为键来配置通用兜底映射关系;<br/>3. 如果映射的目标名称为空字符串 "",则表示保留原模型名称。 |
| `modelMapping` | map of string | 非必填 | - | AI 模型映射表,用于将请求中的模型名称映射为服务提供商支持模型名称。<br/>1. 支持前缀匹配。例如用 "gpt-3-\*" 匹配所有名称以“gpt-3-”开头的模型;<br/>2. 支持使用 "\*" 为键来配置通用兜底映射关系;<br/>3. 如果映射的目标名称为空字符串 "",则表示保留原模型名称。 |
| `protocol` | string | 非必填 | - | 插件对外提供的 API 接口契约。目前支持以下取值openai默认值使用 OpenAI 的接口契约、original使用目标服务提供商的原始接口契约 |
| `context` | object | 非必填 | - | 配置 AI 对话上下文信息 |
| `customSettings` | array of customSetting | 非必填 | - | 为AI请求指定覆盖或者填充参数 |

View File

@@ -34,7 +34,7 @@ Plugin execution priority: `100`
| `type` | string | Required | - | Name of the AI service provider |
| `apiTokens` | array of string | Optional | - | Tokens used for authentication when accessing AI services. If multiple tokens are configured, the plugin randomly selects one for each request. Some service providers only support configuring a single token. |
| `timeout` | number | Optional | - | Timeout for accessing AI services, in milliseconds. The default value is 120000, which equals 2 minutes. Only used when retrieving context data. Won't affect the request forwarded to the LLM upstream. |
| `modelMapping` | map of string | Optional | - | Mapping table for AI models, used to map model names in requests to names supported by the service provider.<br/>1. Supports prefix matching. For example, "gpt-3-*" matches all model names starting with “gpt-3-”;<br/>2. Supports using "*" as a key for a general fallback mapping;<br/>3. If the mapped target name is an empty string "", the original model name is preserved. |
| `modelMapping` | map of string | Optional | - | Mapping table for AI models, used to map model names in requests to names supported by the service provider.<br/>1. Supports prefix matching. For example, "gpt-3-\*" matches all model names starting with “gpt-3-”;<br/>2. Supports using "\*" as a key for a general fallback mapping;<br/>3. If the mapped target name is an empty string "", the original model name is preserved. |
| `protocol` | string | Optional | - | API contract provided by the plugin. Currently supports the following values: openai (default, uses OpenAI's interface contract), original (uses the raw interface contract of the target service provider) |
| `context` | object | Optional | - | Configuration for AI conversation context information |
| `customSettings` | array of customSetting | Optional | - | Specifies overrides or fills parameters for AI requests |

View File

@@ -59,6 +59,7 @@ static_resources:
"activeProviderId": "moonshot",
"providers": [
{
"id": "moonshot",
"type": "moonshot",
"domain": "api.moonshot.cn",
"apiTokens": [
@@ -71,11 +72,13 @@ static_resources:
"gpt-35-turbo": "moonshot-v1-32k",
"gpt-4-turbo": "moonshot-v1-128k",
"*": "moonshot-v1-8k"
},
}
}
]
}
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: httpbin
connect_timeout: 30s

View File

@@ -61,12 +61,43 @@ Attribute 配置说明:
### 空配置
#### 监控
```
route_upstream_model_metric_input_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 10
route_upstream_model_metric_llm_duration_count{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1
route_upstream_model_metric_llm_first_token_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 309
route_upstream_model_metric_llm_service_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1955
route_upstream_model_metric_output_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 69
# counter 类型,输入 token 数量的累加值
route_upstream_model_consumer_metric_input_token{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 24
# counter 类型,输出 token 数量的累加值
route_upstream_model_consumer_metric_output_token{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 507
# counter 类型,流式请求和非流式请求消耗总时间的累加值
route_upstream_model_consumer_metric_llm_service_duration{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 6470
# counter 类型,流式请求和非流式请求次数的累加值
route_upstream_model_consumer_metric_llm_duration_count{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 2
# counter 类型,流式请求首个 token 延时的累加值
route_upstream_model_consumer_metric_llm_first_token_duration{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 340
# counter 类型,流式请求次数的累加值
route_upstream_model_consumer_metric_llm_stream_duration_count{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 1
```
以下是使用指标的几个示例:
流式请求首个 token 的平均延时:
```
irate(route_upstream_model_consumer_metric_llm_first_token_duration[2m])
/
irate(route_upstream_model_consumer_metric_llm_stream_duration_count[2m])
```
流式请求和非流式请求平均消耗的总时长:
```
irate(route_upstream_model_consumer_metric_llm_service_duration[2m])
/
irate(route_upstream_model_consumer_metric_llm_duration_count[2m])
```
#### 日志
@@ -101,18 +132,19 @@ attributes:
apply_to_span: false
```
#### 监控
```
route_upstream_model_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343
route_upstream_model_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153
route_upstream_model_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725
route_upstream_model_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1
route_upstream_model_consumer_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343
route_upstream_model_consumer_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153
route_upstream_model_consumer_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725
route_upstream_model_consumer_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1
```
#### 日志
此配置下日志效果如下:
```json
{
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
}
```
@@ -120,7 +152,7 @@ route_upstream_model_metric_llm_duration_count{ai_route="bailian",ai_cluster="qw
链路追踪的 span 中可以看到 model, input_token, output_token 三个额外的 attribute
### 配合认证鉴权记录consumer
举例如下:
举例如下:
```yaml
attributes:
- key: consumer # 配合认证鉴权记录consumer

View File

@@ -48,12 +48,12 @@ The meanings of various values for `value_source` are as follows:
When `value_source` is `response_streaming_body`, `rule` should be configured to specify how to obtain the specified value from the streaming body. The meaning of the value is as follows:
- `first`: extract value from the first valid chunk
- `replace`: extract value from the last valid chunk
- `first`: extract value from the first valid chunk
- `replace`: extract value from the last valid chunk
- `append`: join value pieces from all valid chunks
## Configuration example
If you want to record ai-statistic related statistical values in the gateway access log, you need to modify log_format and add a new field based on the original log_format. The example is as follows:
If you want to record ai-statistic related statistical values in the gateway access log, you need to modify log_format and add a new field based on the original log_format. The example is as follows:
```yaml
'{"ai_log":"%FILTER_STATE(wasm.ai_log:PLAIN)%"}'
@@ -61,12 +61,43 @@ If you want to record ai-statistic related statistical values in the
### Empty
#### Metric
```
route_upstream_model_metric_input_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 10
route_upstream_model_metric_llm_duration_count{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1
route_upstream_model_metric_llm_first_token_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 309
route_upstream_model_metric_llm_service_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1955
route_upstream_model_metric_output_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 69
# counter, cumulative count of input tokens
route_upstream_model_consumer_metric_input_token{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 24
# counter, cumulative count of output tokens
route_upstream_model_consumer_metric_output_token{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 507
# counter, cumulative total duration of both streaming and non-streaming requests
route_upstream_model_consumer_metric_llm_service_duration{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 6470
# counter, cumulative count of both streaming and non-streaming requests
route_upstream_model_consumer_metric_llm_duration_count{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 2
# counter, cumulative latency of the first token in streaming requests
route_upstream_model_consumer_metric_llm_first_token_duration{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 340
# counter, cumulative count of streaming requests
route_upstream_model_consumer_metric_llm_stream_duration_count{ai_route="ai-route-aliyun.internal",ai_cluster="outbound|443||llm-aliyun.internal.dns",ai_model="qwen-turbo",ai_consumer="none"} 1
```
Below are some example usages of these metrics:
Average latency of the first token in streaming requests:
```
irate(route_upstream_model_consumer_metric_llm_first_token_duration[2m])
/
irate(route_upstream_model_consumer_metric_llm_stream_duration_count[2m])
```
Average process duration of both streaming and non-streaming requests:
```
irate(route_upstream_model_consumer_metric_llm_service_duration[2m])
/
irate(route_upstream_model_consumer_metric_llm_duration_count[2m])
```
#### Log
@@ -101,17 +132,18 @@ attributes:
apply_to_span: false
```
#### Metric
```
route_upstream_model_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343
route_upstream_model_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153
route_upstream_model_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725
route_upstream_model_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1
route_upstream_model_consumer_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343
route_upstream_model_consumer_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153
route_upstream_model_consumer_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725
route_upstream_model_consumer_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1
```
#### Log
```json
{
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
}
```
@@ -130,7 +162,7 @@ attributes:
### Record questions and answers
```yaml
attributes:
- key: question
- key: question
value_source: request_body
value: messages.@reverse.0.content
apply_to_log: true

View File

@@ -17,4 +17,5 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/resp v0.1.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
)

View File

@@ -9,6 +9,7 @@ github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -17,4 +18,6 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -46,7 +46,7 @@ const (
ResponseStreamingBody = "response_streaming_body"
ResponseBody = "response_body"
// Inner metric & log attributes name
// Inner metric & log attributes
Model = "model"
InputToken = "input_token"
OutputToken = "output_token"
@@ -55,6 +55,16 @@ const (
LLMDurationCount = "llm_duration_count"
LLMStreamDurationCount = "llm_stream_duration_count"
ResponseType = "response_type"
ChatID = "chat_id"
ChatRound = "chat_round"
// Inner span attributes
ArmsSpanKind = "gen_ai.span.kind"
ArmsModelName = "gen_ai.model_name"
ArmsRequestModel = "gen_ai.request.model"
ArmsInputToken = "gen_ai.usage.input_tokens"
ArmsOutputToken = "gen_ai.usage.output_tokens"
ArmsTotalToken = "gen_ai.usage.total_tokens"
// Extract Rule
RuleFirst = "first"
@@ -171,7 +181,8 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config AIStatisticsConfig, lo
setAttributeBySource(ctx, config, FixedValue, nil, log)
// Set user defined log & span attributes which type is request_header
setAttributeBySource(ctx, config, RequestHeader, nil, log)
// Set request start time.
// Set span attributes for ARMS.
setSpanAttribute(ArmsSpanKind, "LLM", log)
return types.ActionContinue
}
@@ -179,6 +190,22 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config AIStatisticsConfig, lo
func onHttpRequestBody(ctx wrapper.HttpContext, config AIStatisticsConfig, body []byte, log wrapper.Log) types.Action {
// Set user defined log & span attributes.
setAttributeBySource(ctx, config, RequestBody, body, log)
// Set span attributes for ARMS.
requestModel := gjson.GetBytes(body, "model").String()
if requestModel == "" {
requestModel = "UNKNOWN"
}
setSpanAttribute(ArmsRequestModel, requestModel, log)
// Set the number of conversation rounds
if gjson.GetBytes(body, "messages").Exists() {
userPromptCount := 0
for _, msg := range gjson.GetBytes(body, "messages").Array() {
if msg.Get("role").String() == "user" {
userPromptCount += 1
}
}
ctx.SetUserAttribute(ChatRound, userPromptCount)
}
// Write log
ctx.WriteUserAttributeToLogWithKey(wrapper.AILogKey)
@@ -211,6 +238,10 @@ func onHttpStreamingBody(ctx wrapper.HttpContext, config AIStatisticsConfig, dat
}
ctx.SetUserAttribute(ResponseType, "stream")
chatID := gjson.GetBytes(data, "id").String()
if chatID != "" {
ctx.SetUserAttribute(ChatID, chatID)
}
// Get requestStartTime from http context
requestStartTime, ok := ctx.GetContext(StatisticsRequestStartTime).(int64)
@@ -231,6 +262,11 @@ func onHttpStreamingBody(ctx wrapper.HttpContext, config AIStatisticsConfig, dat
ctx.SetUserAttribute(Model, model)
ctx.SetUserAttribute(InputToken, inputToken)
ctx.SetUserAttribute(OutputToken, outputToken)
// Set span attributes for ARMS.
setSpanAttribute(ArmsModelName, model, log)
setSpanAttribute(ArmsInputToken, inputToken, log)
setSpanAttribute(ArmsOutputToken, outputToken, log)
setSpanAttribute(ArmsTotalToken, inputToken+outputToken, log)
}
// If the end of the stream is reached, record metrics/logs/spans.
if endOfStream {
@@ -263,12 +299,21 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AIStatisticsConfig, body
ctx.SetUserAttribute(LLMServiceDuration, responseEndTime-requestStartTime)
ctx.SetUserAttribute(ResponseType, "normal")
chatID := gjson.GetBytes(body, "id").String()
if chatID != "" {
ctx.SetUserAttribute(ChatID, chatID)
}
// Set information about this request
if model, inputToken, outputToken, ok := getUsage(body); ok {
ctx.SetUserAttribute(Model, model)
ctx.SetUserAttribute(InputToken, inputToken)
ctx.SetUserAttribute(OutputToken, outputToken)
// Set span attributes for ARMS.
setSpanAttribute(ArmsModelName, model, log)
setSpanAttribute(ArmsInputToken, inputToken, log)
setSpanAttribute(ArmsOutputToken, outputToken, log)
setSpanAttribute(ArmsTotalToken, inputToken+outputToken, log)
}
// Set user defined log & span attributes.
@@ -283,8 +328,14 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AIStatisticsConfig, body
return types.ActionContinue
}
func unifySSEChunk(data []byte) []byte {
data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n"))
return data
}
func getUsage(data []byte) (model string, inputTokenUsage int64, outputTokenUsage int64, ok bool) {
chunks := bytes.Split(bytes.TrimSpace(data), []byte("\n\n"))
chunks := bytes.Split(bytes.TrimSpace(unifySSEChunk(data)), []byte("\n\n"))
for _, chunk := range chunks {
// the feature strings are used to identify the usage data, like:
// {"model":"gpt2","usage":{"prompt_tokens":1,"completion_tokens":1}}
@@ -353,7 +404,7 @@ func setAttributeBySource(ctx wrapper.HttpContext, config AIStatisticsConfig, so
}
func extractStreamingBodyByJsonPath(data []byte, jsonPath string, rule string, log wrapper.Log) interface{} {
chunks := bytes.Split(bytes.TrimSpace(data), []byte("\n\n"))
chunks := bytes.Split(bytes.TrimSpace(unifySSEChunk(data)), []byte("\n\n"))
var value interface{}
if rule == RuleFirst {
for _, chunk := range chunks {

View File

@@ -5,8 +5,7 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/higress-group/proxy-wasm-go-sdk v1.0.0 h1:BZRNf4R7jr9hwRivg/E29nkVaKEak5MWjBDhWjuHijU=
github.com/higress-group/proxy-wasm-go-sdk v1.0.0/go.mod h1:iiSyFbo+rAtbtGt/bsefv8GU57h9CCLYGJA74/tF5/0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
@@ -14,8 +13,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/tetratelabs/wazero v1.7.1 h1:QtSfd6KLc41DIMpDYlJdoMc6k7QTN246DM2+n2Y/Dx8=
github.com/tetratelabs/wazero v1.7.1/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=

View File

@@ -38,7 +38,7 @@ func main() {
}
const (
ClusterRateLimitFormat string = "higress-cluster-key-rate-limit:%s:%s:%s:%s" // redis key为前缀:限流规则名称:限流类型:限流key名称:限流key对应的实际值
ClusterRateLimitFormat string = "higress-cluster-key-rate-limit:%s:%s:%d:%d:%s:%s" // redis key为前缀:限流规则名称:限流类型:时间窗口:窗口内限流数:限流key名称:限流key对应的实际值
FixedWindowScript string = `
local ttl = redis.call('ttl', KEYS[1])
if ttl < 0 then
@@ -84,7 +84,7 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config ClusterKeyRateLimitCon
}
// 构建redis限流key和参数
limitKey := fmt.Sprintf(ClusterRateLimitFormat, config.ruleName, ruleItem.limitType, ruleItem.key, val)
limitKey := fmt.Sprintf(ClusterRateLimitFormat, config.ruleName, ruleItem.limitType, configItem.timeWindow, configItem.count, ruleItem.key, val)
keys := []interface{}{limitKey}
args := []interface{}{configItem.count, configItem.timeWindow}
// 执行限流逻辑

View File

@@ -14,34 +14,152 @@ description: 自定义应答插件配置参考
插件执行优先级:`910`
## 配置字段
### 新版本-支持多种返回
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-------|------------------|------|-----|-------------------------------------|
| rules | array of object | 必填 | - | 规则组 |
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------- | -------- | -------- | -------- | -------- |
| status_code | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| headers | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| body | string | 选填 | - | 自定义 HTTP 应答 Body |
| enable_on_status | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 |
`rules`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------------------|---------------------------|------|-----|------------------------------------------------------------------------------------------------------------------------------------------------------|
| `status_code` | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| `headers` | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| `body` | string | 选填 | - | 自定义 HTTP 应答 Body |
| `enable_on_status` | array of string or number | 选填 | - | 匹配原始状态码,生成自定义响应。可填写精确值如:`200`,`404`等,也可以模糊匹配例如:`2xx`来匹配200-299之间的状态码`20x`来匹配200-209之间的状态码x代表任意一位数字。不填写时不判断原始状态码,取第一个`enable_on_status`为空的规则作为默认规则 |
#### 模糊匹配规则:
* 长度为3
* 至少一位数字
* 至少一位x(不区分大小写)
| 规则 | 匹配内容 |
|-----|------------------------------------------------------------------------------------------|
| 40x | 400-409前两位为40的情况 |
| 1x4 | 104,114,124,134,144,154,164,174,184,194第一位和第三位分别为1和4的情况 |
| x23 | 023,123,223,323,423,523,623,723,823,923第二位和第三位为23的情况 |
| 4xx | 400-499第一位为4的情况 |
| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949第二位为4的情况 |
| xx4 | 尾数为4的情况 |
### 老版本-只支持一种返回
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------- | -------- |------| -------- |---------------------------------|
| `status_code` | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| `headers` | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| `body` | string | 选填 | - | 自定义 HTTP 应答 Body |
| `enable_on_status` | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 |
匹配优先级:精确匹配 > 模糊匹配 > 默认配置(第一个enable_on_status为空的配置)
## 配置示例
### Mock 应答场景
### 新版本-不同状态码不同应答场景
```yaml
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
- 201
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 404"}'
enable_on_status:
- 404
headers:
- key1=value1
- key2=value2
status_code: 200
```
根据该配置,请求将返回自定义应答如下:
根据该配置,200、201请求将返回自定义应答如下:
```text
HTTP/1.1 200 OK
Content-Type: application/json
Hello: World
Content-Length: 17
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
根据该配置404请求将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 400"}
```
### 新版本-模糊匹配场景
```yaml
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 40x"}'
enable_on_status:
- '40x'
headers:
- key1=value1
- key2=value2
status_code: 200
```
根据该配置200状态码将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
根据该配置401-409之间的状态码将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 40x"}
```
### 老版本-不同状态码相同应答场景
```yaml
enable_on_status:
- 200
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
```
根据该配置200请求将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world"}
```

View File

@@ -12,30 +12,165 @@ Plugin Execution Phase: `Authentication Phase`
Plugin Execution Priority: `910`
## Configuration Fields
| Name | Data Type | Requirements | Default Value | Description |
| -------- | -------- | -------- | -------- | -------- |
| status_code | number | Optional | 200 | Custom HTTP response status code |
| headers | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| body | string | Optional | - | Custom HTTP response body |
| enable_on_status | array of number | Optional | - | Match original status codes to generate custom responses; if not specified, the original status code is not checked |
### New version - Supports multiple returns
| Name | Data Type | Requirements | Default Value | Description |
|---------------------|-----------------|----------|-----|------------|
| rules | array of object | Required | - | rule array |
The configuration field description of `rules` is as follows
| Name | Data Type | Requirements | Default Value | Description |
|--------------------|---------------------------|--------------|-----|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `status_code` | number | Optional | 200 | Custom HTTP response status code |
| `headers` | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| `body` | string | Optional | - | Custom HTTP response body |
| `enable_on_status` | array of string or number | Optional | - | Match the original status code to generate a custom response. You can fill in the exact value such as :`200`,`404`, etc., you can also fuzzy match such as: `2xx` to match the status code between 200-299, `20x` to match the status code between 200-209, x represents any digit. If enable_on_status is not specified, the original status code is not determined and the first rule with ENABLE_ON_status left blank is used as the default rule |
#### Fuzzy matching rule
* Length is 3
* At least one digit
* At least one x(case insensitive)
| rule | Matching content |
|------|------------------------------------------------------------------------------------------|
| 40x | 400-409; If the first two digits are 40 |
| 1x4 | 104,114,124,134,144,154,164,174,184,194The first and third positions are 1 and 4 respectively |
| x23 | 023,123,223,323,423,523,623,723,823,923The second and third positions are 23 |
| 4xx | 400-499The first digit is 4 |
| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949The second digit is 4 |
| xx4 | When the mantissa is 4 |
Matching priority: Exact Match > Fuzzy Match > Default configuration (the first enable_on_status parameter is null)
## Old version - Only one return is supported
| Name | Data Type | Requirements | Default Value | Description |
| -------- | -------- | -------- | -------- |----------------------------------------------------------------------------------------------------|
| `status_code` | number | Optional | 200 | Custom HTTP response status code |
| `headers` | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| `body` | string | Optional | - | Custom HTTP response body |
| `enable_on_status` | array of number | Optional | - | Match original status codes to generate custom responses; if not specified, the original status code is not checked |
## Configuration Example
### Mock Response Scenario
### Different status codes for different response scenarios
```yaml
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- '200'
- '201'
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 404"}'
enable_on_status:
- '404'
headers:
- key1=value1
- key2=value2
status_code: 200
```
With this configuration, the request will return the following custom response:
According to this configuration 200 201 requests will return a custom response as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
Hello: World
Content-Length: 17
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
According to this configuration 404 requests will return a custom response as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 400"}
```
With this configuration, 404 response will return the following custom response:
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 404"}
```
### Fuzzy matching scene
```yaml
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 40x"}'
enable_on_status:
- '40x'
headers:
- key1=value1
- key2=value2
status_code: 200
```
According to this configuration, the status 200 will return a custom reply as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
According to this configuration, the status code between 401-409 will return a custom reply as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 40x"}
```
### Mock Response Scenario
```yaml
enable_on_status:
- 200
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
```
With this configuration, 200/201 response will return the following custom response:
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world"}
```
### Custom Response on Rate Limiting
```yaml
enable_on_status:

View File

@@ -1 +1 @@
1.0.0
1.1.0

View File

@@ -0,0 +1,25 @@
services:
envoy:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.0.7
entrypoint: /usr/local/bin/envoy
# 注意这里对 Wasm 开启了 debug 级别日志,在生产环境部署时请使用默认的 info 级别
# 如果需要将 Envoy 的日志级别调整为 debug将 --log-level 参数设置为 debug
command: -c /etc/envoy/envoy.yaml --log-level info --component-log-level wasm:debug
depends_on:
- echo-server
networks:
- wasmtest
ports:
- "10000:10000"
- "9901:9901"
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml
- ./plugin.wasm:/etc/envoy/plugin.wasm
echo-server:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0
networks:
- wasmtest
ports:
- "3000:3000"
networks:
wasmtest: {}

View File

@@ -1,9 +1,3 @@
admin:
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener_0
@@ -27,9 +21,9 @@ static_resources:
domains: ["*"]
routes:
- match:
prefix: "/"
prefix: "/echo"
route:
cluster: httpbin
cluster: echo-server
http_filters:
- name: wasmdemo
typed_config:
@@ -42,30 +36,100 @@ static_resources:
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/main.wasm
filename: /etc/envoy/plugin.wasm
configuration:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
# value: |-
# {
# "rules": [
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "enable_on_status": [
# 200,
# 201
# ],
# "body": "{\"hello\":\"world 200\"}"
# },
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "enable_on_status": [
# 404
# ],
# "body": "{\"hello\":\"world 404\"}"
# }
# ]
# }
value: |-
{
"headers": ["key1=value1", "key2=value2"],
"status_code": 200,
"enable_on_status": [200, 201],
"body": "{\"hello\":\"world\"}"
"rules": [
{
"headers": [
"key1=value1",
"key2=value2"
],
"status_code": 200,
"enable_on_status": [
200
],
"body": "{\"hello\":\"world 200\"}"
},
{
"headers": [
"key1=value1",
"key2=value2"
],
"status_code": 200,
"enable_on_status": [
"40x"
],
"body": "{\"hello\":\"world 40x\"}"
}
]
}
# value: |-
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "body": "{\"hello\":\"world 200\"}",
# "enable_on_status": [
# 200
# ]
# }
# value: |-
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "body": "{\"hello\":\"world 200\"}"
# }
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: httpbin
- name: echo-server
connect_timeout: 30s
type: LOGICAL_DNS
# Comment out the following line to test on v6 networks
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin
cluster_name: echo-server
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: httpbin
port_value: 80
address: echo-server
port_value: 3000

View File

@@ -16,10 +16,12 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
@@ -29,78 +31,160 @@ import (
func main() {
wrapper.SetCtx(
"custom-response",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
wrapper.ParseConfig(parseConfig),
wrapper.ProcessRequestHeaders(onHttpRequestHeaders),
wrapper.ProcessResponseHeaders(onHttpResponseHeaders),
)
}
type CustomResponseConfig struct {
rules []CustomResponseRule
defaultRule *CustomResponseRule
enableOnStatusRuleMap map[string]*CustomResponseRule
}
type CustomResponseRule struct {
statusCode uint32
headers [][2]string
body string
enableOnStatus []uint32
enableOnStatus []string
contentType string
}
func parseConfig(gjson gjson.Result, config *CustomResponseConfig, log wrapper.Log) error {
func parseConfig(gjson gjson.Result, config *CustomResponseConfig) error {
rules := gjson.Get("rules")
rulesVersion := rules.Exists() && rules.IsArray()
if rulesVersion {
for _, cf := range gjson.Get("rules").Array() {
item := new(CustomResponseRule)
if err := parseRuleItem(cf, item); err != nil {
return err
}
// the first rule item which enableOnStatus is empty to be set default
if len(item.enableOnStatus) == 0 && config.defaultRule == nil {
config.defaultRule = item
}
config.rules = append(config.rules, *item)
}
} else {
rule := new(CustomResponseRule)
if err := parseRuleItem(gjson, rule); err != nil {
return err
}
config.rules = append(config.rules, *rule)
config.defaultRule = rule
}
config.enableOnStatusRuleMap = make(map[string]*CustomResponseRule)
for i, configItem := range config.rules {
for _, statusCode := range configItem.enableOnStatus {
if v, ok := config.enableOnStatusRuleMap[statusCode]; ok {
log.Errorf("enable_on_status code used in %v, want to add %v", v, statusCode)
return errors.New("enableOnStatus can only use once")
}
config.enableOnStatusRuleMap[statusCode] = &config.rules[i]
}
}
if rulesVersion && config.defaultRule == nil && len(config.enableOnStatusRuleMap) == 0 {
return errors.New("no valid config is found")
}
return nil
}
func parseRuleItem(gjson gjson.Result, rule *CustomResponseRule) error {
headersArray := gjson.Get("headers").Array()
config.headers = make([][2]string, 0, len(headersArray))
rule.headers = make([][2]string, 0, len(headersArray))
for _, v := range headersArray {
kv := strings.SplitN(v.String(), "=", 2)
if len(kv) == 2 {
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
if strings.EqualFold(key, "content-type") {
config.contentType = value
rule.contentType = value
} else if strings.EqualFold(key, "content-length") {
continue
} else {
config.headers = append(config.headers, [2]string{key, value})
rule.headers = append(rule.headers, [2]string{key, value})
}
} else {
return fmt.Errorf("invalid header pair format: %s", v.String())
}
}
config.body = gjson.Get("body").String()
if config.contentType == "" && config.body != "" {
if json.Valid([]byte(config.body)) {
config.contentType = "application/json; charset=utf-8"
rule.body = gjson.Get("body").String()
if rule.contentType == "" && rule.body != "" {
if json.Valid([]byte(rule.body)) {
rule.contentType = "application/json; charset=utf-8"
} else {
config.contentType = "text/plain; charset=utf-8"
rule.contentType = "text/plain; charset=utf-8"
}
}
config.headers = append(config.headers, [2]string{"content-type", config.contentType})
rule.headers = append(rule.headers, [2]string{"content-type", rule.contentType})
config.statusCode = 200
rule.statusCode = 200
if gjson.Get("status_code").Exists() {
statusCode := gjson.Get("status_code")
parsedStatusCode, err := strconv.Atoi(statusCode.String())
if err != nil {
return fmt.Errorf("invalid status code value: %s", statusCode.String())
}
config.statusCode = uint32(parsedStatusCode)
rule.statusCode = uint32(parsedStatusCode)
}
enableOnStatusArray := gjson.Get("enable_on_status").Array()
config.enableOnStatus = make([]uint32, 0, len(enableOnStatusArray))
rule.enableOnStatus = make([]string, 0, len(enableOnStatusArray))
for _, v := range enableOnStatusArray {
parsedEnableOnStatus, err := strconv.Atoi(v.String())
s := v.String()
_, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("invalid enable_on_status value: %s", v.String())
matchString, err := isValidFuzzyMatchString(s)
if err != nil {
return err
}
rule.enableOnStatus = append(rule.enableOnStatus, matchString)
continue
}
config.enableOnStatus = append(config.enableOnStatus, uint32(parsedEnableOnStatus))
rule.enableOnStatus = append(rule.enableOnStatus, s)
}
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, log wrapper.Log) types.Action {
if len(config.enableOnStatus) != 0 {
func isValidFuzzyMatchString(s string) (string, error) {
const requiredLength = 3
if len(s) != requiredLength {
return "", fmt.Errorf("invalid enable_on_status %q: length must be %d", s, requiredLength)
}
lower := strings.ToLower(s)
hasX := false
hasDigit := false
for _, c := range lower {
switch {
case c == 'x':
hasX = true
case c >= '0' && c <= '9':
hasDigit = true
default:
return "", fmt.Errorf("invalid enable_on_status %q: must contain only digits and x/X", s)
}
}
if !hasX {
return "", fmt.Errorf("invalid enable_on_status %q: fuzzy match must contain x/X (use enable_on_status for exact statusCode matching)", s)
}
if !hasDigit {
return "", fmt.Errorf("invalid enable_on_status %q: must contain at least one digit", s)
}
return lower, nil
}
func onHttpRequestHeaders(_ wrapper.HttpContext, config CustomResponseConfig) types.Action {
if len(config.enableOnStatusRuleMap) != 0 {
return types.ActionContinue
}
err := proxywasm.SendHttpResponseWithDetail(config.statusCode, "custom-response", config.headers, []byte(config.body), -1)
log.Infof("use default rule %+v", config.defaultRule)
err := proxywasm.SendHttpResponseWithDetail(config.defaultRule.statusCode, "custom-response", config.defaultRule.headers, []byte(config.defaultRule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
@@ -108,28 +192,62 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config CustomResponseConfig,
return types.ActionPause
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, log wrapper.Log) types.Action {
// enableOnStatus is not empty, compare the status code.
func onHttpResponseHeaders(_ wrapper.HttpContext, config CustomResponseConfig) types.Action {
// enableOnStatusRuleMap is not empty, compare the status code.
// if match the status code, mock the response.
statusCodeStr, err := proxywasm.GetHttpResponseHeader(":status")
if err != nil {
log.Errorf("get http response status code failed: %v", err)
return types.ActionContinue
}
statusCode, err := strconv.ParseUint(statusCodeStr, 10, 32)
if err != nil {
log.Errorf("parse http response status code failed: %v", err)
if rule, ok := config.enableOnStatusRuleMap[statusCodeStr]; ok {
err = proxywasm.SendHttpResponseWithDetail(rule.statusCode, "custom-response", rule.headers, []byte(rule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
return types.ActionContinue
}
for _, v := range config.enableOnStatus {
if uint32(statusCode) == v {
err = proxywasm.SendHttpResponseWithDetail(config.statusCode, "custom-response", config.headers, []byte(config.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
if rule, match := fuzzyMatchCode(config.enableOnStatusRuleMap, statusCodeStr); match {
err = proxywasm.SendHttpResponseWithDetail(rule.statusCode, "custom-response", rule.headers, []byte(rule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
return types.ActionContinue
}
return types.ActionContinue
}
func fuzzyMatchCode(statusRuleMap map[string]*CustomResponseRule, statusCode string) (*CustomResponseRule, bool) {
if len(statusRuleMap) == 0 || statusCode == "" {
return nil, false
}
codeLen := len(statusCode)
for pattern, rule := range statusRuleMap {
// 规则1模式长度必须与状态码一致
if len(pattern) != codeLen {
continue
}
// 纯数字的enableOnStatus已经判断过跳过
if !strings.Contains(pattern, "x") {
continue
}
// 规则2所有数字位必须精确匹配
match := true
for i, c := range pattern {
// 如果是数字位需要校验
if c >= '0' && c <= '9' {
// 边界检查防止panic
if i >= codeLen || statusCode[i] != byte(c) {
match = false
break
}
}
// 非数字位如x自动匹配
}
if match {
return rule, true
}
}
return nil, false
}

View File

@@ -0,0 +1,80 @@
package main
import (
"testing"
)
func Test_prefixMatchCode(t *testing.T) {
rules := map[string]*CustomResponseRule{
"x01": {},
"2x3": {},
"45x": {},
"6xx": {},
"x7x": {},
"xx8": {},
}
tests := []struct {
code string
expectHit bool
}{
{"101", true}, // 匹配x01
{"201", true}, // 匹配x01
{"111", false}, // 不匹配
{"203", true}, // 匹配2x3
{"213", true}, // 匹配2x3
{"450", true}, // 匹配45x
{"451", true}, // 匹配45x
{"600", true}, // 匹配6xx
{"611", true}, // 匹配6xx
{"612", true}, // 匹配6xx
{"171", true}, // 匹配x7x
{"161", false}, // 不匹配
{"228", true}, // 匹配xx8
{"229", false}, // 不匹配
{"123", false}, // 不匹配
}
for _, tt := range tests {
_, found := fuzzyMatchCode(rules, tt.code)
if found != tt.expectHit {
t.Errorf("code:%s expect:%v got:%v", tt.code, tt.expectHit, found)
}
}
}
func TestIsValidPrefixString(t *testing.T) {
tests := []struct {
input string
expected string
hasError bool
}{
{"x1x", "x1x", false},
{"X2X", "x2x", false},
{"xx1", "xx1", false},
{"x12", "x12", false},
{"1x2", "1x2", false},
{"12x", "12x", false},
{"123", "", true}, // 缺少x
{"xxx", "", true}, // 缺少数字
{"xYx", "", true}, // 非法字符
{"x1", "", true}, // 长度不足
{"x123", "", true}, // 长度超限
}
for _, tt := range tests {
result, err := isValidFuzzyMatchString(tt.input)
if tt.hasError {
if err == nil {
t.Errorf("%q: expected error but got none", tt.input)
}
} else {
if err != nil {
t.Errorf("%q: unexpected error: %v", tt.input, err)
}
if result != tt.expected {
t.Errorf("%q: expected %q, got %q", tt.input, tt.expected, result)
}
}
}
}

View File

@@ -0,0 +1,11 @@
.PHONY: reload
build:
tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer' ./main.go
reload:
tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer' ./main.go
./envoy -c envoy.yaml --concurrency 0 --log-level info --component-log-level wasm:debug
start:
./envoy -c envoy.yaml --concurrency 0 --log-level info --component-log-level wasm:debug

View File

@@ -9,8 +9,8 @@ description: 前端灰度插件配置参考
## 运行属性
插件执行阶段:`认阶段`
插件执行优先级:`450`
插件执行阶段:`认阶段`
插件执行优先级:`1000`
## 配置字段
@@ -19,16 +19,17 @@ description: 前端灰度插件配置参考
| `grayKey` | string | 非必填 | - | 用户ID的唯一标识可以来自Cookie或者Header中比如 userid如果没有填写则使用`rules[].grayTagKey``rules[].grayTagValue`过滤灰度规则 |
| `localStorageGrayKey` | string | 非必填 | - | 使用JWT鉴权方式用户ID的唯一标识来自`localStorage`中,如果配置了当前参数,则`grayKey`失效 |
| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出比如`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` |
| `userStickyMaxAge` | int | 非必填 | 172800 | 用户粘滞的时长:单位为秒,默认为`172800`2天时间 |
| `includePathPrefixes` | array of strings | 非必填 | - | 强制处理的路径。例如,在 微前端 场景下XHR 接口如: `/resource/xxx`本质是一个资源请求,需要走插件转发逻辑。 |
| `skippedPathPrefixes` | array of strings | 非必填 | - | 用于排除特定路径,避免当前插件处理这些请求。例如,在 rewrite 场景下XHR 接口请求 `/api/xxx` 如果经过插件转发逻辑,可能会导致非预期的结果。 |
| `storeMaxAge` | int | 非必填 | 60 * 60 * 24 * 365 | 网关设置Cookie最大存储时长单位为秒默认为1年 |
| `indexPaths` | array of strings | 非必填 | - | 强制处理的路径,支持 `Glob` 模式匹配。例如:在 微前端场景下XHR 接口如: `/resource/**/manifest-main.json`本质是一个资源请求,需要走插件转发逻辑。 |
| `skippedPaths` | array of strings | 非必填 | - | 用于排除特定路径,避免当前插件处理这些请求,支持 `Glob` 模式匹配。例如,在 rewrite 场景下XHR 接口请求 `/api/**` 如果经过插件转发逻辑,可能会导致非预期的结果。 |
| `skippedByHeaders` | map of string to string | 非必填 | - | 用于通过请求头过滤,指定哪些请求不被当前插件
处理。`skippedPathPrefixes` 的优先级高于当前配置且页面HTML请求不受本配置的影响。若本配置为空,默认会判断`sec-fetch-mode=cors`以及`upgrade=websocket`两个header头进行过滤 |
处理。`skippedPaths` 的优先级高于当前配置且页面HTML请求不受本配置的影响。 |
| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 |
| `rewrite` | object | 必填 | - | 重写配置一般用于OSS/CDN前端部署的重写配置 |
| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 |
| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则以及生效版本 |
| `backendGrayTag` | string | 非必填 | `x-mse-tag` | 后端灰度版本Tag如果配置了cookie中将携带值为`${backendGrayTag}:${grayDeployments[].backendVersion}` |
| `uniqueGrayTag` | string | 非必填 | `x-higress-uid` | 开启按照比例灰度时候,网关会生成一个唯一标识存在`cookie`一方面用于session黏贴另一方面后端也可以使用这个值用于全链路的灰度串联 |
| `injection` | object | 非必填 | - | 往首页HTML中注入全局信息比如`<script>window.global = {...}</script>` |
@@ -50,7 +51,6 @@ description: 前端灰度插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|------------|--------------|------|-----|------------------------------|
| `host` | string | 非必填 | - | host地址如果是OSS则设置为 VPC 内网访问地址 |
| `notFoundUri` | string | 非必填 | - | 404 页面配置 |
| `indexRouting` | map of string to string | 非必填 | - | 用于定义首页重写路由规则。每个键 (Key) 表示首页的路由路径,值 (Value) 则指向重定向的目标文件。例如,键为 `/app1` 对应的值为 `/mfe/app1/{version}/index.html`。生效version为`0.0.1` 访问路径为 `/app1`,则重定向到 `/mfe/app1/0.0.1/index.html`。 |
| `fileRouting` | map of string to string | 非必填 | - | 用于定义资源文件重写路由规则。每个键 (Key) 表示资源访问路径,值 (Value) 则指向重定向的目标文件。例如,键为 `/app1/` 对应的值为 `/mfe/app1/{version}`。生效version为`0.0.1`,访问路径为 `/app1/js/a.js`,则重定向到 `/mfe/app1/0.0.1/js/a.js`。 |
@@ -59,6 +59,7 @@ description: 前端灰度插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|------|-----|-----------------------------------------------------------------------------------|
| `version` | string | 必填 | - | Base版本的版本号作为兜底的版本 |
| `backendVersion` | string | 必填 | - | 后端灰度版本,配合`key``${backendGrayTag}`写入cookie中 |
| `versionPredicates` | string | 必填 | - | 和`version`含义相同,但是满足多版本的需求:根据不同路由映射不同的`Version`版本。一般用于微前端的场景:一个主应用需要管理多个微应用 |
`grayDeployments`字段配置说明:
@@ -70,17 +71,28 @@ description: 前端灰度插件配置参考
| `backendVersion` | string | 必填 | - | 后端灰度版本,配合`key``${backendGrayTag}`写入cookie中 |
| `name` | string | 必填 | - | 规则名称和`rules[].name`关联 |
| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 |
| `weight` | int | 非必填 | - | 按照比例灰度,比如`50`。注意灰度规则权重总和不能超过100如果同时配置了`grayKey`以及`grayDeployments[0].weight`按照比例灰度优先生效 |
> 为了实现按比例weight 进行灰度发布,并确保用户粘滞,我们需要确认客户端的唯一性。如果配置了 grayKey则将其用作唯一标识如果未配置 grayKey则使用客户端的访问 IP 地址作为唯一标识。
| `weight` | int | 非必填 | - | 按照比例灰度,比如50。 |
>按照比例灰度注意下面几点:
> 1. 如果同时配置了`按用户灰度`以及`按比例灰度`,按`比例灰度`优先生效
> 2. 采用客户端设备标识符的哈希摘要机制实现流量比例控制其唯一性判定逻辑遵循以下原则自动生成全局唯一标识符UUID作为设备指纹可以通过`uniqueGrayTag`配置`cookie`的key值并通过SHA-256哈希算法生成对应灰度判定基准值。
`injection`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
| `globalConfig` | object | 非必填 | - | 注入到HTML首页的全局变量 |
| `head` | array of string | 非必填 | - | 注入head信息比如`<link rel="stylesheet" href="https://cdn.example.com/styles.css">` |
| `body` | object | 非必填 | - | 注入Body |
`injection.globalConfig`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
| `key` | string | 非必填 | HIGRESS_CONSOLE_CONFIG | 注入到window全局变量的key值 |
| `featureKey` | string | 非必填 | FEATURE_STATUS | 关于`rules`相关规则的命中情况,返回实例`{"beta-user":true,"inner-user":false}` |
| `value` | string | 非必填 | - | 自定义的全局变量 |
| `enabled` | boolean | 非必填 | false | 是否开启注入全局变量 |
`injection.body`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
@@ -139,8 +151,7 @@ grayDeployments:
enabled: true
weight: 80
```
总的灰度规则为100%,其中灰度版本的权重为`80%`,基线版本为`20%`。一旦用户命中了灰度规则会根据IP固定这个用户的灰度版本否则会在下次请求时随机选择一个灰度版本
总的灰度规则为100%其中灰度版本的权重为80%基线版本为20%。
### 用户信息存在JSON中
```yml
@@ -218,7 +229,6 @@ rules:
- level5
rewrite:
host: frontend-gray.oss-cn-shanghai-internal.aliyuncs.com
notFoundUri: /mfe/app1/dev/404.html
indexRouting:
/app1: '/mfe/app1/{version}/index.html'
/: '/mfe/app1/{version}/index.html',
@@ -260,7 +270,6 @@ grayDeployments:
- name: beta-user
version: gray
enabled: true
weight: 80
injection:
head:
- <script>console.log('Header')</script>

View File

@@ -1,59 +1,95 @@
---
title: Frontend Gray
keywords: [higress, frontend gray]
description: Frontend gray plugin configuration reference
---
## Function Description
The `frontend-gray` plugin implements the functionality of user gray release on the frontend. Through this plugin, it can be used for business `A/B testing`, while the `gradual release` combined with `monitorable` and `rollback` strategies ensures the stability of system release operations.
description: Frontend Gray Plugin Configuration Reference
## Runtime Attributes
Plugin execution phase: `Authentication Phase`
Plugin execution priority: `450`
## Feature Description
The `frontend-gray` plugin implements frontend user grayscale capabilities. This plugin can be used for business `A/B testing` while ensuring system release stability through `grayscale`, `monitoring`, and `rollback` strategies.
## Runtime Properties
Execution Stage: `Default Stage`
Execution Priority: `1000`
## Configuration Fields
| Name | Data Type | Requirements | Default Value | Description |
|-----------------|-------------------|---------------|---------------|-------------------------------------------------------------------------------------------------------------|
| `grayKey` | string | Optional | - | The unique identifier of the user ID, which can be from Cookie or Header, such as userid. If not provided, uses `rules[].grayTagKey` and `rules[].grayTagValue` to filter gray release rules. |
| `graySubKey` | string | Optional | - | User identity information may be output in JSON format, for example: `userInfo:{ userCode:"001" }`, in the current example, `graySubKey` is `userCode`. |
| `rules` | array of object | Required | - | User-defined different gray release rules, adapted to different gray release scenarios. |
| `rewrite` | object | Required | - | Rewrite configuration, generally used for OSS/CDN frontend deployment rewrite configurations. |
| `baseDeployment`| object | Optional | - | Configuration of the Base baseline rules. |
| `grayDeployments` | array of object | Optional | - | Configuration of the effective rules for gray release, as well as the effective versions. |
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `grayKey` | string | Optional | - | Unique user identifier from Cookie/Header (e.g., userid). If empty, uses `rules[].grayTagKey` and `rules[].grayTagValue` to filter rules. |
| `localStorageGrayKey` | string | Optional | - | When using JWT authentication, user ID comes from `localStorage`. Overrides `grayKey` if configured. |
| `graySubKey` | string | Optional | - | Used when user info is in JSON format (e.g., `userInfo:{ userCode:"001" }`). In this example, `graySubKey` would be `userCode`. |
| `storeMaxAge` | int | Optional | 31536000 | Max cookie storage duration in seconds (default: 1 year). |
| `indexPaths` | string[] | Optional | - | Paths requiring mandatory processing (supports Glob patterns). Example: `/resource/**/manifest-main.json` in micro-frontend scenarios. |
| `skippedPaths` | string[] | Optional | - | Excluded paths (supports Glob patterns). Example: `/api/**` XHR requests in rewrite scenarios. |
| `skippedByHeaders` | map<string, string> | Optional | - | Filter requests via headers. `skippedPaths` has higher priority. HTML page requests are unaffected. |
| `rules` | object[] | Required | - | User-defined grayscale rules for different scenarios. |
| `rewrite` | object | Required | - | Rewrite configuration for OSS/CDN deployments. |
| `baseDeployment` | object | Optional | - | Baseline configuration. |
| `grayDeployments` | object[] | Optional | - | Gray deployment rules and versions. |
| `backendGrayTag` | string | Optional | `x-mse-tag` | Backend grayscale tag. Cookies will carry `${backendGrayTag}:${grayDeployments[].backendVersion}` if configured. |
| `uniqueGrayTag` | string | Optional | `x-higress-uid` | UUID stored in cookies for percentage-based grayscale session stickiness and backend tracking. |
| `injection` | object | Optional | - | Inject global info into HTML (e.g., `<script>window.global = {...}</script>`). |
`rules` field configuration description:
| Name | Data Type | Requirements | Default Value | Description |
|------------------|-------------------|---------------|---------------|--------------------------------------------------------------------------------------------|
| `name` | string | Required | - | Unique identifier for the rule name, associated with `deploy.gray[].name` for effectiveness. |
| `grayKeyValue` | array of string | Optional | - | Whitelist of user IDs. |
| `grayTagKey` | string | Optional | - | Label key for user classification tagging, derived from Cookie. |
| `grayTagValue` | array of string | Optional | - | Label value for user classification tagging, derived from Cookie. |
### `rules` Field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `name` | string | Required | - | Unique rule name linked to `grayDeployments[].name`. |
| `grayKeyValue` | string[] | Optional | - | User ID whitelist. |
| `grayTagKey` | string | Optional | - | User tag key from cookies. |
| `grayTagValue` | string[] | Optional | - | User tag values from cookies. |
`rewrite` field configuration description:
> `indexRouting` homepage rewrite and `fileRouting` file rewrite essentially use prefix matching, for example, `/app1`: `/mfe/app1/{version}/index.html` represents requests with the prefix /app1 routed to `/mfe/app1/{version}/index.html` page, where `{version}` represents the version number, which will be dynamically replaced by `baseDeployment.version` or `grayDeployments[].version` during execution.
> `{version}` will be replaced dynamically during execution by the frontend version from `baseDeployment.version` or `grayDeployments[].version`.
### `rewrite` Field
> Both `indexRouting` and `fileRouting` use prefix matching. The `{version}` placeholder will be dynamically replaced by `baseDeployment.version` or `grayDeployments[].version`.
| Name | Data Type | Requirements | Default Value | Description |
|------------------|-------------------|---------------|---------------|---------------------------------------|
| `host` | string | Optional | - | Host address, if OSS set to the VPC internal access address. |
| `notFoundUri` | string | Optional | - | 404 page configuration. |
| `indexRouting` | map of string to string | Optional | - | Defines the homepage rewrite routing rules. Each key represents the homepage routing path, and the value points to the redirect target file. For example, the key `/app1` corresponds to the value `/mfe/app1/{version}/index.html`. If the effective version is `0.0.1`, the access path is `/app1`, it redirects to `/mfe/app1/0.0.1/index.html`. |
| `fileRouting` | map of string to string | Optional | - | Defines resource file rewrite routing rules. Each key represents the resource access path, and the value points to the redirect target file. For example, the key `/app1/` corresponds to the value `/mfe/app1/{version}`. If the effective version is `0.0.1`, the access path is `/app1/js/a.js`, it redirects to `/mfe/app1/0.0.1/js/a.js`. |
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `host` | string | Optional | - | Host address (use VPC endpoint for OSS). |
| `indexRouting` | map<string, string> | Optional | - | Homepage rewrite rules. Key: route path, Value: target file. Example: `/app1``/mfe/app1/{version}/index.html`. |
| `fileRouting` | map<string, string> | Optional | - | Resource rewrite rules. Key: resource path, Value: target path. Example: `/app1/` `/mfe/app1/{version}`. |
`baseDeployment` field configuration description:
| Name | Data Type | Requirements | Default Value | Description |
|------------------|-------------------|---------------|---------------|-------------------------------------------------------------------------------------------|
| `version` | string | Required | - | The version number of the Base version, as a fallback version. |
### `baseDeployment` Field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `version` | string | Required | - | Baseline version as fallback. |
| `backendVersion` | string | Required | - | Backend grayscale version written to cookies via `${backendGrayTag}`. |
| `versionPredicates` | string | Required | - | Supports multi-version mapping for micro-frontend scenarios. |
`grayDeployments` field configuration description:
| Name | Data Type | Requirements | Default Value | Description |
|------------------|-------------------|---------------|---------------|----------------------------------------------------------------------------------------------|
| `version` | string | Required | - | Version number of the Gray version, if the gray rules are hit, this version will be used. If it is a non-CDN deployment, add `x-higress-tag` to the header. |
| `backendVersion` | string | Required | - | Gray version for the backend, which will add `x-mse-tag` to the header of `XHR/Fetch` requests. |
| `name` | string | Required | - | Rule name associated with `rules[].name`. |
| `enabled` | boolean | Required | - | Whether to activate the current gray release rule. |
### `grayDeployments` Field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `version` | string | Required | - | Gray version used when rules match. Adds `x-higress-tag` header for non-CDN deployments. |
| `versionPredicates` | string | Required | - | Multi-version support for micro-frontends. |
| `backendVersion` | string | Required | - | Backend grayscale version for cookies. |
| `name` | string | Required | - | Linked to `rules[].name`. |
| `enabled` | boolean | Required | - | Enable/disable rule. |
| `weight` | int | Optional | - | Traffic percentage (e.g., 50). |
## Configuration Example
### Basic Configuration
> **Percentage-based Grayscale Notes**:
> 1. Percentage rules override user-based rules when both exist.
> 2. Uses UUID fingerprint hashed via SHA-256 for traffic distribution.
### `injection` Field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `globalConfig` | object | Optional | - | Global variables injected into HTML. |
| `head` | string[] | Optional | - | Inject elements into `<head>`. |
| `body` | object | Optional | - | Inject elements into `<body>`. |
#### `globalConfig` Sub-field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `key` | string | Optional | `HIGRESS_CONSOLE_CONFIG` | Window global variable key. |
| `featureKey` | string | Optional | `FEATURE_STATUS` | Rule hit status (e.g., `{"beta-user":true}`). |
| `value` | string | Optional | - | Custom global value. |
| `enabled` | boolean | Optional | `false` | Enable global injection. |
#### `body` Sub-field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `first` | string[] | Optional | - | Inject at body start. |
| `after` | string[] | Optional | - | Inject at body end. |
## Configuration Examples
### Basic Configuration (User-based)
```yml
grayKey: userid
rules:
@@ -75,88 +111,3 @@ grayDeployments:
- name: beta-user
version: gray
enabled: true
```
The unique identifier of the user in the cookie is `userid`, and the current gray release rule has configured the `beta-user` rule.
When the following conditions are met, the version `version: gray` will be used:
- `userid` in the cookie equals `00000002` or `00000003`
- Users whose `level` in the cookie equals `level3` or `level5`
Otherwise, use version `version: base`.
### User Information Exists in JSON
```yml
grayKey: appInfo
graySubKey: userId
rules:
- name: inner-user
grayKeyValue:
- '00000001'
- '00000005'
- name: beta-user
grayKeyValue:
- '00000002'
- '00000003'
grayTagKey: level
grayTagValue:
- level3
- level5
baseDeployment:
version: base
grayDeployments:
- name: beta-user
version: gray
enabled: true
```
The cookie contains JSON data for `appInfo`, which includes the field `userId` as the current unique identifier.
The current gray release rule has configured the `beta-user` rule.
When the following conditions are met, the version `version: gray` will be used:
- `userid` in the cookie equals `00000002` or `00000003`
- Users whose `level` in the cookie equals `level3` or `level5`
Otherwise, use version `version: base`.
### Rewrite Configuration
> Generally used in CDN deployment scenarios.
```yml
grayKey: userid
rules:
- name: inner-user
grayKeyValue:
- '00000001'
- '00000005'
- name: beta-user
grayKeyValue:
- '00000002'
- '00000003'
grayTagKey: level
grayTagValue:
- level3
- level5
rewrite:
host: frontend-gray.oss-cn-shanghai-internal.aliyuncs.com
notFoundUri: /mfe/app1/dev/404.html
indexRouting:
/app1: '/mfe/app1/{version}/index.html'
/: '/mfe/app1/{version}/index.html',
fileRouting:
/: '/mfe/app1/{version}'
/app1/: '/mfe/app1/{version}'
baseDeployment:
version: base
grayDeployments:
- name: beta-user
version: gray
enabled: true
```
`{version}` will be dynamically replaced with the actual version during execution.
#### indexRouting: Homepage Route Configuration
Accessing `/app1`, `/app123`, `/app1/index.html`, `/app1/xxx`, `/xxxx` will route to '/mfe/app1/{version}/index.html'.
#### fileRouting: File Route Configuration
The following file mappings are effective:
- `/js/a.js` => `/mfe/app1/v1.0.0/js/a.js`
- `/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js`
- `/app1/js/a.js` => `/mfe/app1/v1.0.0/js/a.js`
- `/app1/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js`

View File

@@ -1,23 +1,23 @@
package config
import (
"errors"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/tidwall/gjson"
)
const (
XHigressTag = "x-higress-tag"
XUniqueClientId = "x-unique-client"
XPreHigressTag = "x-pre-higress-tag"
IsPageRequest = "is-page-request"
IsNotFound = "is-not-found"
EnabledGray = "enabled-gray"
SecFetchMode = "sec-fetch-mode"
XHigressTag = "x-higress-tag"
PreHigressVersion = "pre-higress-version"
IsHtmlRequest = "is-html-request"
IsIndexRequest = "is-index-request"
EnabledGray = "enabled-gray"
)
type LogInfo func(format string, args ...interface{})
type GrayRule struct {
Name string
GrayKeyValue []string
@@ -35,15 +35,22 @@ type Deployment struct {
}
type Rewrite struct {
Host string
NotFound string
Index map[string]string
File map[string]string
Host string
Index map[string]string
File map[string]string
}
type Injection struct {
Head []string
Body *BodyInjection
GlobalConfig *GlobalConfig
Head []string
Body *BodyInjection
}
type GlobalConfig struct {
Key string
FeatureKey string
Value string
Enabled bool
}
type BodyInjection struct {
@@ -52,8 +59,7 @@ type BodyInjection struct {
}
type GrayConfig struct {
UserStickyMaxAge string
TotalGrayWeight int
StoreMaxAge int
GrayKey string
LocalStorageGrayKey string
GraySubKey string
@@ -63,10 +69,26 @@ type GrayConfig struct {
BaseDeployment *Deployment
GrayDeployments []*Deployment
BackendGrayTag string
UniqueGrayTag string
Injection *Injection
SkippedPathPrefixes []string
IncludePathPrefixes []string
SkippedPaths []string
SkippedByHeaders map[string]string
IndexPaths []string
GrayWeight int
}
func isValidName(s string) bool {
// 定义一个正则表达式,匹配字母、数字和下划线
re := regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
return re.MatchString(s)
}
func GetWithDefault(json gjson.Result, path, defaultValue string) string {
res := json.Get(path)
if res.Exists() {
return res.String()
}
return defaultValue
}
func convertToStringList(results []gjson.Result) []string {
@@ -77,6 +99,22 @@ func convertToStringList(results []gjson.Result) []string {
return interfaces
}
func compatibleConvertToStringList(results []gjson.Result, compatibleResults []gjson.Result) []string {
// 优先使用兼容模式的数据
if len(compatibleResults) == 0 {
interfaces := make([]string, len(results)) // 预分配切片容量
for i, result := range results {
interfaces[i] = result.String() // 使用 String() 方法直接获取字符串
}
return interfaces
}
compatibleInterfaces := make([]string, len(compatibleResults)) // 预分配切片容量
for i, result := range compatibleResults {
compatibleInterfaces[i] = filepath.Join(result.String(), "**")
}
return compatibleInterfaces
}
func convertToStringMap(result gjson.Result) map[string]string {
m := make(map[string]string)
result.ForEach(func(key, value gjson.Result) bool {
@@ -86,7 +124,7 @@ func convertToStringMap(result gjson.Result) map[string]string {
return m
}
func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) error {
// 解析 GrayKey
grayConfig.LocalStorageGrayKey = json.Get("localStorageGrayKey").String()
grayConfig.GrayKey = json.Get("grayKey").String()
@@ -94,22 +132,18 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
grayConfig.GrayKey = grayConfig.LocalStorageGrayKey
}
grayConfig.GraySubKey = json.Get("graySubKey").String()
grayConfig.BackendGrayTag = json.Get("backendGrayTag").String()
grayConfig.UserStickyMaxAge = json.Get("userStickyMaxAge").String()
grayConfig.BackendGrayTag = GetWithDefault(json, "backendGrayTag", "x-mse-tag")
grayConfig.UniqueGrayTag = GetWithDefault(json, "uniqueGrayTag", "x-higress-uid")
grayConfig.StoreMaxAge = 60 * 60 * 24 * 365 // 默认一年
storeMaxAge, err := strconv.Atoi(GetWithDefault(json, "StoreMaxAge", strconv.Itoa(grayConfig.StoreMaxAge)))
if err != nil {
grayConfig.StoreMaxAge = storeMaxAge
}
grayConfig.Html = json.Get("html").String()
grayConfig.SkippedPathPrefixes = convertToStringList(json.Get("skippedPathPrefixes").Array())
grayConfig.SkippedPaths = compatibleConvertToStringList(json.Get("skippedPaths").Array(), json.Get("skippedPathPrefixes").Array())
grayConfig.IndexPaths = compatibleConvertToStringList(json.Get("indexPaths").Array(), json.Get("includePathPrefixes").Array())
grayConfig.SkippedByHeaders = convertToStringMap(json.Get("skippedByHeaders"))
grayConfig.IncludePathPrefixes = convertToStringList(json.Get("includePathPrefixes").Array())
if grayConfig.UserStickyMaxAge == "" {
// 默认值2天
grayConfig.UserStickyMaxAge = "172800"
}
if grayConfig.BackendGrayTag == "" {
grayConfig.BackendGrayTag = "x-mse-tag"
}
// 解析 Rules
rules := json.Get("rules").Array()
for _, rule := range rules {
@@ -122,10 +156,9 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
grayConfig.Rules = append(grayConfig.Rules, &grayRule)
}
grayConfig.Rewrite = &Rewrite{
Host: json.Get("rewrite.host").String(),
NotFound: json.Get("rewrite.notFoundUri").String(),
Index: convertToStringMap(json.Get("rewrite.indexRouting")),
File: convertToStringMap(json.Get("rewrite.fileRouting")),
Host: json.Get("rewrite.host").String(),
Index: convertToStringMap(json.Get("rewrite.indexRouting")),
File: convertToStringMap(json.Get("rewrite.fileRouting")),
}
// 解析 deployment
@@ -134,6 +167,7 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
grayConfig.BaseDeployment = &Deployment{
Name: baseDeployment.Get("name").String(),
BackendVersion: baseDeployment.Get("backendVersion").String(),
Version: strings.Trim(baseDeployment.Get("version").String(), " "),
VersionPredicates: convertToStringMap(baseDeployment.Get("versionPredicates")),
}
@@ -141,16 +175,28 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
if !item.Get("enabled").Bool() {
continue
}
grayWeight := int(item.Get("weight").Int())
weight := int(item.Get("weight").Int())
grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &Deployment{
Name: item.Get("name").String(),
Enabled: item.Get("enabled").Bool(),
Version: strings.Trim(item.Get("version").String(), " "),
BackendVersion: item.Get("backendVersion").String(),
Weight: grayWeight,
Weight: weight,
VersionPredicates: convertToStringMap(item.Get("versionPredicates")),
})
grayConfig.TotalGrayWeight += grayWeight
if weight > 0 {
grayConfig.GrayWeight = weight
break
}
}
injectGlobalFeatureKey := GetWithDefault(json, "injection.globalConfig.featureKey", "FEATURE_STATUS")
injectGlobalKey := GetWithDefault(json, "injection.globalConfig.key", "HIGRESS_CONSOLE_CONFIG")
if !isValidName(injectGlobalFeatureKey) {
return errors.New("injection.globalConfig.featureKey is invalid")
}
if !isValidName(injectGlobalKey) {
return errors.New("injection.globalConfig.featureKey is invalid")
}
grayConfig.Injection = &Injection{
@@ -159,5 +205,12 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
First: convertToStringList(json.Get("injection.body.first").Array()),
Last: convertToStringList(json.Get("injection.body.last").Array()),
},
GlobalConfig: &GlobalConfig{
FeatureKey: injectGlobalFeatureKey,
Key: injectGlobalKey,
Value: json.Get("injection.globalConfig.value").String(),
Enabled: json.Get("injection.globalConfig.enabled").Bool(),
},
}
return nil
}

View File

@@ -48,8 +48,8 @@ static_resources:
value: |
{
"grayKey": "userId",
"backendGrayTag": "x-mse-tag",
"userStickyMaxAge": 172800,
"backendGrayTag": "env",
"uniqueGrayTag": "uuid",
"rules": [
{
"name": "inner-user",
@@ -72,30 +72,44 @@ static_resources:
}
],
"rewrite": {
"host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com",
"host": "apig-oss-integration.oss-cn-hangzhou.aliyuncs.com",
"indexRouting": {
"/app1": "/mfe/app1/{version}/index.html",
"/": "/mfe/app1/{version}/index.html"
"/": "/mfe/{version}/index.html"
},
"fileRouting": {
"/": "/mfe/app1/{version}",
"/app1": "/mfe/app1/{version}"
"/": "/mfe/{version}",
"/mfe": "/mfe/{version}"
}
},
"skippedPathPrefixes": [
"/api/"
"skippedPaths": [
"/api/**",
"/v2/**"
],
"indexPaths": [
"/mfe/**/mf-manifest-main.json"
],
"baseDeployment": {
"version": "dev"
"version": "v1"
},
"grayDeployments": [
{
"weight": 90,
"name": "beta-user",
"version": "0.0.1",
"enabled": true
"version": "v2",
"enabled": true,
"backendVersion":"gray",
"versionPredicates": {
"/mfe": "v1"
}
}
],
"injection": {
"globalConfig": {
"key": "HIGRESS_CONSOLE_CONFIG",
"featureKey": "FEATURE_STATUS",
"value": "{CONSOLE_GLOBAL: {'gray':'2.0.15','main':'2.0.15'}}",
"enabled": true
},
"head": [
"<script>console.log('Header')</script>"
],
@@ -127,5 +141,5 @@ static_resources:
- endpoint:
address:
socket_address:
address: frontend-gray-cn-shanghai.oss-cn-shanghai.aliyuncs.com
address: apig-oss-integration.oss-cn-hangzhou.aliyuncs.com
port_value: 80

View File

@@ -6,6 +6,7 @@ replace github.com/alibaba/higress/plugins/wasm-go => ../..
require (
github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240727022514-bccfbde62188
github.com/bmatcuk/doublestar/v4 v4.6.1
github.com/higress-group/proxy-wasm-go-sdk v1.0.0
github.com/stretchr/testify v1.9.0
github.com/tidwall/gjson v1.17.3

View File

@@ -1,6 +1,8 @@
github.com/davecgh/go-spew v1.1.1+incompatible/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1+incompatible/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
@@ -11,9 +13,9 @@ github.com/higress-group/proxy-wasm-go-sdk v1.0.0 h1:BZRNf4R7jr9hwRivg/E29nkVaKE
github.com/higress-group/proxy-wasm-go-sdk v1.0.0/go.mod h1:iiSyFbo+rAtbtGt/bsefv8GU57h9CCLYGJA74/tF5/0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pmezard/go-difflib v1.0.0+incompatible/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0+incompatible/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=

View File

@@ -2,13 +2,11 @@ package main
import (
"fmt"
"net/http"
"net/url"
"path"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/util"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
@@ -19,44 +17,42 @@ import (
func main() {
wrapper.SetCtx(
"frontend-gray",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeader),
wrapper.ProcessResponseBodyBy(onHttpResponseBody),
wrapper.ParseConfig(parseConfig),
wrapper.ProcessRequestHeaders(onHttpRequestHeaders),
wrapper.ProcessResponseHeaders(onHttpResponseHeader),
wrapper.ProcessResponseBody(onHttpResponseBody),
)
}
func parseConfig(json gjson.Result, grayConfig *config.GrayConfig, log wrapper.Log) error {
func parseConfig(json gjson.Result, grayConfig *config.GrayConfig) error {
// 解析json 为GrayConfig
config.JsonToGrayConfig(json, grayConfig)
log.Infof("Rewrite: %v, GrayDeployments: %v", json.Get("rewrite"), json.Get("grayDeployments"))
if err := config.JsonToGrayConfig(json, grayConfig); err != nil {
log.Errorf("failed to parse config: %v", err)
return err
}
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
requestPath, _ := proxywasm.GetHttpRequestHeader(":path")
requestPath = path.Clean(requestPath)
parsedURL, err := url.Parse(requestPath)
if err == nil {
requestPath = parsedURL.Path
} else {
log.Errorf("parse request path %s failed: %v", requestPath, err)
}
enabledGray := util.IsGrayEnabled(grayConfig, requestPath)
func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig) types.Action {
requestPath := util.GetRequestPath()
enabledGray := util.IsGrayEnabled(requestPath, &grayConfig)
ctx.SetContext(config.EnabledGray, enabledGray)
secFetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode")
ctx.SetContext(config.SecFetchMode, secFetchMode)
route, _ := util.GetRouteName()
if !enabledGray {
log.Infof("gray not enabled")
log.Infof("route: %s, gray not enabled, requestPath: %v", route, requestPath)
ctx.DontReadRequestBody()
return types.ActionContinue
}
cookies, _ := proxywasm.GetHttpRequestHeader("cookie")
isPageRequest := util.IsPageRequest(requestPath)
cookie, _ := proxywasm.GetHttpRequestHeader("cookie")
isHtmlRequest := util.CheckIsHtmlRequest(requestPath)
ctx.SetContext(config.IsHtmlRequest, isHtmlRequest)
isIndexRequest := util.IsIndexRequest(requestPath, grayConfig.IndexPaths)
ctx.SetContext(config.IsIndexRequest, isIndexRequest)
hasRewrite := len(grayConfig.Rewrite.File) > 0 || len(grayConfig.Rewrite.Index) > 0
grayKeyValueByCookie := util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey)
grayKeyValueByCookie := util.GetCookieValue(cookie, grayConfig.GrayKey)
grayKeyValueByHeader, _ := proxywasm.GetHttpRequestHeader(grayConfig.GrayKey)
// 优先从cookie中获取否则从header中获取
grayKeyValue := util.GetGrayKey(grayKeyValueByCookie, grayKeyValueByHeader, grayConfig.GraySubKey)
@@ -65,93 +61,92 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
// 禁止重新路由要在更改Header之前操作否则会失效
ctx.DisableReroute()
}
frontendVersion := util.GetCookieValue(cookie, config.XHigressTag)
if grayConfig.GrayWeight > 0 {
ctx.SetContext(grayConfig.UniqueGrayTag, util.GetGrayWeightUniqueId(cookie, grayConfig.UniqueGrayTag))
}
// 删除Accept-Encoding避免压缩 如果是压缩的内容,后续插件就没法处理了
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
_ = proxywasm.RemoveHttpRequestHeader("Content-Length")
deployment := &config.Deployment{}
preVersion, preUniqueClientId := util.GetXPreHigressVersion(cookies)
// 客户端唯一ID用于在按照比率灰度时候 客户访问黏贴
uniqueClientId := grayKeyValue
if uniqueClientId == "" {
xForwardedFor, _ := proxywasm.GetHttpRequestHeader("X-Forwarded-For")
uniqueClientId = util.GetRealIpFromXff(xForwardedFor)
globalConfig := grayConfig.Injection.GlobalConfig
if globalConfig.Enabled {
conditionRule := util.GetConditionRules(grayConfig.Rules, grayKeyValue, cookie)
trimmedValue := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(globalConfig.Value), "{"), "}")
ctx.SetContext(globalConfig.Key, fmt.Sprintf("<script>var %s = {\n%s:%s,\n %s \n}\n</script>", globalConfig.Key, globalConfig.FeatureKey, conditionRule, trimmedValue))
}
// 如果没有配置比例,则进行灰度规则匹配
if util.IsSupportMultiVersion(grayConfig) {
deployment = util.FilterMultiVersionGrayRule(&grayConfig, grayKeyValue, requestPath)
log.Infof("multi version %v", deployment)
} else {
if isPageRequest {
if grayConfig.TotalGrayWeight > 0 {
log.Infof("grayConfig.TotalGrayWeight: %v", grayConfig.TotalGrayWeight)
deployment = util.FilterGrayWeight(&grayConfig, preVersion, preUniqueClientId, uniqueClientId)
} else {
deployment = util.FilterGrayRule(&grayConfig, grayKeyValue)
}
log.Infof("index deployment: %v, path: %v, backend: %v, xPreHigressVersion: %s,%s", deployment, requestPath, deployment.BackendVersion, preVersion, preUniqueClientId)
} else {
grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue)
deployment = util.GetVersion(grayConfig, grayDeployment, preVersion, isPageRequest)
}
ctx.SetContext(config.XPreHigressTag, deployment.Version)
if isHtmlRequest {
// index首页请求每次都会进度灰度规则判断
deployment = util.FilterGrayRule(&grayConfig, grayKeyValue, cookie)
log.Infof("route: %s, index html request: %v, backend: %v, xPreHigressVersion: %s", route, requestPath, deployment.BackendVersion, frontendVersion)
ctx.SetContext(config.PreHigressVersion, deployment.Version)
ctx.SetContext(grayConfig.BackendGrayTag, deployment.BackendVersion)
} else {
if util.IsSupportMultiVersion(grayConfig) {
deployment = util.FilterMultiVersionGrayRule(&grayConfig, grayKeyValue, cookie, requestPath)
log.Infof("route: %s, multi version %v", route, deployment)
} else {
grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue, cookie)
if isIndexRequest {
deployment = grayDeployment
} else {
deployment = util.GetVersion(grayConfig, grayDeployment, frontendVersion)
}
}
}
proxywasm.AddHttpRequestHeader(config.XHigressTag, deployment.Version)
ctx.SetContext(config.IsPageRequest, isPageRequest)
ctx.SetContext(config.XUniqueClientId, uniqueClientId)
rewrite := grayConfig.Rewrite
if rewrite.Host != "" {
err := proxywasm.ReplaceHttpRequestHeader(":authority", rewrite.Host)
if err != nil {
log.Errorf("host rewrite failed: %v", err)
log.Errorf("route: %s, host rewrite failed: %v", route, err)
}
}
if hasRewrite {
rewritePath := requestPath
if isPageRequest {
if isHtmlRequest {
rewritePath = util.IndexRewrite(requestPath, deployment.Version, grayConfig.Rewrite.Index)
} else {
rewritePath = util.PrefixFileRewrite(requestPath, deployment.Version, grayConfig.Rewrite.File)
}
if requestPath != rewritePath {
log.Infof("rewrite path:%s, rewritePath:%s, Version:%v", requestPath, rewritePath, deployment.Version)
log.Infof("route: %s, rewrite path:%s, rewritePath:%s, Version:%v", route, requestPath, rewritePath, deployment.Version)
proxywasm.ReplaceHttpRequestHeader(":path", rewritePath)
}
}
log.Infof("request path:%s, has rewrited:%v, rewrite config:%+v", requestPath, hasRewrite, rewrite)
return types.ActionContinue
}
func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig) types.Action {
enabledGray, _ := ctx.GetContext(config.EnabledGray).(bool)
if !enabledGray {
ctx.DontReadResponseBody()
return types.ActionContinue
}
secFetchMode, isSecFetchModeOk := ctx.GetContext(config.SecFetchMode).(string)
if isSecFetchModeOk && secFetchMode == "cors" {
isIndexRequest, indexOk := ctx.GetContext(config.IsIndexRequest).(bool)
if indexOk && isIndexRequest {
// 首页请求强制不缓存
proxywasm.ReplaceHttpResponseHeader("cache-control", "no-cache, no-store, max-age=0, must-revalidate")
ctx.DontReadResponseBody()
return types.ActionContinue
}
isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool)
if !ok {
isPageRequest = false // 默认值
}
isHtmlRequest, htmlOk := ctx.GetContext(config.IsHtmlRequest).(bool)
// response 不处理非首页的请求
if !isPageRequest {
if !htmlOk || !isHtmlRequest {
ctx.DontReadResponseBody()
return types.ActionContinue
} else {
// 不会进去Streaming 的Body处理
ctx.BufferResponseBody()
}
// 处理HTML的首页
status, err := proxywasm.GetHttpResponseHeader(":status")
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
// 删除Content-Disposition避免自动下载文件
@@ -163,117 +158,81 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
// 处理code为 200的情况
if err != nil || status != "200" {
if status == "404" {
if grayConfig.Rewrite.NotFound != "" && isPageRequest {
ctx.SetContext(config.IsNotFound, true)
responseHeaders, _ := proxywasm.GetHttpResponseHeaders()
headersMap := util.ConvertHeaders(responseHeaders)
if _, ok := headersMap[":status"]; !ok {
headersMap[":status"] = []string{"200"} // 如果没有初始化,设定默认值
} else {
headersMap[":status"][0] = "200" // 修改现有值
}
if _, ok := headersMap["content-type"]; !ok {
headersMap["content-type"] = []string{"text/html"} // 如果没有初始化,设定默认值
} else {
headersMap["content-type"][0] = "text/html" // 修改现有值
}
// 删除 content-length 键
delete(headersMap, "content-length")
proxywasm.ReplaceHttpResponseHeaders(util.ReconvertHeaders(headersMap))
ctx.BufferResponseBody()
return types.ActionContinue
} else {
// 直接返回400
ctx.DontReadResponseBody()
}
// 如果找不到HTML但配置了HTML页面
if status == "404" && grayConfig.Html != "" {
responseHeaders, _ := proxywasm.GetHttpResponseHeaders()
headersMap := util.ConvertHeaders(responseHeaders)
delete(headersMap, "content-length")
headersMap[":status"][0] = "200"
headersMap["content-type"][0] = "text/html"
ctx.BufferResponseBody()
proxywasm.ReplaceHttpResponseHeaders(util.ReconvertHeaders(headersMap))
} else {
route, _ := util.GetRouteName()
log.Errorf("route: %s, request error code: %s, message: %v", route, status, err)
ctx.DontReadResponseBody()
return types.ActionContinue
}
log.Errorf("error status: %s, error message: %v", status, err)
return types.ActionContinue
}
cacheControl, _ := proxywasm.GetHttpResponseHeader("cache-control")
if !strings.Contains(cacheControl, "no-cache") {
proxywasm.ReplaceHttpResponseHeader("cache-control", "no-cache, no-store, max-age=0, must-revalidate")
proxywasm.ReplaceHttpResponseHeader("cache-control", "no-cache, no-store, max-age=0, must-revalidate")
// 前端版本
frontendVersion, isFrontendVersionOk := ctx.GetContext(config.PreHigressVersion).(string)
if isFrontendVersionOk {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Path=/;", config.XHigressTag, frontendVersion, grayConfig.StoreMaxAge))
}
frontendVersion, isFeVersionOk := ctx.GetContext(config.XPreHigressTag).(string)
xUniqueClient, isUniqClientOk := ctx.GetContext(config.XUniqueClientId).(string)
// 设置前端的版本
if isFeVersionOk && isUniqClientOk && frontendVersion != "" {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s,%s; Max-Age=%s; Path=/;", config.XPreHigressTag, frontendVersion, xUniqueClient, grayConfig.UserStickyMaxAge))
// 设置GrayWeight 唯一值
if grayConfig.GrayWeight > 0 {
uniqueId, isUniqueIdOk := ctx.GetContext(grayConfig.UniqueGrayTag).(string)
if isUniqueIdOk {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Path=/;", grayConfig.UniqueGrayTag, uniqueId, grayConfig.StoreMaxAge))
}
}
// 设置后端的版本
if util.IsBackendGrayEnabled(grayConfig) {
backendVersion, isBackVersionOk := ctx.GetContext(grayConfig.BackendGrayTag).(string)
if isBackVersionOk && backendVersion != "" {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%s; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.UserStickyMaxAge))
if isBackVersionOk {
if backendVersion == "" {
// 删除后端灰度版本
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/;", grayConfig.BackendGrayTag, backendVersion))
} else {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.StoreMaxAge))
}
}
}
return types.ActionContinue
}
func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, body []byte, log wrapper.Log) types.Action {
func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, body []byte) types.Action {
enabledGray, _ := ctx.GetContext(config.EnabledGray).(bool)
if !enabledGray {
return types.ActionContinue
}
isPageRequest, isPageRequestOk := ctx.GetContext(config.IsPageRequest).(bool)
frontendVersion, isFeVersionOk := ctx.GetContext(config.XPreHigressTag).(string)
isHtmlRequest, isHtmlRequestOk := ctx.GetContext(config.IsHtmlRequest).(bool)
frontendVersion, isFeVersionOk := ctx.GetContext(config.PreHigressVersion).(string)
// 只处理首页相关请求
if !isFeVersionOk || !isPageRequestOk || !isPageRequest {
if !isFeVersionOk || !isHtmlRequestOk || !isHtmlRequest {
return types.ActionContinue
}
isNotFound, ok := ctx.GetContext(config.IsNotFound).(bool)
if !ok {
isNotFound = false // 默认值
globalConfig := grayConfig.Injection.GlobalConfig
globalConfigValue, isGobalConfigOk := ctx.GetContext(globalConfig.Key).(string)
if !isGobalConfigOk {
globalConfigValue = ""
}
// 检查是否存在自定义 HTML 如有则省略 rewrite.indexRouting 的内容
newHtml := string(body)
if grayConfig.Html != "" {
log.Debugf("Returning custom HTML from config.")
// 替换响应体为 config.Html 内容
if err := proxywasm.ReplaceHttpResponseBody([]byte(grayConfig.Html)); err != nil {
log.Errorf("Error replacing response body: %v", err)
return types.ActionContinue
}
newHtml := util.InjectContent(grayConfig.Html, grayConfig.Injection)
// 替换当前html加载的动态文件版本
newHtml = strings.ReplaceAll(newHtml, "{version}", frontendVersion)
// 最终替换响应体
if err := proxywasm.ReplaceHttpResponseBody([]byte(newHtml)); err != nil {
log.Errorf("Error replacing injected response body: %v", err)
return types.ActionContinue
}
return types.ActionContinue
newHtml = grayConfig.Html
}
newHtml = util.InjectContent(newHtml, grayConfig.Injection, globalConfigValue)
// 替换当前html加载的动态文件版本
newHtml = strings.ReplaceAll(newHtml, "{version}", frontendVersion)
newHtml = util.FixLocalStorageKey(newHtml, grayConfig.LocalStorageGrayKey)
// 针对404页面处理
if isNotFound && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" {
client := wrapper.NewClusterClient(wrapper.RouteCluster{Host: grayConfig.Rewrite.Host})
client.Get(strings.Replace(grayConfig.Rewrite.NotFound, "{version}", frontendVersion, -1), nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
proxywasm.ReplaceHttpResponseBody(responseBody)
proxywasm.ResumeHttpResponse()
}, 1500)
return types.ActionPause
}
// 处理响应体HTML
newBody := string(body)
newBody = util.InjectContent(newBody, grayConfig.Injection)
if grayConfig.LocalStorageGrayKey != "" {
localStr := strings.ReplaceAll(`<script>
!function(){var o,e,n="@@X_GRAY_KEY",t=document.cookie.split("; ").filter(function(o){return 0===o.indexOf(n+"=")});try{"undefined"!=typeof localStorage&&null!==localStorage&&(o=localStorage.getItem(n),e=0<t.length?decodeURIComponent(t[0].split("=")[1]):null,o)&&o.indexOf("=")<0&&e&&e!==o&&(document.cookie=n+"="+encodeURIComponent(o)+"; path=/;",window.location.reload())}catch(o){}}();
</script>
`, "@@X_GRAY_KEY", grayConfig.LocalStorageGrayKey)
newBody = strings.ReplaceAll(newBody, "<body>", "<body>\n"+localStr)
}
if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); err != nil {
// 最终替换响应体
if err := proxywasm.ReplaceHttpResponseBody([]byte(newHtml)); err != nil {
route, _ := util.GetRouteName()
log.Errorf("route: %s, Failed to replace response body: %v", route, err)
return types.ActionContinue
}
return types.ActionContinue

View File

@@ -1,15 +1,16 @@
package util
import (
"fmt"
"math/rand"
"encoding/json"
"hash/crc32"
"net/url"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/google/uuid"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
@@ -17,43 +18,26 @@ import (
"github.com/tidwall/gjson"
)
func LogInfof(format string, args ...interface{}) {
format = fmt.Sprintf("[%s] %s", "frontend-gray", format)
proxywasm.LogInfof(format, args...)
func GetRequestPath() string {
requestPath, _ := proxywasm.GetHttpRequestHeader(":path")
requestPath = path.Clean(requestPath)
parsedURL, err := url.Parse(requestPath)
if err == nil {
requestPath = parsedURL.Path
} else {
return ""
}
return requestPath
}
func GetXPreHigressVersion(cookies string) (string, string) {
xPreHigressVersion := ExtractCookieValueByKey(cookies, config.XPreHigressTag)
preVersions := strings.Split(xPreHigressVersion, ",")
if len(preVersions) == 0 {
return "", ""
func GetRouteName() (string, error) {
if raw, err := proxywasm.GetProperty([]string{"route_name"}); err != nil {
return "-", err
} else {
return string(raw), nil
}
if len(preVersions) == 1 {
return preVersions[0], ""
}
return strings.TrimSpace(preVersions[0]), strings.TrimSpace(preVersions[1])
}
// 从xff中获取真实的IP
func GetRealIpFromXff(xff string) string {
if xff != "" {
// 通常客户端的真实 IP 是 XFF 头中的第一个 IP
ips := strings.Split(xff, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
return ""
}
func IsRequestSkippedByHeaders(grayConfig config.GrayConfig) bool {
secFetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode")
upgrade, _ := proxywasm.GetHttpRequestHeader("upgrade")
if len(grayConfig.SkippedByHeaders) == 0 {
// 默认不走插件逻辑的header
return secFetchMode == "cors" || upgrade == "websocket"
}
func IsRequestSkippedByHeaders(grayConfig *config.GrayConfig) bool {
for headerKey, headerValue := range grayConfig.SkippedByHeaders {
requestHeader, _ := proxywasm.GetHttpRequestHeader(headerKey)
if requestHeader == headerValue {
@@ -63,34 +47,40 @@ func IsRequestSkippedByHeaders(grayConfig config.GrayConfig) bool {
return false
}
func IsGrayEnabled(grayConfig config.GrayConfig, requestPath string) bool {
for _, prefix := range grayConfig.IncludePathPrefixes {
if strings.HasPrefix(requestPath, prefix) {
func IsIndexRequest(requestPath string, indexPaths []string) bool {
for _, prefix := range indexPaths {
matchResult, err := doublestar.Match(prefix, requestPath)
if err == nil && matchResult {
return true
}
}
return false
}
// 当前路径中前缀为 SkippedPathPrefixes则不走插件逻辑
for _, prefix := range grayConfig.SkippedPathPrefixes {
if strings.HasPrefix(requestPath, prefix) {
func IsGrayEnabled(requestPath string, grayConfig *config.GrayConfig) bool {
if IsIndexRequest(requestPath, grayConfig.IndexPaths) {
return true
}
// 当前路径中前缀为 SkippedPaths则不走插件逻辑
for _, prefix := range grayConfig.SkippedPaths {
matchResult, err := doublestar.Match(prefix, requestPath)
if err == nil && matchResult {
return false
}
}
// 如果是首页,进入插件逻辑
if IsPageRequest(requestPath) {
if CheckIsHtmlRequest(requestPath) {
return true
}
// 检查是否存在重写主机
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
return true
}
// 检查header标识判断是否需要跳过
if IsRequestSkippedByHeaders(grayConfig) {
return false
}
// 检查是否存在重写主机
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
return true
}
// 检查是否存在灰度版本配置
return len(grayConfig.GrayDeployments) > 0
}
@@ -105,8 +95,8 @@ func IsBackendGrayEnabled(grayConfig config.GrayConfig) bool {
return false
}
// ExtractCookieValueByKey 根据 cookie 和 key 获取 cookie 值
func ExtractCookieValueByKey(cookie string, key string) string {
// GetCookieValue 根据 cookie 和 key 获取 cookie 值
func GetCookieValue(cookie string, key string) string {
if cookie == "" {
return ""
}
@@ -170,7 +160,7 @@ var indexSuffixes = []string{
".html", ".htm", ".jsp", ".php", ".asp", ".aspx", ".erb", ".ejs", ".twig",
}
func IsPageRequest(requestPath string) bool {
func CheckIsHtmlRequest(requestPath string) bool {
if requestPath == "/" || requestPath == "" {
return true
}
@@ -227,10 +217,7 @@ func PrefixFileRewrite(path, version string, matchRules map[string]string) strin
return path
}
func GetVersion(grayConfig config.GrayConfig, deployment *config.Deployment, xPreHigressVersion string, isPageRequest bool) *config.Deployment {
if isPageRequest {
return deployment
}
func GetVersion(grayConfig config.GrayConfig, deployment *config.Deployment, xPreHigressVersion string) *config.Deployment {
// cookie 中为空,返回当前版本
if xPreHigressVersion == "" {
return deployment
@@ -295,10 +282,75 @@ func IsSupportMultiVersion(grayConfig config.GrayConfig) bool {
return false
}
func GetConditionRules(rules []*config.GrayRule, grayKeyValue string, cookie string) string {
ruleMaps := map[string]bool{}
for _, grayRule := range rules {
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
ruleMaps[grayRule.Name] = ContainsValue(grayRule.GrayKeyValue, grayKeyValue)
continue
} else if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 {
grayTagValue := GetCookieValue(cookie, grayRule.GrayTagKey)
ruleMaps[grayRule.Name] = ContainsValue(grayRule.GrayTagValue, grayTagValue)
continue
} else {
ruleMaps[grayRule.Name] = false
}
}
jsonBytes, err := json.Marshal(ruleMaps)
if err != nil {
return ""
}
return string(jsonBytes)
}
func GetGrayWeightUniqueId(cookie string, uniqueGrayTag string) string {
uniqueId := GetCookieValue(cookie, uniqueGrayTag)
if uniqueId == "" {
uniqueId = strings.ReplaceAll(uuid.NewString(), "-", "")
}
return uniqueId
}
// FilterGrayRule 过滤灰度规则
func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, cookie string) *config.Deployment {
if grayConfig.GrayWeight > 0 {
uniqueId := GetGrayWeightUniqueId(cookie, grayConfig.UniqueGrayTag)
// 计算哈希后取模
mod := crc32.ChecksumIEEE([]byte(uniqueId)) % 100
isGray := mod < uint32(grayConfig.GrayWeight)
if isGray {
for _, deployment := range grayConfig.GrayDeployments {
if deployment.Enabled && deployment.Weight > 0 {
return deployment
}
}
}
return grayConfig.BaseDeployment
}
for _, deployment := range grayConfig.GrayDeployments {
grayRule := GetRule(grayConfig.Rules, deployment.Name)
// 首先先校验用户名单ID
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
if ContainsValue(grayRule.GrayKeyValue, grayKeyValue) {
return deployment
}
}
// 第二校验Cookie中的 GrayTagKey
if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 {
grayTagValue := GetCookieValue(cookie, grayRule.GrayTagKey)
if ContainsValue(grayRule.GrayTagValue, grayTagValue) {
return deployment
}
}
}
return grayConfig.BaseDeployment
}
// FilterMultiVersionGrayRule 过滤多版本灰度规则
func FilterMultiVersionGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, requestPath string) *config.Deployment {
func FilterMultiVersionGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, cookie string, requestPath string) *config.Deployment {
// 首先根据灰度键值获取当前部署
currentDeployment := FilterGrayRule(grayConfig, grayKeyValue)
currentDeployment := FilterGrayRule(grayConfig, grayKeyValue, cookie)
// 创建一个新的部署对象,初始化版本为当前部署的版本
deployment := &config.Deployment{
@@ -319,68 +371,13 @@ func FilterMultiVersionGrayRule(grayConfig *config.GrayConfig, grayKeyValue stri
return deployment
}
// FilterGrayRule 过滤灰度规则
func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string) *config.Deployment {
for _, deployment := range grayConfig.GrayDeployments {
grayRule := GetRule(grayConfig.Rules, deployment.Name)
// 首先先校验用户名单ID
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
if ContainsValue(grayRule.GrayKeyValue, grayKeyValue) {
return deployment
}
}
// 第二校验Cookie中的 GrayTagKey
if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 {
cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie")
grayTagValue := ExtractCookieValueByKey(cookieStr, grayRule.GrayTagKey)
if ContainsValue(grayRule.GrayTagValue, grayTagValue) {
return deployment
}
}
}
return grayConfig.BaseDeployment
}
func FilterGrayWeight(grayConfig *config.GrayConfig, preVersion string, preUniqueClientId string, uniqueClientId string) *config.Deployment {
// 如果没有灰度权重,直接返回基础版本
if grayConfig.TotalGrayWeight == 0 {
return grayConfig.BaseDeployment
}
deployments := append(grayConfig.GrayDeployments, grayConfig.BaseDeployment)
LogInfof("preVersion: %s, preUniqueClientId: %s, uniqueClientId: %s", preVersion, preUniqueClientId, uniqueClientId)
// 用户粘滞,确保每个用户每次访问的都是走同一版本
if preVersion != "" && uniqueClientId == preUniqueClientId {
for _, deployment := range deployments {
if deployment.Version == preVersion {
return deployment
}
}
}
totalWeight := 100
// 如果总权重小于100则将基础版本也加入到总版本列表中
if grayConfig.TotalGrayWeight <= totalWeight {
grayConfig.BaseDeployment.Weight = 100 - grayConfig.TotalGrayWeight
} else {
totalWeight = grayConfig.TotalGrayWeight
}
rand.Seed(time.Now().UnixNano())
randWeight := rand.Intn(totalWeight)
sumWeight := 0
for _, deployment := range deployments {
sumWeight += deployment.Weight
if randWeight < sumWeight {
return deployment
}
}
return nil
}
// InjectContent 用于将内容注入到 HTML 文档的指定位置
func InjectContent(originalHtml string, injectionConfig *config.Injection) string {
headInjection := strings.Join(injectionConfig.Head, "\n")
func InjectContent(originalHtml string, injectionConfig *config.Injection, globalConfigValue string) string {
heads := injectionConfig.Head
if globalConfigValue != "" {
heads = append([]string{globalConfigValue}, injectionConfig.Head...)
}
headInjection := strings.Join(heads, "\n")
bodyFirstInjection := strings.Join(injectionConfig.Body.First, "\n")
bodyLastInjection := strings.Join(injectionConfig.Body.Last, "\n")
@@ -401,3 +398,14 @@ func InjectContent(originalHtml string, injectionConfig *config.Injection) strin
return modifiedHtml
}
func FixLocalStorageKey(newHtml string, localStorageGrayKey string) string {
if localStorageGrayKey != "" {
localStr := strings.ReplaceAll(`<script>
!function(){var o,e,n="@@X_GRAY_KEY",t=document.cookie.split("; ").filter(function(o){return 0===o.indexOf(n+"=")});try{"undefined"!=typeof localStorage&&null!==localStorage&&(o=localStorage.getItem(n),e=0<t.length?decodeURIComponent(t[0].split("=")[1]):null,o)&&o.indexOf("=")<0&&e&&e!==o&&(document.cookie=n+"="+encodeURIComponent(o)+"; path=/;",window.location.reload())}catch(o){}}();
</script>
`, "@@X_GRAY_KEY", localStorageGrayKey)
newHtml = strings.ReplaceAll(newHtml, "<body>", "<body>\n"+localStr)
}
return newHtml
}

View File

@@ -3,12 +3,14 @@ package util
import (
"testing"
"github.com/bmatcuk/doublestar/v4"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
func TestExtractCookieValueByKey(t *testing.T) {
func TestGetCookieValue(t *testing.T) {
var tests = []struct {
cookie, cookieKey, output string
}{
@@ -21,7 +23,7 @@ func TestExtractCookieValueByKey(t *testing.T) {
for _, test := range tests {
testName := test.cookie
t.Run(testName, func(t *testing.T) {
output := ExtractCookieValueByKey(test.cookie, test.cookieKey)
output := GetCookieValue(test.cookie, test.cookieKey)
assert.Equal(t, test.output, output)
})
}
@@ -106,7 +108,7 @@ func TestPrefixFileRewrite(t *testing.T) {
}
}
func TestIsPageRequest(t *testing.T) {
func TestCheckIsHtmlRequest(t *testing.T) {
var tests = []struct {
p string
output bool
@@ -121,30 +123,11 @@ func TestIsPageRequest(t *testing.T) {
for _, test := range tests {
testPath := test.p
t.Run(testPath, func(t *testing.T) {
output := IsPageRequest(testPath)
output := CheckIsHtmlRequest(testPath)
assert.Equal(t, test.output, output)
})
}
}
func TestFilterGrayWeight(t *testing.T) {
var tests = []struct {
name string
input string
}{
{"demo", `{"grayKey":"userId","rules":[{"name":"inner-user","grayKeyValue":["00000001","00000005"]},{"name":"beta-user","grayKeyValue":["noah","00000003"],"grayTagKey":"level","grayTagValue":["level3","level5"]}],"rewrite":{"host":"frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com","notFoundUri":"/mfe/app1/dev/404.html","indexRouting":{"/app1":"/mfe/app1/{version}/index.html","/":"/mfe/app1/{version}/index.html"},"fileRouting":{"/":"/mfe/app1/{version}","/app1":"/mfe/app1/{version}"}},"baseDeployment":{"version":"dev"},"grayDeployments":[{"name":"beta-user","version":"0.0.1","backendVersion":"beta","enabled":true,"weight":50}]}`},
}
for _, test := range tests {
testName := test.name
t.Run(testName, func(t *testing.T) {
grayConfig := &config.GrayConfig{}
config.JsonToGrayConfig(gjson.Parse(test.input), grayConfig)
result := FilterGrayWeight(grayConfig, "base", "1.0.1", "192.168.1.1")
t.Logf("result-----: %v", result)
})
}
}
func TestReplaceHtml(t *testing.T) {
var tests = []struct {
name string
@@ -158,8 +141,26 @@ func TestReplaceHtml(t *testing.T) {
t.Run(testName, func(t *testing.T) {
grayConfig := &config.GrayConfig{}
config.JsonToGrayConfig(gjson.Parse(test.input), grayConfig)
result := InjectContent(grayConfig.Html, grayConfig.Injection)
result := InjectContent(grayConfig.Html, grayConfig.Injection, "")
t.Logf("result-----: %v", result)
})
}
}
func TestIsIndexRequest(t *testing.T) {
var tests = []struct {
name string
input string
output bool
}{
{"/api/user.json", "/api/**", true},
{"/api/blade-auth/oauth/captcha", "/api/**", true},
}
for _, test := range tests {
testName := test.name
t.Run(testName, func(t *testing.T) {
matchResult, _ := doublestar.Match(test.input, testName)
assert.Equal(t, test.output, matchResult)
})
}
}

View File

@@ -26,8 +26,9 @@ func claimsToHeader(claims map[string]any, cth []cfg.ClaimsToHeader) {
if v, ok := claims[cth[i].Claim]; ok {
if *cth[i].Override {
proxywasm.ReplaceHttpRequestHeader(cth[i].Header, fmt.Sprint(v))
} else {
proxywasm.AddHttpRequestHeader(cth[i].Header, fmt.Sprint(v))
}
proxywasm.AddHttpRequestHeader(cth[i].Header, fmt.Sprint(v))
}
}
}

View File

@@ -1,29 +1,12 @@
# Build stage
FROM golang:1.24 AS builder
ARG SERVER_NAME=quark-search
ARG GOPROXY
WORKDIR /app
# Copy the server code
COPY ${SERVER_NAME}/ .
# Set GOPROXY if provided
RUN if [ -n "$GOPROXY" ]; then go env -w GOPROXY=${GOPROXY}; fi
# Build the WASM binary
RUN GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm main.go
# Final stage
# Single stage build using pre-built WASM binary
FROM scratch
ARG SERVER_NAME=quark-search
WORKDIR /
# Copy the WASM binary from the builder stage
COPY --from=builder /app/main.wasm /main.wasm
# Copy the pre-built WASM binary from local build
COPY ${SERVER_NAME}/main.wasm /plugin.wasm
# Metadata
LABEL org.opencontainers.image.title="${SERVER_NAME}"

View File

@@ -2,7 +2,7 @@
# Variables
SERVER_NAME ?= quark-search
REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/
REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/mcp-server/
GO_VERSION ?= 1.24
BUILD_TIME := $(shell date "+%Y%m%d-%H%M%S")
COMMIT_ID := $(shell git rev-parse --short HEAD 2>/dev/null)
@@ -18,8 +18,8 @@ build:
@echo ""
@echo "Output WASM file: ${SERVER_NAME}/main.wasm"
# Build Docker image
build-image:
# Build Docker image (depends on build target to ensure WASM binary exists)
build-image: build
@echo "Building Docker image for ${SERVER_NAME}..."
docker build -t ${IMG} \
--build-arg SERVER_NAME=${SERVER_NAME} \

View File

@@ -1,5 +1,9 @@
# MCP Server Implementation Guide
> **🌟 Try it now!** Experience Higress-hosted Remote MCP Servers at [https://mcp.higress.ai/](https://mcp.higress.ai/). This platform allows you to see firsthand how Higress can host and manage Remote MCP Servers.
>
> ![Higress MCP Server Platform](https://img.alicdn.com/imgextra/i2/O1CN01nmVa0a1aChgpyyWOX_!!6000000003294-0-tps-3430-1742.jpg)
## Background
Higress, as an Envoy-based API gateway, supports hosting MCP Servers through its plugin mechanism. MCP (Model Context Protocol) is essentially an AI-friendly API that enables AI Agents to more easily call various tools and services. Higress provides unified capabilities for authentication, authorization, rate limiting, and observability for tool calls, simplifying the development and deployment of AI applications.
@@ -15,6 +19,10 @@
This guide explains how to implement a Model Context Protocol (MCP) server using the Higress WASM Go SDK. MCP servers provide tools and resources that extend the capabilities of AI assistants.
[**MCP Server QuickStart**](https://higress.cn/en/ai/mcp-quick-start/)
[**Wasm Plugin Hub**](https://higress.cn/en/plugin/)
## Overview
An MCP server is a standalone application that communicates with AI assistants through the Model Context Protocol. It can provide:
@@ -33,49 +41,20 @@ my-mcp-server/
├── go.mod # Go module definition
├── go.sum # Go module checksums
├── main.go # Entry point that registers tools and resources
├── server/
│ └── server.go # Server configuration and parsing
└── tools/
└── my_tool.go # Tool implementation
```
## Server Configuration
The server configuration defines the parameters needed for the server to function. For example:
Define a configuration structure for your MCP server to store settings like API keys:
```go
// server/server.go
package server
// config/config.go
package config
import (
"encoding/json"
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
// Define your server configuration structure
type MyMCPServer struct {
type MyServerConfig struct {
ApiKey string `json:"apiKey"`
// Add other configuration fields as needed
}
// Validate the configuration
func (s MyMCPServer) ConfigHasError() error {
if s.ApiKey == "" {
return errors.New("missing api key")
}
return nil
}
// Parse configuration from JSON
func ParseFromConfig(configBytes []byte, server *MyMCPServer) error {
return json.Unmarshal(configBytes, server)
}
// Parse configuration from HTTP request
func ParseFromRequest(ctx wrapper.HttpContext, server *MyMCPServer) error {
return ctx.ParseMCPServerConfig(server)
}
```
@@ -96,12 +75,13 @@ package tools
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"my-mcp-server/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"my-mcp-server/config"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/utils"
)
// Define your tool structure with input parameters
@@ -122,13 +102,13 @@ func (t MyTool) Description() string {
// which defines the JSON Schema for the tool's input parameters, including
// property types, descriptions, and required fields.
func (t MyTool) InputSchema() map[string]any {
return wrapper.ToInputSchema(&MyTool{})
return server.ToInputSchema(&MyTool{})
}
// Create instantiates a new tool instance based on the input parameters
// from an MCP tool call. It deserializes the JSON parameters into a struct,
// applying default values for optional fields, and returns the configured tool instance.
func (t MyTool) Create(params []byte) wrapper.MCPTool[server.MyMCPServer] {
func (t MyTool) Create(params []byte) server.Tool {
myTool := &MyTool{
Param2: 5, // Default value
}
@@ -139,26 +119,251 @@ func (t MyTool) Create(params []byte) wrapper.MCPTool[server.MyMCPServer] {
// Call implements the core logic for handling an MCP tool call. This method is executed
// when the tool is invoked through the MCP framework. It processes the configured parameters,
// makes any necessary API requests, and formats the results to be returned to the caller.
func (t MyTool) Call(ctx wrapper.HttpContext, config server.MyMCPServer) error {
// Validate configuration
err := server.ParseFromRequest(ctx, &config)
if err != nil {
return err
}
err = config.ConfigHasError()
if err != nil {
return err
func (t MyTool) Call(ctx server.HttpContext, s server.Server) error {
// Get server configuration
serverConfig := &config.MyServerConfig{}
s.GetConfig(serverConfig)
if serverConfig.ApiKey == "" {
return errors.New("missing api key in server configuration")
}
// Implement your tool's logic here
// ...
// Return results
ctx.SendMCPToolTextResult(fmt.Sprintf("Result: %s, %d", t.Param1, t.Param2))
utils.SendMCPToolTextResult(ctx, fmt.Sprintf("Result: %s, %d", t.Param1, t.Param2))
return nil
}
```
## Tool Loading
For better organization, you can create a separate file to load all your tools:
```go
// tools/load_tools.go
package tools
import (
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
)
func LoadTools(server *mcp.MCPServer) server.Server {
return server.AddMCPTool("my_tool", &MyTool{}).
AddMCPTool("another_tool", &AnotherTool{})
// Add more tools as needed
}
```
This approach to organizing code facilitates integration with the all-in-one MCP server plugin. The all-in-one plugin combines multiple MCP servers into a single plugin, reducing the overhead of deploying multiple plugins on the gateway.
### All-in-One Integration
The all-in-one plugin packages multiple MCP servers into a single WASM binary. Each MCP server maintains its own identity and configuration, but they share the same plugin instance. Here's an example of how multiple MCP servers are integrated in the all-in-one plugin:
```go
// all-in-one/main.go
package main
import (
amap "amap-tools/tools"
quark "quark-search/tools"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
)
func main() {}
func init() {
mcp.LoadMCPServer(mcp.AddMCPServer("quark-search",
quark.LoadTools(&mcp.MCPServer{})))
mcp.LoadMCPServer(mcp.AddMCPServer("amap-tools",
amap.LoadTools(&mcp.MCPServer{})))
mcp.InitMCPServer()
}
```
The configuration for the all-in-one plugin follows the same pattern as individual MCP server plugins. The `name` field in the server configuration is used to identify and route requests to the appropriate MCP server within the all-in-one plugin.
## REST-to-MCP Configuration
Higress supports a special REST-to-MCP configuration that allows you to convert REST APIs to MCP tools without writing any code. This is useful for quickly integrating existing REST APIs with AI assistants. This capability is built into all MCP servers and can be used with the all-in-one plugin. The implementation is available at [rest_server.go](https://github.com/alibaba/higress/blob/wasm-go-1.24/plugins/wasm-go/pkg/mcp/server/rest_server.go).
### Configuration Format
To use the REST-to-MCP feature, you need to define your tools in the plugin configuration:
```yaml
server:
name: rest-amap-server
config:
apiKey: your-api-key-here
tools:
- name: maps-geo
description: "Convert structured address information to latitude and longitude coordinates. Supports parsing landmarks, scenic spots, and building names into coordinates."
args:
- name: address
description: "The structured address to parse"
required: true
- name: city
description: "The city to search in"
required: false
requestTemplate:
url: "https://restapi.amap.com/v3/geocode/geo?key={{.config.apiKey}}&address={{.args.address}}&city={{.args.city}}&source=ts_mcp"
method: GET
headers:
- key: x-api-key
value: "{{.config.apiKey}}"
- key: Content-Type
value: application/json
responseTemplate:
body: |
# Geocoding Information
{{- range $index, $geo := .Geocodes }}
## Location {{add $index 1}}
- **Country**: {{ $geo.Country }}
- **Province**: {{ $geo.Province }}
- **City**: {{ $geo.City }}
- **City Code**: {{ $geo.Citycode }}
- **District**: {{ $geo.District }}
- **Street**: {{ $geo.Street }}
- **Number**: {{ $geo.Number }}
- **Administrative Code**: {{ $geo.Adcode }}
- **Coordinates**: {{ $geo.Location }}
- **Level**: {{ $geo.Level }}
{{- end }}
```
### Template Syntax
The REST-to-MCP feature uses the [GJSON Template](https://github.com/higress-group/gjson_template) library for template rendering, which combines Go's template syntax with GJSON's powerful path syntax:
- **Request Templates**: Used to construct the HTTP request URL, headers, and body
- Access configuration values with `.config.fieldName`
- Access tool arguments with `.args.argName`
- **Response Templates**: Used to transform the HTTP response into a format suitable for AI consumption
- Access JSON response fields using GJSON path syntax
- Use template functions like `add`, `upper`, `lower`, etc.
- Use control structures like `if`, `range`, etc.
GJSON Template includes all of [Sprig](https://github.com/Masterminds/sprig)'s functions, providing a rich set of over 70 template functions for string manipulation, math operations, date formatting, list processing, and more. This makes GJSON Template functionally equivalent to Helm's template capabilities.
Some commonly used Sprig functions include:
- **String manipulation**: `trim`, `upper`, `lower`, `replace`, `plural`, `nospace`
- **Math operations**: `add`, `sub`, `mul`, `div`, `max`, `min`
- **Date formatting**: `now`, `date`, `dateInZone`, `dateModify`
- **List operations**: `list`, `first`, `last`, `uniq`, `sortAlpha`
- **Dictionary operations**: `dict`, `get`, `set`, `hasKey`, `pluck`
- **Flow control**: `ternary`, `default`, `empty`, `coalesce`
- **Type conversion**: `toString`, `toJson`, `toPrettyJson`, `toRawJson`
- **Encoding/decoding**: `b64enc`, `b64dec`, `urlquery`, `urlqueryescape`
- **UUID generation**: `uuidv4`
For a complete reference of all available functions, see the [Helm documentation on functions](https://helm.sh/docs/chart_template_guide/function_list/), as GJSON Template includes the same function set.
### GJSON Path Syntax
GJSON Template supports the full GJSON path syntax, which provides powerful JSON querying capabilities:
- **Dot notation**: `address.city`
- **Array indexing**: `users.0.name`
- **Array iteration**: `users.#.name`
- **Wildcards**: `users.*.name`
- **Array filtering**: `users.#(age>=30)#.name`
- **Modifiers**: `users.@reverse.#.name`
- **Multipath**: `{name:users.0.name,count:users.#}`
- **Escape characters**: `path.with\.dot`
For more complex queries, you can use the `gjson` function directly in your templates:
```
<!-- Using the gjson function for complex queries -->
Active users: {{gjson "users.#(active==true)#.name"}}
<!-- Array filtering with multiple conditions -->
Active developers over 30: {{gjson "users.#(active==true && age>30)#.name"}}
<!-- Using modifiers -->
User names (reversed): {{gjson "users.@reverse.#.name"}}
<!-- Iterating over filtered results -->
Admins:
{{range $user := gjson "users.#(roles.#(==admin)>0)#"}}
- {{$user.name}} ({{$user.age}})
{{end}}
```
For a complete reference of GJSON path syntax, see the [GJSON documentation](https://github.com/tidwall/gjson#path-syntax).
### AI Prompt for Template Generation
When working with AI assistants to generate templates for REST-to-MCP configuration, you can use the following prompt:
```
Please help me create a REST-to-MCP configuration for Higress that converts a REST API to an MCP tool. The configuration should follow this format:
```yaml
server:
name: rest-api-server
config:
apiKey: your-api-key-here
tools:
- name: tool-name
description: "Detailed description of what this tool does"
args:
- name: arg1
description: "Description of argument 1"
required: true
- name: arg2
description: "Description of argument 2"
required: false
default: "default value"
requestTemplate:
url: "https://api.example.com/endpoint?key={{.config.apiKey}}&param={{.args.arg1}}"
method: GET
headers:
- key: x-api-key
value: "{{.config.apiKey}}"
- key: Content-Type
value: application/json
body: |
{
"param1": "{{.args.arg1}}",
"param2": "{{.args.arg2}}"
}
responseTemplate:
body: |
# Result
{{- range $index, $item := .items }}
## Item {{add $index 1}}
- **Name**: {{ $item.name }}
- **Value**: {{ $item.value }}
{{- end }}
```
The REST API I want to convert is [describe your API here, including endpoints, parameters, and response format].
Please generate a complete configuration that:
1. Has a descriptive name and appropriate server configuration
2. Defines all necessary arguments with clear descriptions and appropriate required/default values
3. Creates a requestTemplate that correctly formats the API request, including headers with template values
4. Creates a responseTemplate that transforms the API response into a readable format for AI consumption
The templates use GJSON Template syntax (https://github.com/higress-group/gjson_template), which combines Go templates with GJSON path syntax for JSON processing. The template engine supports:
1. Basic dot notation for accessing fields: {{.fieldName}}
2. The gjson function for complex queries: {{gjson "users.#(active==true)#.name"}}
3. All Sprig template functions (like Helm): {{add}}, {{upper}}, {{lower}}, {{date}}, etc.
4. Control structures: {{if}}, {{range}}, {{with}}, etc.
5. Variable assignment: {{$var := .value}}
For complex JSON responses, consider using GJSON's powerful filtering and querying capabilities to extract and format the most relevant information.
```
## Main Entry Point
The main.go file is the entry point for your MCP server. It registers your tools and resources:
@@ -168,31 +373,59 @@ The main.go file is the entry point for your MCP server. It registers your tools
package main
import (
"my-mcp-server/server"
"my-mcp-server/tools"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
)
func main() {}
func init() {
wrapper.SetCtx(
"my-mcp-server", // Server name
wrapper.ParseRawConfig(server.ParseFromConfig),
wrapper.AddMCPTool("my_tool", tools.MyTool{}), // Register tools
// Add more tools as needed
)
mcp.LoadMCPServer(mcp.AddMCPServer("my-mcp-server",
tools.LoadTools(&mcp.MCPServer{})))
mcp.InitMCPServer()
}
```
## Plugin Configuration
When deploying your MCP server as a Higress plugin, you need to configure it in the Higress configuration. Here's an example configuration:
```yaml
server:
# MCP server name - MUST match the name used in mcp.AddMCPServer() in your code
name: my-mcp-server
# MCP server configuration
config:
apiKey: your-api-key-here
# Optional: If configured, acts as a whitelist - only tools listed here can be called
allowTools:
- my_tool
- another_tool
```
> **Important**: The `name` field in the server configuration must exactly match the server name used in the `mcp.AddMCPServer()` call in your code. This is how the system identifies which MCP server should handle the request.
## Dependencies
Your MCP server must use a specific version of the wasm-go SDK that supports Go 1.24's WebAssembly compilation features:
```bash
# Add the required dependency with the specific version tag
go get github.com/alibaba/higress/plugins/wasm-go@wasm-go-1.24
# Add the required dependency
go get github.com/alibaba/higress/plugins/wasm-go
```
Make sure your go.mod file specifies Go 1.24:
```
module my-mcp-server
go 1.24
require (
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250324133957-dab499f6ade6
// other dependencies
)
```
## Building the WASM Binary
@@ -242,6 +475,8 @@ import (
"testing"
)
// TestMyToolInputSchema tests the InputSchema method of MyTool
// to verify that the JSON schema configuration is correct.
func TestMyToolInputSchema(t *testing.T) {
myTool := MyTool{}
schema := myTool.InputSchema()
@@ -257,4 +492,3 @@ func TestMyToolInputSchema(t *testing.T) {
t.Error("InputSchema returned an empty schema")
}
}
```

View File

@@ -15,6 +15,11 @@
下面介绍如何使用 Higress WASM Go SDK 实现 Model Context Protocol (MCP) 服务器。MCP 服务器提供工具和资源,扩展 AI 助手的能力。
[**MCP Server QuickStart**](https://higress.cn/en/ai/mcp-quick-start/)
[**Wasm Plugin Hub**](https://higress.cn/en/plugin/)
## 概述
MCP 服务器是一个独立的应用程序,通过 Model Context Protocol 与 AI 助手通信。它可以提供:
@@ -33,49 +38,20 @@ my-mcp-server/
├── go.mod # Go 模块定义
├── go.sum # Go 模块校验和
├── main.go # 注册工具和资源的入口点
├── server/
│ └── server.go # 服务器配置和解析
└── tools/
└── my_tool.go # 工具实现
```
## 服务器配置
服务器配置定义了服务器运行所需的参数。例如
为您的 MCP 服务器定义一个配置结构,用于存储 API 密钥等设置
```go
// server/server.go
package server
// config/config.go
package config
import (
"encoding/json"
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
// 定义服务器配置结构
type MyMCPServer struct {
type MyServerConfig struct {
ApiKey string `json:"apiKey"`
// 根据需要添加其他配置字段
}
// 验证配置
func (s MyMCPServer) ConfigHasError() error {
if s.ApiKey == "" {
return errors.New("missing api key")
}
return nil
}
// 从 JSON 解析配置
func ParseFromConfig(configBytes []byte, server *MyMCPServer) error {
return json.Unmarshal(configBytes, server)
}
// 从 HTTP 请求解析配置
func ParseFromRequest(ctx wrapper.HttpContext, server *MyMCPServer) error {
return ctx.ParseMCPServerConfig(server)
}
```
@@ -96,12 +72,13 @@ package tools
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"my-mcp-server/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"my-mcp-server/config"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/utils"
)
// 定义带有输入参数的工具结构
@@ -121,12 +98,12 @@ func (t MyTool) Description() string {
// 这对应于 MCP 工具 JSON 响应中的 "inputSchema" 字段,
// 定义了工具输入参数的 JSON Schema包括属性类型、描述和必填字段。
func (t MyTool) InputSchema() map[string]any {
return wrapper.ToInputSchema(&MyTool{})
return server.ToInputSchema(&MyTool{})
}
// Create 基于 MCP 工具调用的输入参数实例化一个新的工具实例。
// 它将 JSON 参数反序列化为结构体,为可选字段应用默认值,并返回配置好的工具实例。
func (t MyTool) Create(params []byte) wrapper.MCPTool[server.MyMCPServer] {
func (t MyTool) Create(params []byte) server.Tool {
myTool := &MyTool{
Param2: 5, // 默认值
}
@@ -136,26 +113,251 @@ func (t MyTool) Create(params []byte) wrapper.MCPTool[server.MyMCPServer] {
// Call 实现处理 MCP 工具调用的核心逻辑。当通过 MCP 框架调用工具时,执行此方法。
// 它处理配置的参数,进行必要的 API 请求,并格式化返回给调用者的结果。
func (t MyTool) Call(ctx wrapper.HttpContext, config server.MyMCPServer) error {
// 验证配置
err := server.ParseFromRequest(ctx, &config)
if err != nil {
return err
}
err = config.ConfigHasError()
if err != nil {
return err
func (t MyTool) Call(ctx server.HttpContext, s server.Server) error {
// 获取服务器配置
serverConfig := &config.MyServerConfig{}
s.GetConfig(serverConfig)
if serverConfig.ApiKey == "" {
return errors.New("服务器配置中缺少 API 密钥")
}
// 在这里实现工具的逻辑
// ...
// 返回结果
ctx.SendMCPToolTextResult(fmt.Sprintf("结果: %s, %d", t.Param1, t.Param2))
utils.SendMCPToolTextResult(ctx, fmt.Sprintf("结果: %s, %d", t.Param1, t.Param2))
return nil
}
```
## 工具加载
为了更好地组织代码,您可以创建一个单独的文件来加载所有工具:
```go
// tools/load_tools.go
package tools
import (
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
)
func LoadTools(server *mcp.MCPServer) server.Server {
return server.AddMCPTool("my_tool", &MyTool{}).
AddMCPTool("another_tool", &AnotherTool{})
// 根据需要添加更多工具
}
```
以这种方式组织代码,可以方便被 all-in-one 目录下的 MCP server 插件集成。all-in-one 插件将所有 MCP server 的逻辑打包到一个插件里,从而降低网关上部署多个插件带来的额外开销。
### All-in-One 集成
all-in-one 插件将多个 MCP server 打包到一个 WASM 二进制文件中。每个 MCP server 保持自己的身份和配置,但它们共享同一个插件实例。以下是 all-in-one 插件中集成多个 MCP server 的示例:
```go
// all-in-one/main.go
package main
import (
amap "amap-tools/tools"
quark "quark-search/tools"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
)
func main() {}
func init() {
mcp.LoadMCPServer(mcp.AddMCPServer("quark-search",
quark.LoadTools(&mcp.MCPServer{})))
mcp.LoadMCPServer(mcp.AddMCPServer("amap-tools",
amap.LoadTools(&mcp.MCPServer{})))
mcp.InitMCPServer()
}
```
all-in-one 插件的配置方式与所有 MCP server 插件都是一样的,都是通过 server 配置中的 name 字段来找到对应的 MCP server。
## REST-to-MCP 配置
Higress 支持一种特殊的 REST-to-MCP 配置,允许您无需编写任何代码即可将 REST API 转换为 MCP 工具。这对于快速将现有 REST API 与 AI 助手集成非常有用。这个能力是所有 MCP 服务器内置的,可以基于 all-in-one 这个插件来使用。内置的逻辑实现在 [rest_server.go](https://github.com/alibaba/higress/blob/wasm-go-1.24/plugins/wasm-go/pkg/mcp/server/rest_server.go)。
### 配置格式
要使用 REST-to-MCP 功能,您需要在插件配置中定义您的工具:
```yaml
server:
name: rest-amap-server
config:
apiKey: 您的API密钥
tools:
- name: maps-geo
description: "将详细的结构化地址转换为经纬度坐标。支持对地标性名胜景区、建筑物名称解析为经纬度坐标"
args:
- name: address
description: "待解析的结构化地址信息"
required: true
- name: city
description: "指定查询的城市"
required: false
requestTemplate:
url: "https://restapi.amap.com/v3/geocode/geo?key={{.config.apiKey}}&address={{.args.address}}&city={{.args.city}}&source=ts_mcp"
method: GET
headers:
- key: x-api-key
value: "{{.config.apiKey}}"
- key: Content-Type
value: application/json
responseTemplate:
body: |
# 地理编码信息
{{- range $index, $geo := .Geocodes }}
## 地点 {{add $index 1}}
- **国家**: {{ $geo.Country }}
- **省份**: {{ $geo.Province }}
- **城市**: {{ $geo.City }}
- **城市代码**: {{ $geo.Citycode }}
- **区/县**: {{ $geo.District }}
- **街道**: {{ $geo.Street }}
- **门牌号**: {{ $geo.Number }}
- **行政编码**: {{ $geo.Adcode }}
- **坐标**: {{ $geo.Location }}
- **级别**: {{ $geo.Level }}
{{- end }}
```
### 模板语法
REST-to-MCP 功能使用 [GJSON Template](https://github.com/higress-group/gjson_template) 库进行模板渲染,该库结合了 Go 的模板语法和 GJSON 的强大路径语法:
- **请求模板**:用于构造 HTTP 请求 URL、头部和正文
- 使用 `.config.fieldName` 访问配置值
- 使用 `.args.argName` 访问工具参数
- **响应模板**:用于将 HTTP 响应转换为适合 AI 消费的格式
- 使用 GJSON 路径语法访问 JSON 响应字段
- 使用模板函数如 `add``upper``lower`
- 使用控制结构如 `if``range`
GJSON Template 包含了所有 [Sprig](https://github.com/Masterminds/sprig) 的函数,提供了超过 70 个用于字符串操作、数学运算、日期格式化、列表处理等的模板函数。这使得 GJSON Template 在功能上等同于 Helm 的模板能力。
一些常用的 Sprig 函数包括:
- **字符串操作**`trim``upper``lower``replace``plural``nospace`
- **数学运算**`add``sub``mul``div``max``min`
- **日期格式化**`now``date``dateInZone``dateModify`
- **列表操作**`list``first``last``uniq``sortAlpha`
- **字典操作**`dict``get``set``hasKey``pluck`
- **流程控制**`ternary``default``empty``coalesce`
- **类型转换**`toString``toJson``toPrettyJson``toRawJson`
- **编码/解码**`b64enc``b64dec``urlquery``urlqueryescape`
- **UUID 生成**`uuidv4`
有关所有可用函数的完整参考,请参阅 [Helm 函数文档](https://helm.sh/docs/chart_template_guide/function_list/),因为 GJSON Template 包含了相同的函数集。
### GJSON 路径语法
GJSON Template 支持完整的 GJSON 路径语法,提供强大的 JSON 查询能力:
- **点表示法**`address.city`
- **数组索引**`users.0.name`
- **数组迭代**`users.#.name`
- **通配符**`users.*.name`
- **数组过滤**`users.#(age>=30)#.name`
- **修饰符**`users.@reverse.#.name`
- **多路径**`{name:users.0.name,count:users.#}`
- **转义字符**`path.with\.dot`
对于更复杂的查询,您可以在模板中直接使用 `gjson` 函数:
```
<!-- 使用 gjson 函数进行复杂查询 -->
活跃用户: {{gjson "users.#(active==true)#.name"}}
<!-- 带有多个条件的数组过滤 -->
30岁以上的活跃开发者: {{gjson "users.#(active==true && age>30)#.name"}}
<!-- 使用修饰符 -->
用户名(倒序): {{gjson "users.@reverse.#.name"}}
<!-- 迭代过滤结果 -->
管理员:
{{range $user := gjson "users.#(roles.#(==admin)>0)#"}}
- {{$user.name}} ({{$user.age}})
{{end}}
```
有关 GJSON 路径语法的完整参考,请参阅 [GJSON 文档](https://github.com/tidwall/gjson#path-syntax)。
### AI 提示词生成模板
在与 AI 助手一起生成 REST-to-MCP 配置的模板时,您可以使用以下提示词:
```
请帮我创建一个 Higress 的 REST-to-MCP 配置,将 REST API 转换为 MCP 工具。配置应遵循以下格式:
```yaml
server:
name: rest-api-server
config:
apiKey: 您的API密钥
tools:
- name: tool-name
description: "详细描述这个工具的功能"
args:
- name: arg1
description: "参数1的描述"
required: true
- name: arg2
description: "参数2的描述"
required: false
default: "默认值"
requestTemplate:
url: "https://api.example.com/endpoint?key={{.config.apiKey}}&param={{.args.arg1}}"
method: GET
headers:
- key: x-api-key
value: "{{.config.apiKey}}"
- key: Content-Type
value: application/json
body: |
{
"param1": "{{.args.arg1}}",
"param2": "{{.args.arg2}}"
}
responseTemplate:
body: |
# 结果
{{- range $index, $item := .items }}
## 项目 {{add $index 1}}
- **名称**: {{ $item.name }}
- **值**: {{ $item.value }}
{{- end }}
```
我想转换的 REST API 是 [在此描述您的 API包括端点、参数和响应格式]。
请生成一个完整的配置,包括:
1. 具有描述性名称和适当的服务器配置
2. 定义所有必要的参数,并提供清晰的描述和适当的必填/默认值
3. 创建正确格式化 API 请求的 requestTemplate包括带有模板值的头部
4. 创建将 API 响应转换为适合 AI 消费的可读格式的 responseTemplate
模板使用 GJSON Template 语法 (https://github.com/higress-group/gjson_template),该语法结合了 Go 模板和 GJSON 路径语法进行 JSON 处理。模板引擎支持:
1. 基本点表示法访问字段:{{.fieldName}}
2. 用于复杂查询的 gjson 函数:{{gjson "users.#(active==true)#.name"}}
3. 所有 Sprig 模板函数(类似 Helm{{add}}、{{upper}}、{{lower}}、{{date}} 等
4. 控制结构:{{if}}、{{range}}、{{with}} 等
5. 变量赋值:{{$var := .value}}
对于复杂的 JSON 响应,请考虑使用 GJSON 强大的过滤和查询能力来提取和格式化最相关的信息。
```
## 主入口点
main.go 文件是 MCP 服务器的入口点。它注册工具和资源:
@@ -165,31 +367,59 @@ main.go 文件是 MCP 服务器的入口点。它注册工具和资源:
package main
import (
"my-mcp-server/server"
"my-mcp-server/tools"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
)
func main() {}
func init() {
wrapper.SetCtx(
"my-mcp-server", // 服务器名称
wrapper.ParseRawConfig(server.ParseFromConfig),
wrapper.AddMCPTool("my_tool", tools.MyTool{}), // 注册工具
// 根据需要添加更多工具
)
mcp.LoadMCPServer(mcp.AddMCPServer("my-mcp-server",
tools.LoadTools(&mcp.MCPServer{})))
mcp.InitMCPServer()
}
```
## 插件配置
当将您的 MCP 服务器部署为 Higress 插件时,您需要在 Higress 配置中进行配置。以下是一个示例配置:
```yaml
server:
# MCP 服务器名称 - 必须与代码中 mcp.AddMCPServer() 调用时使用的名称完全一致
name: my-mcp-server
# MCP 服务器配置
config:
apiKey: 您的API密钥
# 可选:如果配置了,则起到白名单作用 - 只有列在这里的工具才能被调用
allowTools:
- my_tool
- another_tool
```
> **重要提示**server 配置中的 `name` 字段必须与代码中 `mcp.AddMCPServer()` 调用时使用的服务器名称完全一致。系统通过这个名称来识别应该由哪个 MCP 服务器处理请求。
## 依赖项
您的 MCP 服务器必须使用支持 Go 1.24 WebAssembly 编译功能的特定版本的 wasm-go SDK
```bash
# 添加具有特定版本标签的必需依赖项
go get github.com/alibaba/higress/plugins/wasm-go@wasm-go-1.24
# 添加必需依赖项
go get github.com/alibaba/higress/plugins/wasm-go
```
确保您的 go.mod 文件指定 Go 1.24
```
module my-mcp-server
go 1.24
require (
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250324133957-dab499f6ade6
// 其他依赖项
)
```
## 构建 WASM 二进制文件
@@ -239,6 +469,8 @@ import (
"testing"
)
// TestMyToolInputSchema 测试 MyTool 的 InputSchema 方法
// 以验证 JSON schema 配置是否正确。
func TestMyToolInputSchema(t *testing.T) {
myTool := MyTool{}
schema := myTool.InputSchema()

View File

@@ -0,0 +1,42 @@
module all-in-one
go 1.24.1
replace quark-search => ../quark-search
replace amap-tools => ../amap-tools
replace github.com/alibaba/higress/plugins/wasm-go => /Users/zhangty/tmp/higress/plugins/wasm-go
require (
amap-tools v0.0.0-00010101000000-000000000000
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250417132640-fb2e8d5157ad
quark-search v0.0.0-00010101000000-000000000000
)
require (
dario.cat/mergo v1.0.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/higress-group/gjson_template v0.0.0-20250413075336-4c4161ed428b // indirect
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/resp v0.1.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/crypto v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,69 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/gjson_template v0.0.0-20250413075336-4c4161ed428b h1:rRI9+ThQbe+nw4jUiYEyOFaREkXCMMW9k1X2gy2d6pE=
github.com/higress-group/gjson_template v0.0.0-20250413075336-4c4161ed428b/go.mod h1:rU3M+Tq5VrQOo0dxpKHGb03Ty0sdWIZfAH+YCOACx/Y=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0 h1:Ta+RBsZYML3hjoenbGJoS2L6aWJN+hqlxKoqzj/Y2SY=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package main
import (
amap "amap-tools/tools"
quark "quark-search/tools"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
)
func main() {}
func init() {
mcp.LoadMCPServer(mcp.AddMCPServer("quark-search",
quark.LoadTools(mcp.NewMCPServer())))
mcp.LoadMCPServer(mcp.AddMCPServer("amap-tools",
amap.LoadTools(mcp.NewMCPServer())))
mcp.InitMCPServer()
}

View File

@@ -12,30 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package server
package config
import (
"encoding/json"
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
type QuarkMCPServer struct {
// AmapServerConfig defines the configuration structure for the Amap MCP server
type AmapServerConfig struct {
ApiKey string `json:"apiKey"`
}
func (s QuarkMCPServer) ConfigHasError() error {
if s.ApiKey == "" {
return errors.New("missing api key")
}
return nil
}
func ParseFromConfig(configBytes []byte, server *QuarkMCPServer) error {
return json.Unmarshal(configBytes, server)
}
func ParseFromRequest(ctx wrapper.HttpContext, server *QuarkMCPServer) error {
return ctx.ParseMCPServerConfig(server)
// Add other configuration fields as needed
}

View File

@@ -1,23 +1,32 @@
module amap-tools
go 1.24
go 1.24.1
toolchain go1.24.1
require github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250324133957-dab499f6ade6
require github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374
require (
dario.cat/mergo v1.0.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250323151219-d75620c61711 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/higress-group/gjson_template v0.0.0-20250331062947-760bb2f96985 // indirect
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/tidwall/gjson v1.17.3 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/resp v0.1.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/crypto v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,37 +1,70 @@
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250324133957-dab499f6ade6 h1:/iHNur+B0lHmcy97XYwHb6QrnHJichzKs37gnTyGP3k=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250324133957-dab499f6ade6/go.mod h1:csP9Mpkc+gVgbZsizCdcYSy0LJrQA+//RcnZBInyknc=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374 h1:Ht+XEuYcuytDa6YkgTXR/94h+/XAafX0GhGXcnr9siw=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250323151219-d75620c61711 h1:n5sZwSZWQ5uKS69hu50/0gliTFrIJ1w+g/FSdIIiZIs=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250323151219-d75620c61711/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/gjson_template v0.0.0-20250331062947-760bb2f96985 h1:rOxn1GyVZGphQ1GeE1bxSCtRNxtNLzE9KpA5Zyq5Ui0=
github.com/higress-group/gjson_template v0.0.0-20250331062947-760bb2f96985/go.mod h1:rU3M+Tq5VrQOo0dxpKHGb03Ty0sdWIZfAH+YCOACx/Y=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0 h1:Ta+RBsZYML3hjoenbGJoS2L6aWJN+hqlxKoqzj/Y2SY=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -1,32 +1,29 @@
// main.go
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package main
import (
"amap-test/server"
"amap-test/tools"
"amap-tools/tools"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
)
func main() {}
func init() {
wrapper.SetCtx(
"amap-test", // Server name
wrapper.ParseRawConfig(server.ParseFromConfig),
// wrapper.AddMCPTool("my_tool", tools.MyTool{}), // Register tools
// Add more tools as needed
wrapper.AddMCPTool("maps_bicycling", tools.BicyclingRequest{}),
wrapper.AddMCPTool("maps_geo", tools.GeoRequest{}),
wrapper.AddMCPTool("maps_direction_transit_integrated", tools.TransitIntegratedRequest{}),
wrapper.AddMCPTool("maps_ip_location", tools.IPLocationRequest{}),
wrapper.AddMCPTool("maps_weather", tools.WeatherRequest{}),
wrapper.AddMCPTool("maps_direction_driving", tools.DrivingRequest{}),
wrapper.AddMCPTool("maps_around_search", tools.AroundSearchRequest{}),
wrapper.AddMCPTool("maps_search_detail", tools.SearchDetailRequest{}),
wrapper.AddMCPTool("maps_regeocode", tools.ReGeocodeRequest{}),
wrapper.AddMCPTool("maps_text_search", tools.TextSearchRequest{}),
wrapper.AddMCPTool("maps_distance", tools.DistanceRequest{}),
wrapper.AddMCPTool("maps_direction_walking", tools.WalkingRequest{}),
)
}
mcp.LoadMCPServer(mcp.AddMCPServer("amap-tools",
tools.LoadTools(mcp.NewMCPServer())))
mcp.InitMCPServer()
}

View File

@@ -1,33 +0,0 @@
// server/server.go
package server
import (
"encoding/json"
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
// Define your server configuration structure
type AmapMCPServer struct {
ApiKey string `json:"apiKey"`
// Add other configuration fields as needed
}
// Validate the configuration
func (s AmapMCPServer) ConfigHasError() error {
if s.ApiKey == "" {
return errors.New("missing api key")
}
return nil
}
// Parse configuration from JSON
func ParseFromConfig(configBytes []byte, server *AmapMCPServer) error {
return json.Unmarshal(configBytes, server)
}
// Parse configuration from HTTP request
func ParseFromRequest(ctx wrapper.HttpContext, server *AmapMCPServer) error {
return ctx.ParseMCPServerConfig(server)
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package tools
import (
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
)
func LoadTools(server *mcp.MCPServer) server.Server {
return server.AddMCPTool("maps_geo", &GeoRequest{}).
AddMCPTool("maps_bicycling", &BicyclingRequest{}).
AddMCPTool("maps_direction_transit_integrated", &TransitIntegratedRequest{}).
AddMCPTool("maps_ip_location", &IPLocationRequest{}).
AddMCPTool("maps_weather", &WeatherRequest{}).
AddMCPTool("maps_direction_driving", &DrivingRequest{}).
AddMCPTool("maps_around_search", &AroundSearchRequest{}).
AddMCPTool("maps_search_detail", &SearchDetailRequest{}).
AddMCPTool("maps_regeocode", &ReGeocodeRequest{}).
AddMCPTool("maps_text_search", &TextSearchRequest{}).
AddMCPTool("maps_distance", &DistanceRequest{}).
AddMCPTool("maps_direction_walking", &WalkingRequest{})
}

View File

@@ -1,17 +1,34 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package tools
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"amap-tools/server"
"amap-tools/config"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/utils"
)
var _ server.Tool = AroundSearchRequest{}
type AroundSearchRequest struct {
Location string `json:"location" jsonschema_description:"中心点经度纬度"`
Radius string `json:"radius" jsonschema_description:"搜索半径"`
@@ -23,58 +40,29 @@ func (t AroundSearchRequest) Description() string {
}
func (t AroundSearchRequest) InputSchema() map[string]any {
return wrapper.ToInputSchema(&AroundSearchRequest{})
return server.ToInputSchema(&AroundSearchRequest{})
}
func (t AroundSearchRequest) Create(params []byte) wrapper.MCPTool[server.AmapMCPServer] {
func (t AroundSearchRequest) Create(params []byte) server.Tool {
request := &AroundSearchRequest{}
json.Unmarshal(params, &request)
return request
}
func (t AroundSearchRequest) Call(ctx wrapper.HttpContext, config server.AmapMCPServer) error {
err := server.ParseFromRequest(ctx, &config)
if err != nil {
log.Errorf("parse config from request failed, err:%s", err)
return err
}
err = config.ConfigHasError()
if err != nil {
return err
func (t AroundSearchRequest) Call(ctx server.HttpContext, s server.Server) error {
serverConfig := &config.AmapServerConfig{}
s.GetConfig(serverConfig)
if serverConfig.ApiKey == "" {
return errors.New("amap API-KEY is not configured")
}
apiKey := config.ApiKey
if apiKey == "" {
return fmt.Errorf("amap API-KEY is not set")
}
url := fmt.Sprintf("http://restapi.amap.com/v3/place/around?key=%s&location=%s&radius=%s&keywords=%s&source=ts_mcp", apiKey, url.QueryEscape(t.Location), url.QueryEscape(t.Radius), url.QueryEscape(t.Keywords))
url := fmt.Sprintf("http://restapi.amap.com/v3/place/around?key=%s&location=%s&radius=%s&keywords=%s&source=ts_mcp", serverConfig.ApiKey, url.QueryEscape(t.Location), url.QueryEscape(t.Radius), url.QueryEscape(t.Keywords))
return ctx.RouteCall(http.MethodGet, url,
[][2]string{{"Accept", "application/json"}}, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
if statusCode != http.StatusOK {
ctx.OnMCPToolCallError(fmt.Errorf("around search call failed, status: %d", statusCode))
utils.OnMCPToolCallError(ctx, fmt.Errorf("around search call failed, status: %d", statusCode))
return
}
var response struct {
Status string `json:"status"`
Info string `json:"info"`
Pois []struct {
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Typecode string `json:"typecode"`
} `json:"pois"`
}
err := json.Unmarshal(responseBody, &response)
if err != nil {
ctx.OnMCPToolCallError(fmt.Errorf("failed to parse around search response: %v", err))
return
}
if response.Status != "1" {
ctx.OnMCPToolCallError(fmt.Errorf("around search failed: %s", response.Info))
return
}
result := fmt.Sprintf(`{"pois": %s}`, string(responseBody))
ctx.SendMCPToolTextResult(result)
utils.SendMCPToolTextResult(ctx, string(responseBody))
})
}
}

View File

@@ -1,17 +1,34 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package tools
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"amap-tools/server"
"amap-tools/config"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/utils"
)
var _ server.Tool = BicyclingRequest{}
type BicyclingRequest struct {
Origin string `json:"origin" jsonschema_description:"出发点经纬度,坐标格式为:经度,纬度"`
Destination string `json:"destination" jsonschema_description:"目的地经纬度,坐标格式为:经度,纬度"`
@@ -22,66 +39,29 @@ func (t BicyclingRequest) Description() string {
}
func (t BicyclingRequest) InputSchema() map[string]any {
return wrapper.ToInputSchema(&BicyclingRequest{})
return server.ToInputSchema(&BicyclingRequest{})
}
func (t BicyclingRequest) Create(params []byte) wrapper.MCPTool[server.AmapMCPServer] {
func (t BicyclingRequest) Create(params []byte) server.Tool {
request := &BicyclingRequest{}
json.Unmarshal(params, &request)
return request
}
func (t BicyclingRequest) Call(ctx wrapper.HttpContext, config server.AmapMCPServer) error {
err := server.ParseFromRequest(ctx, &config)
if err != nil {
log.Errorf("parse config from request failed, err:%s", err)
return err
}
err = config.ConfigHasError()
if err != nil {
return err
func (t BicyclingRequest) Call(ctx server.HttpContext, s server.Server) error {
serverConfig := &config.AmapServerConfig{}
s.GetConfig(serverConfig)
if serverConfig.ApiKey == "" {
return errors.New("amap API-KEY is not configured")
}
apiKey := config.ApiKey
if apiKey == "" {
return fmt.Errorf("amap API-KEY is not set")
}
url := fmt.Sprintf("http://restapi.amap.com/v4/direction/bicycling?key=%s&origin=%s&destination=%s&source=ts_mcp", apiKey, url.QueryEscape(t.Origin), url.QueryEscape(t.Destination))
url := fmt.Sprintf("http://restapi.amap.com/v4/direction/bicycling?key=%s&origin=%s&destination=%s&source=ts_mcp", serverConfig.ApiKey, url.QueryEscape(t.Origin), url.QueryEscape(t.Destination))
return ctx.RouteCall(http.MethodGet, url,
[][2]string{{"Accept", "application/json"}}, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
if statusCode != http.StatusOK {
ctx.OnMCPToolCallError(fmt.Errorf("bicycling call failed, status: %d", statusCode))
utils.OnMCPToolCallError(ctx, fmt.Errorf("bicycling call failed, status: %d", statusCode))
return
}
var response struct {
Errcode int `json:"errcode"`
Data struct {
Origin string `json:"origin"`
Destination string `json:"destination"`
Paths []struct {
Distance string `json:"distance"`
Duration string `json:"duration"`
Steps []struct {
Instruction string `json:"instruction"`
Road string `json:"road"`
Distance string `json:"distance"`
Orientation string `json:"orientation"`
Duration string `json:"duration"`
} `json:"steps"`
} `json:"paths"`
} `json:"data"`
}
err := json.Unmarshal(responseBody, &response)
if err != nil {
ctx.OnMCPToolCallError(fmt.Errorf("failed to parse bicycling response: %v", err))
return
}
if response.Errcode != 0 {
ctx.OnMCPToolCallError(fmt.Errorf("bicycling failed: %v", response))
return
}
result := fmt.Sprintf(`{"origin": "%s", "destination": "%s", "paths": %s}`, response.Data.Origin, response.Data.Destination, string(responseBody))
ctx.SendMCPToolTextResult(result)
utils.SendMCPToolTextResult(ctx, string(responseBody))
})
}
}

View File

@@ -1,17 +1,34 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package tools
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"amap-tools/server"
"amap-tools/config"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/utils"
)
var _ server.Tool = DrivingRequest{}
type DrivingRequest struct {
Origin string `json:"origin" jsonschema_description:"出发点经度,纬度,坐标格式为:经度,纬度"`
Destination string `json:"destination" jsonschema_description:"目的地经纬度,坐标格式为:经度,纬度"`
@@ -22,68 +39,29 @@ func (t DrivingRequest) Description() string {
}
func (t DrivingRequest) InputSchema() map[string]any {
return wrapper.ToInputSchema(&DrivingRequest{})
}
return server.ToInputSchema(&DrivingRequest{})
func (t DrivingRequest) Create(params []byte) wrapper.MCPTool[server.AmapMCPServer] {
}
func (t DrivingRequest) Create(params []byte) server.Tool {
request := &DrivingRequest{}
json.Unmarshal(params, &request)
return request
}
func (t DrivingRequest) Call(ctx wrapper.HttpContext, config server.AmapMCPServer) error {
err := server.ParseFromRequest(ctx, &config)
if err != nil {
log.Errorf("parse config from request failed, err:%s", err)
return err
}
err = config.ConfigHasError()
if err != nil {
return err
func (t DrivingRequest) Call(ctx server.HttpContext, s server.Server) error {
serverConfig := &config.AmapServerConfig{}
s.GetConfig(serverConfig)
if serverConfig.ApiKey == "" {
return errors.New("amap API-KEY is not configured")
}
apiKey := config.ApiKey
if apiKey == "" {
return fmt.Errorf("amap API-KEY is not set")
}
url := fmt.Sprintf("http://restapi.amap.com/v3/direction/driving?key=%s&origin=%s&destination=%s&source=ts_mcp", apiKey, url.QueryEscape(t.Origin), url.QueryEscape(t.Destination))
url := fmt.Sprintf("http://restapi.amap.com/v3/direction/driving?key=%s&origin=%s&destination=%s&source=ts_mcp", serverConfig.ApiKey, url.QueryEscape(t.Origin), url.QueryEscape(t.Destination))
return ctx.RouteCall(http.MethodGet, url,
[][2]string{{"Accept", "application/json"}}, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
if statusCode != http.StatusOK {
ctx.OnMCPToolCallError(fmt.Errorf("driving call failed, status: %d", statusCode))
utils.OnMCPToolCallError(ctx, fmt.Errorf("driving call failed, status: %d", statusCode))
return
}
var response struct {
Status string `json:"status"`
Info string `json:"info"`
Route struct {
Origin string `json:"origin"`
Destination string `json:"destination"`
Paths []struct {
Path string `json:"path"`
Distance string `json:"distance"`
Duration string `json:"duration"`
Steps []struct {
Instruction string `json:"instruction"`
Road string `json:"road"`
Distance string `json:"distance"`
Orientation string `json:"orientation"`
Duration string `json:"duration"`
} `json:"steps"`
} `json:"paths"`
} `json:"route"`
}
err := json.Unmarshal(responseBody, &response)
if err != nil {
ctx.OnMCPToolCallError(fmt.Errorf("failed to parse driving response: %v", err))
return
}
if response.Status != "1" {
ctx.OnMCPToolCallError(fmt.Errorf("driving failed: %s", response.Info))
return
}
result := fmt.Sprintf(`{"origin": "%s", "destination": "%s", "paths": %s}`, response.Route.Origin, response.Route.Destination, string(responseBody))
ctx.SendMCPToolTextResult(result)
utils.SendMCPToolTextResult(ctx, string(responseBody))
})
}
}

View File

@@ -1,17 +1,34 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// 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.
package tools
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"amap-tools/server"
"amap-tools/config"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/utils"
)
var _ server.Tool = TransitIntegratedRequest{}
type TransitIntegratedRequest struct {
Origin string `json:"origin" jsonschema_description:"出发点经纬度,坐标格式为:经度,纬度"`
Destination string `json:"destination" jsonschema_description:"目的地经纬度,坐标格式为:经度,纬度"`
@@ -20,106 +37,33 @@ type TransitIntegratedRequest struct {
}
func (t TransitIntegratedRequest) Description() string {
return "公交路径规划 API 可以根据用户起终点经纬度坐标规划综合各类公共(火车、公交、地铁)交通方式的通勤方案,并且返回通勤方案的数据,跨城场景下必须传起点城市与终点城市"
return "公交路径规划 API 可以根据用户起终点经纬度坐标规划综合各类公共(火车、公交、地铁)交通方式的通勤方案,并且返回通勤方案的数据,跨城场景下必须传起点城市与终点城市, 起点城市名称可以通过基于ip定位位置的mcp工具获取"
}
func (t TransitIntegratedRequest) InputSchema() map[string]any {
return wrapper.ToInputSchema(&TransitIntegratedRequest{})
return server.ToInputSchema(&TransitIntegratedRequest{})
}
func (t TransitIntegratedRequest) Create(params []byte) wrapper.MCPTool[server.AmapMCPServer] {
func (t TransitIntegratedRequest) Create(params []byte) server.Tool {
request := &TransitIntegratedRequest{}
json.Unmarshal(params, &request)
return request
}
func (t TransitIntegratedRequest) Call(ctx wrapper.HttpContext, config server.AmapMCPServer) error {
err := server.ParseFromRequest(ctx, &config)
if err != nil {
log.Errorf("parse config from request failed, err:%s", err)
return err
}
err = config.ConfigHasError()
if err != nil {
return err
func (t TransitIntegratedRequest) Call(ctx server.HttpContext, s server.Server) error {
serverConfig := &config.AmapServerConfig{}
s.GetConfig(serverConfig)
if serverConfig.ApiKey == "" {
return errors.New("amap API-KEY is not configured")
}
apiKey := config.ApiKey
if apiKey == "" {
return fmt.Errorf("amap API-KEY is not set")
}
url := fmt.Sprintf("http://restapi.amap.com/v3/direction/transit/integrated?key=%s&origin=%s&destination=%s&city=%s&cityd=%s&source=ts_mcp", apiKey, url.QueryEscape(t.Origin), url.QueryEscape(t.Destination), url.QueryEscape(t.City), url.QueryEscape(t.Cityd))
url := fmt.Sprintf("http://restapi.amap.com/v3/direction/transit/integrated?key=%s&origin=%s&destination=%s&city=%s&cityd=%s&source=ts_mcp", serverConfig.ApiKey, url.QueryEscape(t.Origin), url.QueryEscape(t.Destination), url.QueryEscape(t.City), url.QueryEscape(t.Cityd))
return ctx.RouteCall(http.MethodGet, url,
[][2]string{{"Accept", "application/json"}}, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
if statusCode != http.StatusOK {
ctx.OnMCPToolCallError(fmt.Errorf("transit integrated call failed, status: %d", statusCode))
utils.OnMCPToolCallError(ctx, fmt.Errorf("transit integrated call failed, status: %d", statusCode))
return
}
var response struct {
Status string `json:"status"`
Info string `json:"info"`
Route struct {
Origin string `json:"origin"`
Destination string `json:"destination"`
Distance string `json:"distance"`
Transits []struct {
Duration string `json:"duration"`
WalkingDistance string `json:"walking_distance"`
Segments []struct {
Walking struct {
Origin string `json:"origin"`
Destination string `json:"destination"`
Distance string `json:"distance"`
Duration string `json:"duration"`
Steps []struct {
Instruction string `json:"instruction"`
Road string `json:"road"`
Distance string `json:"distance"`
Action string `json:"action"`
AssistantAction string `json:"assistant_action"`
} `json:"steps"`
} `json:"walking"`
Bus struct {
Buslines []struct {
Name string `json:"name"`
DepartureStop struct {
Name string `json:"name"`
} `json:"departure_stop"`
ArrivalStop struct {
Name string `json:"name"`
} `json:"arrival_stop"`
Distance string `json:"distance"`
Duration string `json:"duration"`
ViaStops []struct {
Name string `json:"name"`
} `json:"via_stops"`
} `json:"buslines"`
} `json:"bus"`
Entrance struct {
Name string `json:"name"`
} `json:"entrance"`
Exit struct {
Name string `json:"name"`
} `json:"exit"`
Railway struct {
Name string `json:"name"`
Trip string `json:"trip"`
} `json:"railway"`
} `json:"segments"`
} `json:"transits"`
} `json:"route"`
}
err := json.Unmarshal(responseBody, &response)
if err != nil {
ctx.OnMCPToolCallError(fmt.Errorf("failed to parse transit integrated response: %v", err))
return
}
if response.Status != "1" {
ctx.OnMCPToolCallError(fmt.Errorf("transit integrated failed: %s", response.Info))
return
}
result := fmt.Sprintf(`{"origin": "%s", "destination": "%s", "distance": "%s", "transits": %s}`, response.Route.Origin, response.Route.Destination, response.Route.Distance, string(responseBody))
ctx.SendMCPToolTextResult(result)
utils.SendMCPToolTextResult(ctx, string(responseBody))
})
}
}

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