diff --git a/plugins/wasm-go/go.mod b/plugins/wasm-go/go.mod index 2b21cb836..a78283977 100644 --- a/plugins/wasm-go/go.mod +++ b/plugins/wasm-go/go.mod @@ -17,5 +17,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/plugins/wasm-go/go.sum b/plugins/wasm-go/go.sum index 679d810a9..3ce581166 100644 --- a/plugins/wasm-go/go.sum +++ b/plugins/wasm-go/go.sum @@ -12,6 +12,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= @@ -20,6 +21,8 @@ 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/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= diff --git a/plugins/wasm-go/mcp-servers/Dockerfile b/plugins/wasm-go/mcp-servers/Dockerfile new file mode 100644 index 000000000..ac8f3b0e2 --- /dev/null +++ b/plugins/wasm-go/mcp-servers/Dockerfile @@ -0,0 +1,33 @@ +# 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 +FROM scratch + +ARG SERVER_NAME=quark-search + +WORKDIR / + +# Copy the WASM binary from the builder stage +COPY --from=builder /app/main.wasm /main.wasm + +# Metadata +LABEL org.opencontainers.image.title="${SERVER_NAME}" +LABEL org.opencontainers.image.description="Higress MCP Server - ${SERVER_NAME}" +LABEL org.opencontainers.image.source="https://github.com/alibaba/higress" + +# The WASM binary is the only artifact in the image diff --git a/plugins/wasm-go/mcp-servers/Makefile b/plugins/wasm-go/mcp-servers/Makefile new file mode 100644 index 000000000..a76f52f5a --- /dev/null +++ b/plugins/wasm-go/mcp-servers/Makefile @@ -0,0 +1,51 @@ +# MCP Server Makefile + +# Variables +SERVER_NAME ?= quark-search +REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ +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) +IMAGE_TAG = $(if $(strip $(SERVER_VERSION)),${SERVER_VERSION},${BUILD_TIME}-${COMMIT_ID}) +IMG ?= ${REGISTRY}${SERVER_NAME}:${IMAGE_TAG} +GOPROXY := $(shell go env GOPROXY) + +# Default target +.DEFAULT: +build: + @echo "Building WASM binary for ${SERVER_NAME}..." + cd ${SERVER_NAME} && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm main.go + @echo "" + @echo "Output WASM file: ${SERVER_NAME}/main.wasm" + +# Build Docker image +build-image: + @echo "Building Docker image for ${SERVER_NAME}..." + docker build -t ${IMG} \ + --build-arg SERVER_NAME=${SERVER_NAME} \ + --build-arg GOPROXY=${GOPROXY} \ + -f Dockerfile . + @echo "" + @echo "Image: ${IMG}" + +# Build and push Docker image +build-push: build-image + docker push ${IMG} + +# Clean build artifacts +clean: + rm -f ${SERVER_NAME}/main.wasm + +# Help +help: + @echo "Available targets:" + @echo " build - Build WASM binary" + @echo " build-image - Build Docker image" + @echo " build-push - Build and push Docker image" + @echo " clean - Remove build artifacts" + @echo "" + @echo "Variables:" + @echo " SERVER_NAME - Name of the MCP server (default: ${SERVER_NAME})" + @echo " REGISTRY - Docker registry (default: ${REGISTRY})" + @echo " SERVER_VERSION - Version tag for the image (default: timestamp-commit)" + @echo " IMG - Full image name (default: ${IMG})" diff --git a/plugins/wasm-go/mcp-servers/README.md b/plugins/wasm-go/mcp-servers/README.md new file mode 100644 index 000000000..8d69b81ef --- /dev/null +++ b/plugins/wasm-go/mcp-servers/README.md @@ -0,0 +1,247 @@ +# MCP Server Implementation Guide + +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. + +## Overview + +An MCP server is a standalone application that communicates with AI assistants through the Model Context Protocol. It can provide: + +- **Tools**: Functions that can be called by the AI to perform specific tasks +- **Resources**: Data that can be accessed by the AI + +> **Note**: MCP server plugins require Higress version 2.1.0 or higher to be used. + +## Project Structure + +A typical MCP server project has the following structure: + +``` +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: + +```go +// server/server.go +package server + +import ( + "encoding/json" + "errors" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" +) + +// Define your server configuration structure +type MyMCPServer 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) +} +``` + +## Tool Implementation + +Each tool should be implemented as a struct with the following methods: + +1. `Description()`: Returns a description of the tool +2. `InputSchema()`: Returns the JSON schema for the tool's input parameters +3. `Create()`: Creates a new instance of the tool with the provided parameters +4. `Call()`: Executes the tool's functionality + +Example: + +```go +// tools/my_tool.go +package tools + +import ( + "encoding/json" + "fmt" + "net/http" + + "my-mcp-server/server" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" +) + +// Define your tool structure with input parameters +type MyTool struct { + Param1 string `json:"param1" jsonschema_description:"Description of param1" jsonschema:"example=example value"` + Param2 int `json:"param2,omitempty" jsonschema_description:"Description of param2" jsonschema:"default=5"` +} + +// Description returns the description field for the MCP tool definition. +// This corresponds to the "description" field in the MCP tool JSON response, +// which provides a human-readable explanation of the tool's purpose and usage. +func (t MyTool) Description() string { + return `Detailed description of what this tool does and when to use it.` +} + +// InputSchema returns the inputSchema field for the MCP tool definition. +// This corresponds to the "inputSchema" field in the MCP tool JSON response, +// 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{}) +} + +// 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] { + myTool := &MyTool{ + Param2: 5, // Default value + } + json.Unmarshal(params, &myTool) + return myTool +} + +// 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 + } + + // Implement your tool's logic here + // ... + + // Return results + ctx.SendMCPToolTextResult(fmt.Sprintf("Result: %s, %d", t.Param1, t.Param2)) + return nil +} +``` + +## Main Entry Point + +The main.go file is the entry point for your MCP server. It registers your tools and resources: + +```go +// main.go +package main + +import ( + "my-mcp-server/server" + "my-mcp-server/tools" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" +) + +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 + ) +} +``` + +## 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 +``` + +## Building the WASM Binary + +To compile your Go code into a WebAssembly (WASM) file, use the following command: + +```bash +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm main.go +``` + +This command sets the target operating system to `wasip1` (WebAssembly System Interface) and architecture to `wasm` (WebAssembly), then builds your code as a C-shared library and outputs it as `main.wasm`. + +## Using the Makefile + +A Makefile is provided to simplify the build process. It includes the following targets: + +- `make build`: Builds the WASM binary for your MCP server +- `make build-image`: Builds a Docker image containing your MCP server +- `make build-push`: Builds and pushes the Docker image to a registry +- `make clean`: Removes build artifacts +- `make help`: Shows available targets and variables + +You can customize the build by setting the following variables: + +```bash +# Build with a custom server name +make SERVER_NAME=my-mcp-server build + +# Build with a custom registry +make REGISTRY=my-registry.example.com/ build-image + +# Build with a specific version tag +make SERVER_VERSION=1.0.0 build-image +``` + +## Testing + +You can create unit tests for your tools to verify their functionality: + +```go +// tools/my_tool_test.go +package tools + +import ( + "encoding/json" + "fmt" + "testing" +) + +func TestMyToolInputSchema(t *testing.T) { + myTool := MyTool{} + schema := myTool.InputSchema() + + schemaJSON, err := json.MarshalIndent(schema, "", " ") + if err != nil { + t.Fatalf("Failed to marshal schema to JSON: %v", err) + } + + fmt.Printf("MyTool InputSchema:\n%s\n", string(schemaJSON)) + + if len(schema) == 0 { + t.Error("InputSchema returned an empty schema") + } +} +``` diff --git a/plugins/wasm-go/mcp-servers/README_zh.md b/plugins/wasm-go/mcp-servers/README_zh.md new file mode 100644 index 000000000..f1295767c --- /dev/null +++ b/plugins/wasm-go/mcp-servers/README_zh.md @@ -0,0 +1,243 @@ +# MCP 服务器实现指南 + +本指南介绍如何使用 Higress WASM Go SDK 实现 Model Context Protocol (MCP) 服务器。MCP 服务器提供工具和资源,扩展 AI 助手的能力。 + +## 概述 + +MCP 服务器是一个独立的应用程序,通过 Model Context Protocol 与 AI 助手通信。它可以提供: + +- **工具**:可以被 AI 调用以执行特定任务的函数 +- **资源**:可以被 AI 访问的数据 + +> **注意**:MCP 服务器插件需要 Higress 2.1.0 或更高版本才能使用。 + +## 项目结构 + +一个典型的 MCP 服务器项目具有以下结构: + +``` +my-mcp-server/ +├── go.mod # Go 模块定义 +├── go.sum # Go 模块校验和 +├── main.go # 注册工具和资源的入口点 +├── server/ +│ └── server.go # 服务器配置和解析 +└── tools/ + └── my_tool.go # 工具实现 +``` + +## 服务器配置 + +服务器配置定义了服务器运行所需的参数。例如: + +```go +// server/server.go +package server + +import ( + "encoding/json" + "errors" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" +) + +// 定义服务器配置结构 +type MyMCPServer 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) +} +``` + +## 工具实现 + +每个工具应该实现为一个具有以下方法的结构体: + +1. `Description()`:返回工具的描述 +2. `InputSchema()`:返回工具输入参数的 JSON schema +3. `Create()`:使用提供的参数创建工具的新实例 +4. `Call()`:执行工具的功能 + +示例: + +```go +// tools/my_tool.go +package tools + +import ( + "encoding/json" + "fmt" + "net/http" + + "my-mcp-server/server" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" +) + +// 定义带有输入参数的工具结构 +type MyTool struct { + Param1 string `json:"param1" jsonschema_description:"参数1的描述" jsonschema:"example=示例值"` + Param2 int `json:"param2,omitempty" jsonschema_description:"参数2的描述" jsonschema:"default=5"` +} + +// Description 返回 MCP 工具定义的描述字段。 +// 这对应于 MCP 工具 JSON 响应中的 "description" 字段, +// 提供了工具目的和用法的人类可读解释。 +func (t MyTool) Description() string { + return `详细描述这个工具做什么以及何时使用它。` +} + +// InputSchema 返回 MCP 工具定义的 inputSchema 字段。 +// 这对应于 MCP 工具 JSON 响应中的 "inputSchema" 字段, +// 定义了工具输入参数的 JSON Schema,包括属性类型、描述和必填字段。 +func (t MyTool) InputSchema() map[string]any { + return wrapper.ToInputSchema(&MyTool{}) +} + +// Create 基于 MCP 工具调用的输入参数实例化一个新的工具实例。 +// 它将 JSON 参数反序列化为结构体,为可选字段应用默认值,并返回配置好的工具实例。 +func (t MyTool) Create(params []byte) wrapper.MCPTool[server.MyMCPServer] { + myTool := &MyTool{ + Param2: 5, // 默认值 + } + json.Unmarshal(params, &myTool) + return myTool +} + +// 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 + } + + // 在这里实现工具的逻辑 + // ... + + // 返回结果 + ctx.SendMCPToolTextResult(fmt.Sprintf("结果: %s, %d", t.Param1, t.Param2)) + return nil +} +``` + +## 主入口点 + +main.go 文件是 MCP 服务器的入口点。它注册工具和资源: + +```go +// main.go +package main + +import ( + "my-mcp-server/server" + "my-mcp-server/tools" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" +) + +func main() {} + +func init() { + wrapper.SetCtx( + "my-mcp-server", // 服务器名称 + wrapper.ParseRawConfig(server.ParseFromConfig), + wrapper.AddMCPTool("my_tool", tools.MyTool{}), // 注册工具 + // 根据需要添加更多工具 + ) +} +``` + +## 依赖项 + +您的 MCP 服务器必须使用支持 Go 1.24 WebAssembly 编译功能的特定版本的 wasm-go SDK: + +```bash +# 添加具有特定版本标签的必需依赖项 +go get github.com/alibaba/higress/plugins/wasm-go@wasm-go-1.24 +``` + +## 构建 WASM 二进制文件 + +要将 Go 代码编译为 WebAssembly (WASM) 文件,请使用以下命令: + +```bash +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm main.go +``` + +此命令将目标操作系统设置为 `wasip1`(WebAssembly 系统接口)和架构设置为 `wasm`(WebAssembly),然后将代码构建为 C 共享库并输出为 `main.wasm`。 + +## 使用 Makefile + +提供了 Makefile 以简化构建过程。它包括以下目标: + +- `make build`:为 MCP 服务器构建 WASM 二进制文件 +- `make build-image`:构建包含 MCP 服务器的 Docker 镜像 +- `make build-push`:构建并将 Docker 镜像推送到注册表 +- `make clean`:删除构建产物 +- `make help`:显示可用的目标和变量 + +您可以通过设置以下变量来自定义构建: + +```bash +# 使用自定义服务器名称构建 +make SERVER_NAME=my-mcp-server build + +# 使用自定义注册表构建 +make REGISTRY=my-registry.example.com/ build-image + +# 使用特定版本标签构建 +make SERVER_VERSION=1.0.0 build-image +``` + +## 测试 + +您可以为工具创建单元测试以验证其功能: + +```go +// tools/my_tool_test.go +package tools + +import ( + "encoding/json" + "fmt" + "testing" +) + +func TestMyToolInputSchema(t *testing.T) { + myTool := MyTool{} + schema := myTool.InputSchema() + + schemaJSON, err := json.MarshalIndent(schema, "", " ") + if err != nil { + t.Fatalf("无法将 schema 序列化为 JSON: %v", err) + } + + fmt.Printf("MyTool InputSchema:\n%s\n", string(schemaJSON)) + + if len(schema) == 0 { + t.Error("InputSchema 返回了空 schema") + } +} diff --git a/plugins/wasm-go/mcp-servers/quark-search/go.mod b/plugins/wasm-go/mcp-servers/quark-search/go.mod new file mode 100644 index 000000000..c19453e4a --- /dev/null +++ b/plugins/wasm-go/mcp-servers/quark-search/go.mod @@ -0,0 +1,25 @@ +module quark-search + +go 1.24 + +toolchain go1.24.1 + +require ( + github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250324133957-dab499f6ade6 + github.com/tidwall/gjson v1.17.3 +) + +require ( + 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/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + 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 + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/wasm-go/mcp-servers/quark-search/go.sum b/plugins/wasm-go/mcp-servers/quark-search/go.sum new file mode 100644 index 000000000..b983448fa --- /dev/null +++ b/plugins/wasm-go/mcp-servers/quark-search/go.sum @@ -0,0 +1,38 @@ +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= +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/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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +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/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/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/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= +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= diff --git a/plugins/wasm-go/mcp-servers/quark-search/main.go b/plugins/wasm-go/mcp-servers/quark-search/main.go new file mode 100644 index 000000000..63ee9e6dd --- /dev/null +++ b/plugins/wasm-go/mcp-servers/quark-search/main.go @@ -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 ( + "quark-search/server" + "quark-search/tools" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" +) + +func main() {} + +func init() { + wrapper.SetCtx( + "quark-mcp-server", + wrapper.ParseRawConfig(server.ParseFromConfig), + wrapper.AddMCPTool("web_search", tools.WebSearch{}), + ) +} diff --git a/plugins/wasm-go/mcp-servers/quark-search/server/server.go b/plugins/wasm-go/mcp-servers/quark-search/server/server.go new file mode 100644 index 000000000..d115b7725 --- /dev/null +++ b/plugins/wasm-go/mcp-servers/quark-search/server/server.go @@ -0,0 +1,41 @@ +// 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 server + +import ( + "encoding/json" + "errors" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" +) + +type QuarkMCPServer 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) +} diff --git a/plugins/wasm-go/mcp-servers/quark-search/tools/web_search.go b/plugins/wasm-go/mcp-servers/quark-search/tools/web_search.go new file mode 100644 index 000000000..770487e41 --- /dev/null +++ b/plugins/wasm-go/mcp-servers/quark-search/tools/web_search.go @@ -0,0 +1,130 @@ +// 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" + "fmt" + "net/http" + "net/url" + "strings" + + "quark-search/server" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/log" + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/tidwall/gjson" +) + +type SearchResult struct { + Title string + Link string + Content string +} + +func (result SearchResult) Valid() bool { + return result.Title != "" && result.Link != "" && result.Content != "" +} + +func (result SearchResult) Format() string { + return fmt.Sprintf(` +## Title: %s + +### Reference URL +%s + +### Content +%s +`, result.Title, result.Link, result.Content) +} + +type WebSearch struct { + Query string `json:"query" jsonschema_description:"Search query, please use Chinese" jsonschema:"example=黄金价格走势"` + ContentMode string `json:"contentMode,omitempty" jsonschema_description:"Return the level of content detail, choose to use summary or full text" jsonschema:"enum=full,enum=summary,default=summary"` + Number uint32 `json:"number,omitempty" jsonschema_description:"Number of results" jsonschema:"default=5"` +} + +// Description returns the description field for the MCP tool definition. +// This corresponds to the "description" field in the MCP tool JSON response, +// which provides a human-readable explanation of the tool's purpose and usage. +func (t WebSearch) Description() string { + return `Performs a web search using the Quark Search API, ideal for general queries, news, articles, and online content. +Use this for broad information gathering, recent events, or when you need diverse web sources. +Because Quark search performs poorly for English searches, please use Chinese for the query parameters.` +} + +// InputSchema returns the inputSchema field for the MCP tool definition. +// This corresponds to the "inputSchema" field in the MCP tool JSON response, +// which defines the JSON Schema for the tool's input parameters, including +// property types, descriptions, and required fields. +func (t WebSearch) InputSchema() map[string]any { + return wrapper.ToInputSchema(&WebSearch{}) +} + +// Create instantiates a new WebSearch tool instance based on the input parameters +// from an MCP tool call. +func (t WebSearch) Create(params []byte) wrapper.MCPTool[server.QuarkMCPServer] { + webSearch := &WebSearch{ + ContentMode: "summary", + Number: 5, + } + json.Unmarshal(params, &webSearch) + return webSearch +} + +// 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 the actual API request to the service, parses the response, +// and formats the results to be returned to the caller. +func (t WebSearch) Call(ctx wrapper.HttpContext, config server.QuarkMCPServer) error { + err := server.ParseFromRequest(ctx, &config) + if err != nil { + log.Errorf("parse config from request failed, err:%s", err) + } + err = config.ConfigHasError() + if err != nil { + return err + } + return ctx.RouteCall(http.MethodGet, fmt.Sprintf("https://cloud-iqs.aliyuncs.com/search/genericSearch?query=%s", url.QueryEscape(t.Query)), + [][2]string{{"Accept", "application/json"}, + {"X-API-Key", config.ApiKey}}, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) { + if statusCode != http.StatusOK { + ctx.OnMCPToolCallError(fmt.Errorf("quark search call failed, status: %d", statusCode)) + return + } + jsonObj := gjson.ParseBytes(responseBody) + var results []string + for index, item := range jsonObj.Get("pageItems").Array() { + var content string + if t.ContentMode == "full" { + content = item.Get("markdownText").String() + if content == "" { + content = item.Get("mainText").String() + } + } else if t.ContentMode == "summary" { + content = item.Get("snippet").String() + } + result := SearchResult{ + Title: item.Get("title").String(), + Link: item.Get("link").String(), + Content: content, + } + if result.Valid() && index < int(t.Number) { + results = append(results, result.Format()) + } + } + ctx.SendMCPToolTextResult(fmt.Sprintf("# Search Results\n\n%s", strings.Join(results, "\n\n"))) + }) +} diff --git a/plugins/wasm-go/mcp-servers/quark-search/tools/web_search_test.go b/plugins/wasm-go/mcp-servers/quark-search/tools/web_search_test.go new file mode 100644 index 000000000..c4204e1a8 --- /dev/null +++ b/plugins/wasm-go/mcp-servers/quark-search/tools/web_search_test.go @@ -0,0 +1,45 @@ +// 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" + "fmt" + "testing" +) + +// TestWebSearchInputSchema tests the InputSchema method of WebSearch +// to verify that the JSON schema configuration is correct. +func TestWebSearchInputSchema(t *testing.T) { + // Create a WebSearch instance + webSearch := WebSearch{} + + // Get the input schema + schema := webSearch.InputSchema() + + // Marshal the schema to JSON for better readability + schemaJSON, err := json.MarshalIndent(schema, "", " ") + if err != nil { + t.Fatalf("Failed to marshal schema to JSON: %v", err) + } + + // Print the schema + fmt.Printf("WebSearch InputSchema:\n%s\n", string(schemaJSON)) + + // Basic validation to ensure the schema is not empty + if len(schema) == 0 { + t.Error("InputSchema returned an empty schema") + } +} diff --git a/plugins/wasm-go/pkg/wrapper/cluster_wrapper.go b/plugins/wasm-go/pkg/wrapper/cluster_wrapper.go index e797394b5..7cc41f6bd 100644 --- a/plugins/wasm-go/pkg/wrapper/cluster_wrapper.go +++ b/plugins/wasm-go/pkg/wrapper/cluster_wrapper.go @@ -24,9 +24,19 @@ import ( type Cluster interface { ClusterName() string HostName() string + HttpCallNotify() HttpCallNotify +} + +type BaseCluster struct { + notify HttpCallNotify +} + +func (base BaseCluster) HttpCallNotify() HttpCallNotify { + return base.notify } type RouteCluster struct { + BaseCluster Host string } @@ -46,6 +56,7 @@ func (c RouteCluster) HostName() string { } type TargetCluster struct { + BaseCluster Host string Cluster string } @@ -59,6 +70,7 @@ func (c TargetCluster) HostName() string { } type K8sCluster struct { + BaseCluster ServiceName string Namespace string Port int64 @@ -83,6 +95,7 @@ func (c K8sCluster) HostName() string { } type NacosCluster struct { + BaseCluster ServiceName string // use DEFAULT-GROUP by default Group string @@ -115,6 +128,7 @@ func (c NacosCluster) HostName() string { } type StaticIpCluster struct { + BaseCluster ServiceName string Port int64 Host string @@ -132,6 +146,7 @@ func (c StaticIpCluster) HostName() string { } type DnsCluster struct { + BaseCluster ServiceName string Domain string Port int64 @@ -146,6 +161,7 @@ func (c DnsCluster) HostName() string { } type ConsulCluster struct { + BaseCluster ServiceName string Datacenter string Port int64 @@ -166,6 +182,7 @@ func (c ConsulCluster) HostName() string { } type FQDNCluster struct { + BaseCluster FQDN string Host string Port int64 diff --git a/plugins/wasm-go/pkg/wrapper/http_wrapper.go b/plugins/wasm-go/pkg/wrapper/http_wrapper.go index 36b4fe76c..8db6bec55 100644 --- a/plugins/wasm-go/pkg/wrapper/http_wrapper.go +++ b/plugins/wasm-go/pkg/wrapper/http_wrapper.go @@ -25,6 +25,11 @@ import ( "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" ) +type HttpCallNotify interface { + HttpCallStart(uint32) + HttpCallEnd(uint32) +} + type ResponseCallback func(statusCode int, responseHeaders http.Header, responseBody []byte) type HttpClient interface { @@ -108,7 +113,9 @@ func HttpCall(cluster Cluster, method, rawURL string, headers [][2]string, body } headers = append(headers, [2]string{":method", method}, [2]string{":path", path}, [2]string{":authority", authority}) requestID := uuid.New().String() - _, err = proxywasm.DispatchHttpCall(cluster.ClusterName(), headers, body, nil, timeout, func(numHeaders, bodySize, numTrailers int) { + httpCallNotify := cluster.HttpCallNotify() + var callID uint32 + callID, err = proxywasm.DispatchHttpCall(cluster.ClusterName(), headers, body, nil, timeout, func(numHeaders, bodySize, numTrailers int) { respBody, err := proxywasm.GetHttpCallResponseBody(0, bodySize) if err != nil { proxywasm.LogCriticalf("failed to get response body: %v", err) @@ -135,8 +142,12 @@ func HttpCall(cluster Cluster, method, rawURL string, headers [][2]string, body proxywasm.LogDebugf("http call end, id: %s, code: %d, normal: %t, body: %s", requestID, code, normalResponse, respBody) callback(code, headers, respBody) + httpCallNotify.HttpCallEnd(callID) }) - proxywasm.LogDebugf("http call start, id: %s, cluster: %s, method: %s, url: %s, headers: %#v, body: %s, timeout: %d", - requestID, cluster.ClusterName(), method, rawURL, headers, body, timeout) + if err == nil { + httpCallNotify.HttpCallStart(callID) + proxywasm.LogDebugf("http call start, id: %s, cluster: %s, method: %s, url: %s, headers: %#v, body: %s, timeout: %d", + requestID, cluster.ClusterName(), method, rawURL, headers, body, timeout) + } return err } diff --git a/plugins/wasm-go/pkg/wrapper/jsonrpc_wrapper.go b/plugins/wasm-go/pkg/wrapper/jsonrpc_wrapper.go new file mode 100644 index 000000000..770301876 --- /dev/null +++ b/plugins/wasm-go/pkg/wrapper/jsonrpc_wrapper.go @@ -0,0 +1,123 @@ +// 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 wrapper + +import ( + "fmt" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/log" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + CtxJsonRpcID = "jsonRpcID" + JError = "error" + JCode = "code" + JMessage = "message" + JResult = "result" + + ErrParseError = -32700 + ErrInvalidRequest = -32600 + ErrMethodNotFound = -32601 + ErrInvalidParams = -32602 + ErrInternalError = -32603 +) + +type JsonRpcRequestHandler[PluginConfig any] func(context HttpContext, config PluginConfig, id int64, method string, params gjson.Result) types.Action + +type JsonRpcResponseHandler[PluginConfig any] func(context HttpContext, config PluginConfig, id int64, result gjson.Result, error gjson.Result) types.Action + +type JsonRpcMethodHandler[PluginConfig any] func(context HttpContext, config PluginConfig, id int64, params gjson.Result) error + +type MethodHandlers[PluginConfig any] map[string]JsonRpcMethodHandler[PluginConfig] + +func sendJsonRpcResponse(id int64, extras map[string]any, debugInfo string) { + body := []byte(`{"jsonrpc": "2.0"}`) + body, _ = sjson.SetBytes(body, "id", id) + for key, value := range extras { + body, _ = sjson.SetBytes(body, key, value) + } + proxywasm.SendHttpResponseWithDetail(200, debugInfo, [][2]string{{"Content-Type", "application/json; charset=utf-8"}}, body, -1) +} + +func (ctx *CommonHttpCtx[PluginConfig]) OnJsonRpcResponseSuccess(result map[string]any) { + var ( + id int64 + ok bool + ) + if id, ok = ctx.userContext[CtxJsonRpcID].(int64); !ok { + proxywasm.SendHttpResponseWithDetail(500, "not_found_json_rpc_id", nil, []byte("not found json rpc id"), -1) + return + } + sendJsonRpcResponse(id, map[string]any{JResult: result}, "json_rpc_success") +} + +func (ctx *CommonHttpCtx[PluginConfig]) OnJsonRpcResponseError(err error, code ...int) { + var ( + id int64 + ok bool + ) + if id, ok = ctx.userContext[CtxJsonRpcID].(int64); !ok { + proxywasm.SendHttpResponseWithDetail(500, "not_found_json_rpc_id", nil, []byte("not found json rpc id"), -1) + return + } + errorCode := ErrInternalError + if len(code) > 0 { + errorCode = code[0] + } + sendJsonRpcResponse(id, map[string]any{JError: map[string]any{ + JMessage: err.Error(), + JCode: errorCode, + }}, "json_rpc_error") +} + +func (ctx *CommonHttpCtx[PluginConfig]) HandleJsonRpcMethod(context HttpContext, config PluginConfig, body []byte, handles MethodHandlers[PluginConfig]) types.Action { + id := gjson.GetBytes(body, "id").Int() + ctx.userContext[CtxJsonRpcID] = id + method := gjson.GetBytes(body, "method").String() + params := gjson.GetBytes(body, "params") + if handle, ok := handles[method]; ok { + log.Debugf("json rpc call id[%d] method[%s] with params[%s]", id, method, params.Raw) + err := handle(context, config, id, params) + if err != nil { + ctx.OnJsonRpcResponseError(err) + return types.ActionContinue + } + // Waiting for the response + return types.ActionPause + } + ctx.OnJsonRpcResponseError(fmt.Errorf("method not found:%s", method), ErrMethodNotFound) + return types.ActionContinue +} + +func (ctx *CommonHttpCtx[PluginConfig]) HandleJsonRpcRequest(context HttpContext, config PluginConfig, body []byte, handle JsonRpcRequestHandler[PluginConfig]) types.Action { + id := gjson.GetBytes(body, "id").Int() + ctx.userContext[CtxJsonRpcID] = id + method := gjson.GetBytes(body, "method").String() + params := gjson.GetBytes(body, "params") + log.Debugf("json rpc call id[%d] method[%s] with params[%s]", id, method, params.Raw) + return handle(context, config, id, method, params) +} + +func (ctx *CommonHttpCtx[PluginConfig]) HandleJsonRpcResponse(context HttpContext, config PluginConfig, body []byte, handle JsonRpcResponseHandler[PluginConfig]) types.Action { + id := gjson.GetBytes(body, "id").Int() + error := gjson.GetBytes(body, "error") + result := gjson.GetBytes(body, "result") + log.Debugf("json rpc response id[%d] error[%s] result[%s]", id, error.Raw, result.Raw) + return handle(context, config, id, result, error) +} diff --git a/plugins/wasm-go/pkg/wrapper/mcp_wrapper.go b/plugins/wasm-go/pkg/wrapper/mcp_wrapper.go new file mode 100644 index 000000000..8c7b08e94 --- /dev/null +++ b/plugins/wasm-go/pkg/wrapper/mcp_wrapper.go @@ -0,0 +1,221 @@ +// 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 wrapper + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/log" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/invopop/jsonschema" + "github.com/tidwall/gjson" +) + +type MCPTool[PluginConfig any] interface { + Create(params []byte) MCPTool[PluginConfig] + Call(context HttpContext, config PluginConfig) error + Description() string + InputSchema() map[string]any +} + +type MCPTools[PluginConfig any] map[string]MCPTool[PluginConfig] + +type addMCPToolOption[PluginConfig any] struct { + name string + tool MCPTool[PluginConfig] +} + +func AddMCPTool[PluginConfig any](name string, tool MCPTool[PluginConfig]) CtxOption[PluginConfig] { + return &addMCPToolOption[PluginConfig]{ + name: name, + tool: tool, + } +} + +func (o *addMCPToolOption[PluginConfig]) Apply(ctx *CommonVmCtx[PluginConfig]) { + ctx.isJsonRpcSever = true + ctx.handleJsonRpcMethod = true + if _, exist := ctx.mcpTools[o.name]; exist { + panic(fmt.Sprintf("Conflict! There is a tool with the same name:%s", + o.name)) + } + ctx.mcpTools[o.name] = o.tool +} + +func (ctx *CommonHttpCtx[PluginConfig]) OnMCPResponseSuccess(result map[string]any) { + ctx.OnJsonRpcResponseSuccess(result) + // TODO: support pub to redis when use POST + SSE +} + +func (ctx *CommonHttpCtx[PluginConfig]) OnMCPResponseError(err error, code ...int) { + ctx.OnJsonRpcResponseError(err, code...) + // TODO: support pub to redis when use POST + SSE +} + +func (ctx *CommonHttpCtx[PluginConfig]) OnMCPToolCallSuccess(content []map[string]any) { + ctx.OnMCPResponseSuccess(map[string]any{ + "content": content, + "isError": false, + }) +} + +func (ctx *CommonHttpCtx[PluginConfig]) OnMCPToolCallError(err error) { + ctx.OnMCPResponseSuccess(map[string]any{ + "content": []map[string]any{ + { + "type": "text", + "text": err.Error(), + }, + }, + "isError": true, + }) +} + +func (ctx *CommonHttpCtx[PluginConfig]) SendMCPToolTextResult(result string) { + ctx.OnMCPToolCallSuccess([]map[string]any{ + { + "type": "text", + "text": result, + }, + }) +} + +func (ctx *CommonHttpCtx[PluginConfig]) registerMCPTools(mcpTools MCPTools[PluginConfig]) { + if !ctx.plugin.vm.isJsonRpcSever { + return + } + if !ctx.plugin.vm.handleJsonRpcMethod { + return + } + ctx.plugin.vm.jsonRpcMethodHandlers["tools/list"] = func(context HttpContext, config PluginConfig, id int64, params gjson.Result) error { + var tools []map[string]any + for name, tool := range mcpTools { + tools = append(tools, map[string]any{ + "name": name, + "description": tool.Description(), + "inputSchema": tool.InputSchema(), + }) + } + ctx.OnMCPResponseSuccess(map[string]any{ + "tools": tools, + "nextCursor": "", + }) + return nil + } + ctx.plugin.vm.jsonRpcMethodHandlers["tools/call"] = func(context HttpContext, config PluginConfig, id int64, params gjson.Result) error { + name := params.Get("name").String() + args := params.Get("arguments") + if tool, ok := mcpTools[name]; ok { + log.Debugf("mcp call tool[%s] with arguments[%s]", name, args.Raw) + toolInstance := tool.Create([]byte(args.Raw)) + err := toolInstance.Call(context, config) + // TODO: validate the json schema through github.com/kaptinlin/jsonschema + if err != nil { + ctx.OnMCPToolCallError(err) + return nil + } + return nil + } + ctx.OnMCPResponseError(errors.New("Unknown tool: invalid_tool_name"), ErrInvalidParams) + return nil + } +} + +type mcpToolRequestFunc[PluginConfig any] func(context HttpContext, config PluginConfig, toolName string, toolArgs gjson.Result) types.Action +type mcpToolResponseFunc[PluginConfig any] func(context HttpContext, config PluginConfig, isError bool, content gjson.Result) types.Action +type jsonRpcErrorFunc[PluginConfig any] func(context HttpContext, config PluginConfig, errorCode int64, errorMessage string) types.Action + +type mcpToolRequestOption[PluginConfig any] struct { + f mcpToolRequestFunc[PluginConfig] +} + +func OnMCPToolRequest[PluginConfig any](f mcpToolRequestFunc[PluginConfig]) CtxOption[PluginConfig] { + return &mcpToolRequestOption[PluginConfig]{f} +} + +func (o *mcpToolRequestOption[PluginConfig]) Apply(ctx *CommonVmCtx[PluginConfig]) { + ctx.isJsonRpcSever = true + ctx.onMcpToolRequest = o.f +} + +type mcpToolResponseOption[PluginConfig any] struct { + f mcpToolResponseFunc[PluginConfig] +} + +func OnMCPToolResponse[PluginConfig any](f mcpToolResponseFunc[PluginConfig]) CtxOption[PluginConfig] { + return &mcpToolResponseOption[PluginConfig]{f} +} + +func (o *mcpToolResponseOption[PluginConfig]) Apply(ctx *CommonVmCtx[PluginConfig]) { + ctx.isJsonRpcSever = true + ctx.onMcpToolResponse = o.f +} + +type jsonRpcErrorOption[PluginConfig any] struct { + f jsonRpcErrorFunc[PluginConfig] +} + +func OnJsonRpcError[PluginConfig any](f jsonRpcErrorFunc[PluginConfig]) CtxOption[PluginConfig] { + return &jsonRpcErrorOption[PluginConfig]{f} +} + +func (o *jsonRpcErrorOption[PluginConfig]) Apply(ctx *CommonVmCtx[PluginConfig]) { + ctx.isJsonRpcSever = true + ctx.onJsonRpcError = o.f +} + +func (ctx *CommonHttpCtx[PluginConfig]) registerMCPToolProcessor() { + if !ctx.plugin.vm.isJsonRpcSever { + return + } + if ctx.plugin.vm.handleJsonRpcMethod { + return + } + if ctx.plugin.vm.onMcpToolRequest != nil { + ctx.plugin.vm.jsonRpcRequestHandler = func(context HttpContext, config PluginConfig, id int64, method string, params gjson.Result) types.Action { + toolName := params.Get("name").String() + toolArgs := params.Get("arguments") + return ctx.plugin.vm.onMcpToolRequest(context, config, toolName, toolArgs) + } + } + if ctx.plugin.vm.onMcpToolResponse != nil { + ctx.plugin.vm.jsonRpcResponseHandler = func(context HttpContext, config PluginConfig, id int64, result, error gjson.Result) types.Action { + if result.Exists() { + isError := result.Get("isError").Bool() + content := result.Get("content") + return ctx.plugin.vm.onMcpToolResponse(context, config, isError, content) + } + if error.Exists() && ctx.plugin.vm.onJsonRpcError != nil { + return ctx.plugin.vm.onJsonRpcError(context, config, error.Get("code").Int(), error.Get("message").String()) + } + return types.ActionContinue + } + } +} + +func ToInputSchema(v any) map[string]any { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + inputSchema := jsonschema.Reflect(v).Definitions[t.Name()] + inputSchemaBytes, _ := json.Marshal(inputSchema) + var result map[string]any + json.Unmarshal(inputSchemaBytes, &result) + return result +} diff --git a/plugins/wasm-go/pkg/wrapper/plugin_wrapper.go b/plugins/wasm-go/pkg/wrapper/plugin_wrapper.go index c591366c5..f7100a5f3 100644 --- a/plugins/wasm-go/pkg/wrapper/plugin_wrapper.go +++ b/plugins/wasm-go/pkg/wrapper/plugin_wrapper.go @@ -70,6 +70,11 @@ type HttpContext interface { SetRequestBodyBufferLimit(byteSize uint32) // Note that this parameter affects the gateway's memory usage! Support setting a maximum buffer size for each response body individually in response phase. SetResponseBodyBufferLimit(byteSize uint32) + // Make a request to the target service of the current route using the specified URL and header. + RouteCall(method, url string, headers [][2]string, body []byte, callback ResponseCallback, timeoutMillisecond ...uint32) error + OnMCPToolCallSuccess(content []map[string]any) + OnMCPToolCallError(err error) + SendMCPToolTextResult(result string) } type oldParseConfigFunc[PluginConfig any] func(json gjson.Result, config *PluginConfig, log log.Log) error @@ -100,6 +105,15 @@ type CommonVmCtx[PluginConfig any] struct { onHttpResponseBody onHttpBodyFunc[PluginConfig] onHttpStreamingResponseBody onHttpStreamingBodyFunc[PluginConfig] onHttpStreamDone onHttpStreamDoneFunc[PluginConfig] + isJsonRpcSever bool + handleJsonRpcMethod bool + jsonRpcMethodHandlers MethodHandlers[PluginConfig] + mcpTools MCPTools[PluginConfig] + onMcpToolRequest mcpToolRequestFunc[PluginConfig] + onMcpToolResponse mcpToolResponseFunc[PluginConfig] + onJsonRpcError jsonRpcErrorFunc[PluginConfig] + jsonRpcRequestHandler JsonRpcRequestHandler[PluginConfig] + jsonRpcResponseHandler JsonRpcResponseHandler[PluginConfig] } type TickFuncEntry struct { @@ -393,8 +407,10 @@ func NewCommonVmCtx[PluginConfig any](pluginName string, options ...CtxOption[Pl func NewCommonVmCtxWithOptions[PluginConfig any](pluginName string, options ...CtxOption[PluginConfig]) *CommonVmCtx[PluginConfig] { ctx := &CommonVmCtx[PluginConfig]{ - pluginName: pluginName, - hasCustomConfig: true, + pluginName: pluginName, + hasCustomConfig: true, + jsonRpcMethodHandlers: make(MethodHandlers[PluginConfig]), + mcpTools: make(MCPTools[PluginConfig]), } for _, opt := range options { opt.Apply(ctx) @@ -403,7 +419,6 @@ func NewCommonVmCtxWithOptions[PluginConfig any](pluginName string, options ...C var config PluginConfig if unsafe.Sizeof(config) != 0 { msg := "the `parseConfig` is missing in NewCommonVmCtx's arguments" - ctx.log.Critical(msg) panic(msg) } ctx.hasCustomConfig = false @@ -495,10 +510,12 @@ func (ctx *CommonPluginCtx[PluginConfig]) NewHttpContext(contextID uint32) types userContext: map[string]interface{}{}, userAttribute: map[string]interface{}{}, } - if ctx.vm.onHttpRequestBody != nil || ctx.vm.onHttpStreamingRequestBody != nil { + httpCtx.registerMCPTools(ctx.vm.mcpTools) + httpCtx.registerMCPToolProcessor() + if ctx.vm.onHttpRequestBody != nil || ctx.vm.onHttpStreamingRequestBody != nil || len(ctx.vm.jsonRpcMethodHandlers) > 0 || ctx.vm.jsonRpcRequestHandler != nil { httpCtx.needRequestBody = true } - if ctx.vm.onHttpResponseBody != nil || ctx.vm.onHttpStreamingResponseBody != nil { + if ctx.vm.onHttpResponseBody != nil || ctx.vm.onHttpStreamingResponseBody != nil || ctx.vm.jsonRpcResponseHandler != nil { httpCtx.needResponseBody = true } if ctx.vm.onHttpStreamingRequestBody != nil { @@ -507,7 +524,6 @@ func (ctx *CommonPluginCtx[PluginConfig]) NewHttpContext(contextID uint32) types if ctx.vm.onHttpStreamingResponseBody != nil { httpCtx.streamingResponseBody = true } - return httpCtx } @@ -524,6 +540,18 @@ type CommonHttpCtx[PluginConfig any] struct { contextID uint32 userContext map[string]interface{} userAttribute map[string]interface{} + pendingCall int +} + +func (ctx *CommonHttpCtx[PluginConfig]) HttpCallStart(uint32) { + ctx.pendingCall++ +} + +func (ctx *CommonHttpCtx[PluginConfig]) HttpCallEnd(uint32) { + if ctx.pendingCall == 0 { + return + } + ctx.pendingCall-- } func (ctx *CommonHttpCtx[PluginConfig]) SetContext(key string, value interface{}) { @@ -599,6 +627,13 @@ func (ctx *CommonHttpCtx[PluginConfig]) WriteUserAttributeToTrace() error { return nil } +func (ctx *CommonHttpCtx[PluginConfig]) GetIntContext(key string, defaultValue int) int { + if b, ok := ctx.userContext[key].(int); ok { + return b + } + return defaultValue +} + func (ctx *CommonHttpCtx[PluginConfig]) GetBoolContext(key string, defaultValue bool) bool { if b, ok := ctx.userContext[key].(bool); ok { return b @@ -686,6 +721,9 @@ func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestHeaders(numHeaders int, end if IsBinaryRequestBody() { ctx.needRequestBody = false } + if ctx.plugin.vm.isJsonRpcSever && HasRequestBody() { + return types.HeaderStopIteration + } if ctx.plugin.vm.onHttpRequestHeaders == nil { return types.ActionContinue } @@ -709,7 +747,9 @@ func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestBody(bodySize int, endOfStr } return types.ActionContinue } - if ctx.plugin.vm.onHttpRequestBody != nil { + if ctx.plugin.vm.onHttpRequestBody != nil || + len(ctx.plugin.vm.jsonRpcMethodHandlers) > 0 || + ctx.plugin.vm.jsonRpcRequestHandler != nil { ctx.requestBodySize += bodySize if !endOfStream { return types.ActionPause @@ -719,7 +759,14 @@ func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestBody(bodySize int, endOfStr ctx.plugin.vm.log.Warnf("get request body failed: %v", err) return types.ActionContinue } - return ctx.plugin.vm.onHttpRequestBody(ctx, *ctx.config, body) + if ctx.plugin.vm.onHttpRequestBody != nil { + return ctx.plugin.vm.onHttpRequestBody(ctx, *ctx.config, body) + } + if len(ctx.plugin.vm.jsonRpcMethodHandlers) > 0 { + return ctx.HandleJsonRpcMethod(ctx, *ctx.config, body, ctx.plugin.vm.jsonRpcMethodHandlers) + } + // ctx.plugin.vm.jsonRpcRequestHandler not nil + return ctx.HandleJsonRpcRequest(ctx, *ctx.config, body, ctx.plugin.vm.jsonRpcRequestHandler) } return types.ActionContinue } @@ -755,7 +802,7 @@ func (ctx *CommonHttpCtx[PluginConfig]) OnHttpResponseBody(bodySize int, endOfSt } return types.ActionContinue } - if ctx.plugin.vm.onHttpResponseBody != nil { + if ctx.plugin.vm.onHttpResponseBody != nil || ctx.plugin.vm.jsonRpcResponseHandler != nil { ctx.responseBodySize += bodySize if !endOfStream { return types.ActionPause @@ -765,7 +812,11 @@ func (ctx *CommonHttpCtx[PluginConfig]) OnHttpResponseBody(bodySize int, endOfSt ctx.plugin.vm.log.Warnf("get response body failed: %v", err) return types.ActionContinue } - return ctx.plugin.vm.onHttpResponseBody(ctx, *ctx.config, body) + if ctx.plugin.vm.onHttpResponseBody != nil { + return ctx.plugin.vm.onHttpResponseBody(ctx, *ctx.config, body) + } + // ctx.plugin.vm.jsonRpcResponseHandler not nil + return ctx.HandleJsonRpcResponse(ctx, *ctx.config, body, ctx.plugin.vm.jsonRpcResponseHandler) } return types.ActionContinue } @@ -779,3 +830,15 @@ func (ctx *CommonHttpCtx[PluginConfig]) OnHttpStreamDone() { } ctx.plugin.vm.onHttpStreamDone(ctx, *ctx.config) } + +func (ctx *CommonHttpCtx[PluginConfig]) RouteCall(method, url string, headers [][2]string, body []byte, callback ResponseCallback, timeoutMillisecond ...uint32) error { + // Since the HttpCall here is a substitute for route invocation, the default timeout is slightly longer, at 1 minute. + var timeout uint32 = 60000 + if len(timeoutMillisecond) > 0 { + timeout = timeoutMillisecond[0] + } + cluster := RouteCluster{ + BaseCluster: BaseCluster{notify: ctx}, + } + return HttpCall(cluster, method, url, headers, body, callback, timeout) +}