feat: mcp server

This commit is contained in:
Jacky 2025-04-29 17:15:42 +08:00
parent c4a9d03bb3
commit e8ee931e16
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
51 changed files with 2749 additions and 1526 deletions

114
mcp/config/config_add.go Normal file
View file

@ -0,0 +1,114 @@
package config
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/0xJacky/Nginx-UI/internal/config"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxConfigAddToolName = "nginx_config_add"
// ErrFileAlreadyExists is returned when trying to create a file that already exists
var ErrFileAlreadyExists = errors.New("file already exists")
var nginxConfigAddTool = mcp.NewTool(
nginxConfigAddToolName,
mcp.WithDescription("Add or create a new Nginx configuration file"),
mcp.WithString("name", mcp.Description("The name of the configuration file to create")),
mcp.WithString("content", mcp.Description("The content of the configuration file")),
mcp.WithString("base_dir", mcp.Description("The base directory for the configuration")),
mcp.WithBoolean("overwrite", mcp.Description("Whether to overwrite an existing file")),
mcp.WithArray("sync_node_ids", mcp.Description("IDs of nodes to sync the configuration to")),
)
func handleNginxConfigAdd(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := request.Params.Arguments
name := args["name"].(string)
content := args["content"].(string)
baseDir := args["base_dir"].(string)
overwrite := args["overwrite"].(bool)
// Convert sync_node_ids from []interface{} to []uint64
syncNodeIdsInterface, ok := args["sync_node_ids"].([]interface{})
syncNodeIds := make([]uint64, 0)
if ok {
for _, id := range syncNodeIdsInterface {
if idFloat, ok := id.(float64); ok {
syncNodeIds = append(syncNodeIds, uint64(idFloat))
}
}
}
dir := nginx.GetConfPath(baseDir)
path := filepath.Join(dir, name)
if !helper.IsUnderDirectory(path, nginx.GetConfPath()) {
return nil, config.ErrPathIsNotUnderTheNginxConfDir
}
if !overwrite && helper.FileExists(path) {
return nil, ErrFileAlreadyExists
}
// Check if the directory exists, if not, create it
if !helper.FileExists(dir) {
err := os.MkdirAll(dir, 0755)
if err != nil {
return nil, err
}
}
err := os.WriteFile(path, []byte(content), 0644)
if err != nil {
return nil, err
}
output, err := nginx.Reload()
if err != nil {
return nil, err
}
if nginx.GetLogLevel(output) >= nginx.Warn {
return nil, config.ErrNginxReloadFailed
}
q := query.Config
_, err = q.Where(q.Filepath.Eq(path)).Delete()
if err != nil {
return nil, err
}
cfg := &model.Config{
Name: name,
Filepath: path,
SyncNodeIds: syncNodeIds,
SyncOverwrite: overwrite,
}
err = q.Create(cfg)
if err != nil {
return nil, err
}
err = config.SyncToRemoteServer(cfg)
if err != nil {
return nil, err
}
result := map[string]interface{}{
"name": name,
"content": content,
"file_path": path,
}
jsonResult, _ := json.Marshal(result)
return mcp.NewToolResultText(string(jsonResult)), nil
}

View file

@ -0,0 +1,27 @@
package config
import (
"context"
"encoding/json"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxConfigBasePathToolName = "nginx_config_base_path"
var nginxConfigBasePathTool = mcp.NewTool(
nginxConfigBasePathToolName,
mcp.WithDescription("Get the base path of Nginx configurations"),
)
func handleNginxConfigBasePath(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
basePath := nginx.GetConfPath()
result := map[string]interface{}{
"base_path": basePath,
}
jsonResult, _ := json.Marshal(result)
return mcp.NewToolResultText(string(jsonResult)), nil
}

67
mcp/config/config_get.go Normal file
View file

@ -0,0 +1,67 @@
package config
import (
"context"
"encoding/json"
"os"
"path/filepath"
"github.com/0xJacky/Nginx-UI/internal/config"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/query"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxConfigGetToolName = "nginx_config_get"
var nginxConfigGetTool = mcp.NewTool(
nginxConfigGetToolName,
mcp.WithDescription("Get a specific Nginx configuration file"),
mcp.WithString("relative_path", mcp.Description("The relative path to the configuration file")),
)
func handleNginxConfigGet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
relativePath := request.Params.Arguments["relative_path"].(string)
absPath := nginx.GetConfPath(relativePath)
if !helper.IsUnderDirectory(absPath, nginx.GetConfPath()) {
return nil, config.ErrPathIsNotUnderTheNginxConfDir
}
stat, err := os.Stat(absPath)
if err != nil {
return nil, err
}
content, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
q := query.Config
g := query.ChatGPTLog
chatgpt, err := g.Where(g.Name.Eq(absPath)).FirstOrCreate()
if err != nil {
return nil, err
}
cfg, err := q.Where(q.Filepath.Eq(absPath)).FirstOrInit()
if err != nil {
return nil, err
}
result := map[string]interface{}{
"name": stat.Name(),
"content": string(content),
"chat_gpt_messages": chatgpt.Content,
"file_path": absPath,
"modified_at": stat.ModTime(),
"dir": filepath.Dir(relativePath),
"sync_node_ids": cfg.SyncNodeIds,
"sync_overwrite": cfg.SyncOverwrite,
}
jsonResult, _ := json.Marshal(result)
return mcp.NewToolResultText(string(jsonResult)), nil
}

View file

@ -0,0 +1,30 @@
package config
import (
"context"
"encoding/json"
"github.com/0xJacky/Nginx-UI/query"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxConfigHistoryToolName = "nginx_config_history"
var nginxConfigHistoryTool = mcp.NewTool(
nginxConfigHistoryToolName,
mcp.WithDescription("Get history of Nginx configuration changes"),
mcp.WithString("filepath", mcp.Description("The file path to get history for")),
)
func handleNginxConfigHistory(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
filepath := request.Params.Arguments["filepath"].(string)
q := query.ConfigBackup
var histories, err = q.Where(q.FilePath.Eq(filepath)).Order(q.ID.Desc()).Find()
if err != nil {
return nil, err
}
jsonResult, _ := json.Marshal(histories)
return mcp.NewToolResultText(string(jsonResult)), nil
}

32
mcp/config/config_list.go Normal file
View file

@ -0,0 +1,32 @@
package config
import (
"context"
"encoding/json"
"os"
"strings"
"github.com/0xJacky/Nginx-UI/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxConfigListToolName = "nginx_config_list"
var nginxConfigListTool = mcp.NewTool(
nginxConfigListToolName,
mcp.WithDescription("This is the list of Nginx configurations"),
mcp.WithString("relative_path", mcp.Description("The relative path to the Nginx configurations")),
mcp.WithString("filter_by_name", mcp.Description("Filter the Nginx configurations by name")),
)
func handleNginxConfigList(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
relativePath := request.Params.Arguments["relative_path"].(string)
filterByName := request.Params.Arguments["filter_by_name"].(string)
configs, err := config.GetConfigList(relativePath, func(file os.FileInfo) bool {
return filterByName == "" || strings.Contains(file.Name(), filterByName)
})
jsonResult, _ := json.Marshal(configs)
return mcp.NewToolResultText(string(jsonResult)), err
}

View file

@ -0,0 +1,45 @@
package config
import (
"context"
"encoding/json"
"os"
"github.com/0xJacky/Nginx-UI/internal/config"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxConfigMkdirToolName = "nginx_config_mkdir"
var nginxConfigMkdirTool = mcp.NewTool(
nginxConfigMkdirToolName,
mcp.WithDescription("Create a new directory in the Nginx configuration path"),
mcp.WithString("base_path", mcp.Description("The base path where to create the directory")),
mcp.WithString("folder_name", mcp.Description("The name of the folder to create")),
)
func handleNginxConfigMkdir(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := request.Params.Arguments
basePath := args["base_path"].(string)
folderName := args["folder_name"].(string)
fullPath := nginx.GetConfPath(basePath, folderName)
if !helper.IsUnderDirectory(fullPath, nginx.GetConfPath()) {
return nil, config.ErrPathIsNotUnderTheNginxConfDir
}
err := os.Mkdir(fullPath, 0755)
if err != nil {
return nil, err
}
result := map[string]interface{}{
"message": "Directory created successfully",
"path": fullPath,
}
jsonResult, _ := json.Marshal(result)
return mcp.NewToolResultText(string(jsonResult)), nil
}

View file

@ -0,0 +1,96 @@
package config
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"github.com/0xJacky/Nginx-UI/internal/config"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/mark3labs/mcp-go/mcp"
"gorm.io/gen/field"
)
const nginxConfigModifyToolName = "nginx_config_modify"
// ErrFileNotFound is returned when a file is not found
var ErrFileNotFound = errors.New("file not found")
var nginxConfigModifyTool = mcp.NewTool(
nginxConfigModifyToolName,
mcp.WithDescription("Modify an existing Nginx configuration file"),
mcp.WithString("relative_path", mcp.Description("The relative path to the configuration file")),
mcp.WithString("content", mcp.Description("The new content of the configuration file")),
mcp.WithBoolean("sync_overwrite", mcp.Description("Whether to overwrite existing files when syncing")),
mcp.WithArray("sync_node_ids", mcp.Description("IDs of nodes to sync the configuration to")),
)
func handleNginxConfigModify(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := request.Params.Arguments
relativePath := args["relative_path"].(string)
content := args["content"].(string)
syncOverwrite := args["sync_overwrite"].(bool)
// Convert sync_node_ids from []interface{} to []uint64
syncNodeIdsInterface, ok := args["sync_node_ids"].([]interface{})
syncNodeIds := make([]uint64, 0)
if ok {
for _, id := range syncNodeIdsInterface {
if idFloat, ok := id.(float64); ok {
syncNodeIds = append(syncNodeIds, uint64(idFloat))
}
}
}
absPath := nginx.GetConfPath(relativePath)
if !helper.IsUnderDirectory(absPath, nginx.GetConfPath()) {
return nil, config.ErrPathIsNotUnderTheNginxConfDir
}
if !helper.FileExists(absPath) {
return nil, ErrFileNotFound
}
q := query.Config
cfg, err := q.Assign(field.Attrs(&model.Config{
Filepath: absPath,
})).Where(q.Filepath.Eq(absPath)).FirstOrCreate()
if err != nil {
return nil, err
}
// Update database record
_, err = q.Where(q.Filepath.Eq(absPath)).
Select(q.SyncNodeIds, q.SyncOverwrite).
Updates(&model.Config{
SyncNodeIds: syncNodeIds,
SyncOverwrite: syncOverwrite,
})
if err != nil {
return nil, err
}
cfg.SyncNodeIds = syncNodeIds
cfg.SyncOverwrite = syncOverwrite
err = config.Save(absPath, content, cfg)
if err != nil {
return nil, err
}
result := map[string]interface{}{
"name": filepath.Base(absPath),
"content": content,
"file_path": absPath,
"dir": filepath.Dir(relativePath),
"sync_node_ids": cfg.SyncNodeIds,
"sync_overwrite": cfg.SyncOverwrite,
}
jsonResult, _ := json.Marshal(result)
return mcp.NewToolResultText(string(jsonResult)), nil
}

120
mcp/config/config_rename.go Normal file
View file

@ -0,0 +1,120 @@
package config
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/0xJacky/Nginx-UI/internal/config"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxConfigRenameToolName = "nginx_config_rename"
var nginxConfigRenameTool = mcp.NewTool(
nginxConfigRenameToolName,
mcp.WithDescription("Rename a file or directory in the Nginx configuration path"),
mcp.WithString("base_path", mcp.Description("The base path where the file or directory is located")),
mcp.WithString("orig_name", mcp.Description("The original name of the file or directory")),
mcp.WithString("new_name", mcp.Description("The new name for the file or directory")),
mcp.WithArray("sync_node_ids", mcp.Description("IDs of nodes to sync the rename operation to")),
)
func handleNginxConfigRename(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := request.Params.Arguments
basePath := args["base_path"].(string)
origName := args["orig_name"].(string)
newName := args["new_name"].(string)
// Convert sync_node_ids from []interface{} to []uint64
syncNodeIdsInterface, ok := args["sync_node_ids"].([]interface{})
syncNodeIds := make([]uint64, 0)
if ok {
for _, id := range syncNodeIdsInterface {
if idFloat, ok := id.(float64); ok {
syncNodeIds = append(syncNodeIds, uint64(idFloat))
}
}
}
if origName == newName {
result := map[string]interface{}{
"message": "No changes needed, names are identical",
}
jsonResult, _ := json.Marshal(result)
return mcp.NewToolResultText(string(jsonResult)), nil
}
origFullPath := nginx.GetConfPath(basePath, origName)
newFullPath := nginx.GetConfPath(basePath, newName)
if !helper.IsUnderDirectory(origFullPath, nginx.GetConfPath()) ||
!helper.IsUnderDirectory(newFullPath, nginx.GetConfPath()) {
return nil, config.ErrPathIsNotUnderTheNginxConfDir
}
stat, err := os.Stat(origFullPath)
if err != nil {
return nil, err
}
if helper.FileExists(newFullPath) {
return nil, ErrFileAlreadyExists
}
err = os.Rename(origFullPath, newFullPath)
if err != nil {
return nil, err
}
// update ChatGPT records
g := query.ChatGPTLog
q := query.Config
cfg, err := q.Where(q.Filepath.Eq(origFullPath)).FirstOrInit()
if err != nil {
return nil, err
}
if !stat.IsDir() {
_, _ = g.Where(g.Name.Eq(newFullPath)).Delete()
_, _ = g.Where(g.Name.Eq(origFullPath)).Update(g.Name, newFullPath)
// for file, the sync policy for this file is used
syncNodeIds = cfg.SyncNodeIds
} else {
// is directory, update all records under the directory
_, _ = g.Where(g.Name.Like(origFullPath+"%")).Update(g.Name, g.Name.Replace(origFullPath, newFullPath))
}
_, err = q.Where(q.Filepath.Eq(origFullPath)).Updates(&model.Config{
Filepath: newFullPath,
Name: newName,
})
if err != nil {
return nil, err
}
b := query.ConfigBackup
_, _ = b.Where(b.FilePath.Eq(origFullPath)).Updates(map[string]interface{}{
"filepath": newFullPath,
"name": newName,
})
if len(syncNodeIds) > 0 {
err = config.SyncRenameOnRemoteServer(origFullPath, newFullPath, syncNodeIds)
if err != nil {
return nil, err
}
}
result := map[string]interface{}{
"path": strings.TrimLeft(filepath.Join(basePath, newName), "/"),
}
jsonResult, _ := json.Marshal(result)
return mcp.NewToolResultText(string(jsonResult)), nil
}

16
mcp/config/register.go Normal file
View file

@ -0,0 +1,16 @@
package config
import (
"github.com/0xJacky/Nginx-UI/internal/mcp"
)
func Init() {
mcp.AddTool(nginxConfigAddTool, handleNginxConfigAdd)
mcp.AddTool(nginxConfigBasePathTool, handleNginxConfigBasePath)
mcp.AddTool(nginxConfigGetTool, handleNginxConfigGet)
mcp.AddTool(nginxConfigHistoryTool, handleNginxConfigHistory)
mcp.AddTool(nginxConfigListTool, handleNginxConfigList)
mcp.AddTool(nginxConfigMkdirTool, handleNginxConfigMkdir)
mcp.AddTool(nginxConfigModifyTool, handleNginxConfigModify)
mcp.AddTool(nginxConfigRenameTool, handleNginxConfigRename)
}

11
mcp/nginx/register.go Normal file
View file

@ -0,0 +1,11 @@
package nginx
import (
"github.com/0xJacky/Nginx-UI/internal/mcp"
)
func Init() {
mcp.AddTool(nginxReloadTool, handleNginxReload)
mcp.AddTool(nginxRestartTool, handleNginxRestart)
mcp.AddTool(statusTool, handleNginxStatus)
}

24
mcp/nginx/reload.go Normal file
View file

@ -0,0 +1,24 @@
package nginx
import (
"context"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxReloadToolName = "reload_nginx"
var nginxReloadTool = mcp.NewTool(
nginxReloadToolName,
mcp.WithDescription("Perform a graceful reload of the Nginx configuration"),
)
func handleNginxReload(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
output, err := nginx.Reload()
if err != nil {
return mcp.NewToolResultError(output + "\n" + err.Error()), err
}
return mcp.NewToolResultText(output), nil
}

24
mcp/nginx/restart.go Normal file
View file

@ -0,0 +1,24 @@
package nginx
import (
"context"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxRestartToolName = "restart_nginx"
var nginxRestartTool = mcp.NewTool(
nginxRestartToolName,
mcp.WithDescription("Perform a graceful restart of the Nginx configuration"),
)
func handleNginxRestart(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
nginx.Restart()
output, err := nginx.GetLastOutput()
if err != nil {
return mcp.NewToolResultError(output + "\n" + err.Error()), err
}
return mcp.NewToolResultText(output), nil
}

41
mcp/nginx/status.go Normal file
View file

@ -0,0 +1,41 @@
package nginx
import (
"context"
"encoding/json"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/gin-gonic/gin"
"github.com/mark3labs/mcp-go/mcp"
)
const nginxStatusToolName = "nginx_status"
// statusResource is the status of the Nginx server
var statusTool = mcp.NewTool(
nginxStatusToolName,
mcp.WithDescription("This is the status of the Nginx server"),
)
// handleNginxStatus handles the Nginx status request
func handleNginxStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
lastOutput, err := nginx.GetLastOutput()
if err != nil {
return mcp.NewToolResultError(lastOutput + "\n" + err.Error()), err
}
running := nginx.IsNginxRunning()
level := nginx.GetLogLevel(lastOutput)
// build result
result := gin.H{
"running": running,
"message": lastOutput,
"level": level,
}
// marshal to json and return text result
jsonResult, _ := json.Marshal(result)
return mcp.NewToolResultText(string(jsonResult)), nil
}

11
mcp/register.go Normal file
View file

@ -0,0 +1,11 @@
package mcp
import (
"github.com/0xJacky/Nginx-UI/mcp/config"
"github.com/0xJacky/Nginx-UI/mcp/nginx"
)
func init() {
config.Init()
nginx.Init()
}

18
mcp/router.go Normal file
View file

@ -0,0 +1,18 @@
package mcp
import (
"github.com/0xJacky/Nginx-UI/internal/mcp"
"github.com/0xJacky/Nginx-UI/internal/middleware"
"github.com/gin-gonic/gin"
)
func InitRouter(r *gin.Engine) {
r.Any("/mcp", middleware.IPWhiteList(), middleware.AuthRequired(),
func(c *gin.Context) {
mcp.ServeHTTP(c)
})
r.Any("/mcp_message", middleware.IPWhiteList(),
func(c *gin.Context) {
mcp.ServeHTTP(c)
})
}