feat: Add MCP management (#8299)

This commit is contained in:
zhengkunwang
2025-04-02 17:01:54 +08:00
committed by GitHub
parent 47d135ecca
commit bba8aab18c
49 changed files with 2259 additions and 4 deletions

View File

@@ -68,4 +68,6 @@ var (
favoriteService = service.NewIFavoriteService()
websiteCAService = service.NewIWebsiteCAService()
mcpServerService = service.NewIMcpServerService()
)

View File

@@ -0,0 +1,167 @@
package v1
import (
"github.com/1Panel-dev/1Panel/backend/app/api/v1/helper"
"github.com/1Panel-dev/1Panel/backend/app/dto/request"
"github.com/1Panel-dev/1Panel/backend/constant"
"github.com/gin-gonic/gin"
)
// @Tags McpServer
// @Summary List mcp servers
// @Accept json
// @Param request body request.McpServerSearch true "request"
// @Success 200 {object} response.McpServersRes
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mcp/search [post]
func (b *BaseApi) PageMcpServers(c *gin.Context) {
var req request.McpServerSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
list := mcpServerService.Page(req)
helper.SuccessWithData(c, list)
}
// @Tags McpServer
// @Summary Create mcp server
// @Accept json
// @Param request body request.McpServerCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mcp/server [post]
func (b *BaseApi) CreateMcpServer(c *gin.Context) {
var req request.McpServerCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := mcpServerService.Create(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithOutData(c)
}
// @Tags McpServer
// @Summary Update mcp server
// @Accept json
// @Param request body request.McpServerUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mcp/server/update [post]
func (b *BaseApi) UpdateMcpServer(c *gin.Context) {
var req request.McpServerUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := mcpServerService.Update(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithOutData(c)
}
// @Tags McpServer
// @Summary Delete mcp server
// @Accept json
// @Param request body request.McpServerDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mcp/server/del [post]
func (b *BaseApi) DeleteMcpServer(c *gin.Context) {
var req request.McpServerDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := mcpServerService.Delete(req.ID)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithOutData(c)
}
// @Tags McpServer
// @Summary Operate mcp server
// @Accept json
// @Param request body request.McpServerOperate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mcp/server/op [post]
func (b *BaseApi) OperateMcpServer(c *gin.Context) {
var req request.McpServerOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := mcpServerService.Operate(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithOutData(c)
}
// @Tags McpServer
// @Summary Bind Domain for mcp server
// @Accept json
// @Param request body request.McpBindDomain true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mcp/domain/bind [post]
func (b *BaseApi) BindMcpDomain(c *gin.Context) {
var req request.McpBindDomain
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := mcpServerService.BindDomain(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithOutData(c)
}
// @Tags McpServer
// @Summary Update bind Domain for mcp server
// @Accept json
// @Param request body request.McpBindDomainUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mcp/domain/update [post]
func (b *BaseApi) UpdateMcpBindDomain(c *gin.Context) {
var req request.McpBindDomainUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := mcpServerService.UpdateBindDomain(req)
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithOutData(c)
}
// @Tags McpServer
// @Summary Get bin Domain for mcp server
// @Accept json
// @Success 200 {object} response.McpBindDomainRes
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mcp/domain/get [get]
func (b *BaseApi) GetMcpBindDomain(c *gin.Context) {
res, err := mcpServerService.GetBindDomain()
if err != nil {
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
return
}
helper.SuccessWithData(c, res)
}

10
backend/app/dto/mcp.go Normal file
View File

@@ -0,0 +1,10 @@
package dto
type DockerComposeService struct {
Image string `yaml:"image"`
ContainerName string `yaml:"container_name"`
Restart string `yaml:"restart"`
Ports []string `yaml:"ports"`
Environment []string `yaml:"environment"`
Command []string `yaml:"command"`
}

View File

@@ -0,0 +1,57 @@
package request
import "github.com/1Panel-dev/1Panel/backend/app/dto"
type McpServerSearch struct {
dto.PageInfo
Name string `json:"name"`
Sync bool `json:"sync"`
}
type McpServerCreate struct {
Name string `json:"name" validate:"required"`
Command string `json:"command" validate:"required"`
Environments []Environment `json:"environments"`
Volumes []Volume `json:"volumes"`
Port int `json:"port" validate:"required"`
ContainerName string `json:"containerName"`
BaseURL string `json:"baseUrl"`
SsePath string `json:"ssePath"`
HostIP string `json:"hostIP"`
}
type McpServerUpdate struct {
ID uint `json:"id" validate:"required"`
McpServerCreate
}
type Environment struct {
Key string `json:"key"`
Value string `json:"value"`
}
type Volume struct {
Source string `json:"source"`
Target string `json:"target"`
}
type McpServerDelete struct {
ID uint `json:"id" validate:"required"`
}
type McpServerOperate struct {
ID uint `json:"id" validate:"required"`
Operate string `json:"operate" validate:"required"`
}
type McpBindDomain struct {
Domain string `json:"domain" validate:"required"`
SSLID uint `json:"sslID"`
IPList string `json:"ipList"`
}
type McpBindDomainUpdate struct {
WebsiteID uint `json:"websiteID" validate:"required"`
SSLID uint `json:"sslID"`
IPList string `json:"ipList"`
}

View File

@@ -0,0 +1,26 @@
package response
import (
"github.com/1Panel-dev/1Panel/backend/app/dto/request"
"github.com/1Panel-dev/1Panel/backend/app/model"
)
type McpServersRes struct {
Items []McpServerDTO `json:"items"`
Total int64 `json:"total"`
}
type McpServerDTO struct {
model.McpServer
Environments []request.Environment `json:"environments"`
Volumes []request.Volume `json:"volumes"`
}
type McpBindDomainRes struct {
Domain string `json:"domain"`
SSLID uint `json:"sslID"`
AcmeAccountID uint `json:"acmeAccountID"`
AllowIPs []string `json:"allowIPs"`
WebsiteID uint `json:"websiteID"`
ConnUrl string `json:"connUrl"`
}

View File

@@ -0,0 +1,18 @@
package model
type McpServer struct {
BaseModel
Name string `json:"name"`
DockerCompose string `json:"dockerCompose"`
Command string `json:"command"`
ContainerName string `json:"containerName"`
Message string `json:"message"`
Port int `json:"port"`
Status string `json:"status"`
Env string `json:"env"`
BaseURL string `json:"baseUrl"`
SsePath string `json:"ssePath"`
WebsiteID int `json:"websiteID"`
Dir string `json:"dir"`
HostIP string `json:"hostIP"`
}

View File

@@ -0,0 +1,56 @@
package repo
import "github.com/1Panel-dev/1Panel/backend/app/model"
type McpServerRepo struct {
}
type IMcpServerRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.McpServer, error)
GetFirst(opts ...DBOption) (*model.McpServer, error)
Create(mcpServer *model.McpServer) error
Save(mcpServer *model.McpServer) error
DeleteBy(opts ...DBOption) error
List(opts ...DBOption) ([]model.McpServer, error)
}
func NewIMcpServerRepo() IMcpServerRepo {
return &McpServerRepo{}
}
func (m McpServerRepo) Page(page, size int, opts ...DBOption) (int64, []model.McpServer, error) {
var servers []model.McpServer
db := getDb(opts...).Model(&model.McpServer{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&servers).Error
return count, servers, err
}
func (m McpServerRepo) GetFirst(opts ...DBOption) (*model.McpServer, error) {
var mcpServer model.McpServer
if err := getDb(opts...).First(&mcpServer).Error; err != nil {
return nil, err
}
return &mcpServer, nil
}
func (m McpServerRepo) List(opts ...DBOption) ([]model.McpServer, error) {
var mcpServers []model.McpServer
if err := getDb(opts...).Find(&mcpServers).Error; err != nil {
return nil, err
}
return mcpServers, nil
}
func (m McpServerRepo) Create(mcpServer *model.McpServer) error {
return getDb().Create(mcpServer).Error
}
func (m McpServerRepo) Save(mcpServer *model.McpServer) error {
return getDb().Save(mcpServer).Error
}
func (m McpServerRepo) DeleteBy(opts ...DBOption) error {
return getDb(opts...).Delete(&model.McpServer{}).Error
}

View File

@@ -46,4 +46,6 @@ var (
phpExtensionsRepo = repo.NewIPHPExtensionsRepo()
favoriteRepo = repo.NewIFavoriteRepo()
mcpServerRepo = repo.NewIMcpServerRepo()
)

View File

@@ -0,0 +1,627 @@
package service
import (
"context"
"errors"
"fmt"
"github.com/1Panel-dev/1Panel/backend/app/dto"
"github.com/1Panel-dev/1Panel/backend/app/dto/request"
"github.com/1Panel-dev/1Panel/backend/app/dto/response"
"github.com/1Panel-dev/1Panel/backend/app/model"
"github.com/1Panel-dev/1Panel/backend/buserr"
"github.com/1Panel-dev/1Panel/backend/constant"
"github.com/1Panel-dev/1Panel/backend/global"
"github.com/1Panel-dev/1Panel/backend/utils/common"
"github.com/1Panel-dev/1Panel/backend/utils/compose"
"github.com/1Panel-dev/1Panel/backend/utils/docker"
"github.com/1Panel-dev/1Panel/backend/utils/files"
"github.com/1Panel-dev/1Panel/backend/utils/nginx"
"github.com/1Panel-dev/1Panel/backend/utils/nginx/components"
"github.com/1Panel-dev/1Panel/backend/utils/nginx/parser"
"github.com/1Panel-dev/1Panel/cmd/server/mcp"
"github.com/1Panel-dev/1Panel/cmd/server/nginx_conf"
"github.com/subosito/gotenv"
"gopkg.in/yaml.v3"
"path"
"strconv"
"strings"
)
type McpServerService struct{}
type IMcpServerService interface {
Page(req request.McpServerSearch) response.McpServersRes
Create(create request.McpServerCreate) error
Update(req request.McpServerUpdate) error
Delete(id uint) error
Operate(req request.McpServerOperate) error
GetBindDomain() (response.McpBindDomainRes, error)
BindDomain(req request.McpBindDomain) error
UpdateBindDomain(req request.McpBindDomainUpdate) error
}
func NewIMcpServerService() IMcpServerService {
return &McpServerService{}
}
func (m McpServerService) Page(req request.McpServerSearch) response.McpServersRes {
var (
res response.McpServersRes
items []response.McpServerDTO
)
total, data, _ := mcpServerRepo.Page(req.PageInfo.Page, req.PageInfo.PageSize)
for _, item := range data {
serverDTO := response.McpServerDTO{
McpServer: item,
Environments: make([]request.Environment, 0),
Volumes: make([]request.Volume, 0),
}
project, _ := docker.GetComposeProject(item.Name, path.Join(constant.McpDir, item.Name), []byte(item.DockerCompose), []byte(item.Env), true)
for _, service := range project.Services {
if service.Environment != nil {
for key, value := range service.Environment {
serverDTO.Environments = append(serverDTO.Environments, request.Environment{
Key: key,
Value: *value,
})
}
}
if service.Volumes != nil {
for _, volume := range service.Volumes {
serverDTO.Volumes = append(serverDTO.Volumes, request.Volume{
Source: volume.Source,
Target: volume.Target,
})
}
}
}
items = append(items, serverDTO)
}
res.Total = total
res.Items = items
return res
}
func (m McpServerService) Update(req request.McpServerUpdate) error {
mcpServer, err := mcpServerRepo.GetFirst(commonRepo.WithByID(req.ID))
if err != nil {
return err
}
if mcpServer.Port != req.Port {
if err := checkPortExist(req.Port); err != nil {
return err
}
}
if mcpServer.ContainerName != req.ContainerName {
if err := checkContainerName(req.ContainerName); err != nil {
return err
}
}
mcpServer.Name = req.Name
mcpServer.ContainerName = req.ContainerName
mcpServer.Port = req.Port
mcpServer.Command = req.Command
mcpServer.BaseURL = req.BaseURL
mcpServer.SsePath = req.SsePath
mcpServer.HostIP = req.HostIP
if err := handleCreateParams(mcpServer, req.Environments, req.Volumes); err != nil {
return err
}
env := handleEnv(mcpServer)
mcpDir := path.Join(constant.McpDir, mcpServer.Name)
envPath := path.Join(mcpDir, ".env")
if err := gotenv.Write(env, envPath); err != nil {
return err
}
dockerComposePath := path.Join(mcpDir, "docker-compose.yml")
if err := files.NewFileOp().SaveFile(dockerComposePath, mcpServer.DockerCompose, 0644); err != nil {
return err
}
startMcp(mcpServer)
return syncMcpServerContainerStatus(mcpServer)
}
func (m McpServerService) Create(create request.McpServerCreate) error {
servers, _ := mcpServerRepo.List()
for _, server := range servers {
if server.Port == create.Port {
return buserr.New("ErrPortInUsed")
}
if server.ContainerName == create.ContainerName {
return buserr.New("ErrContainerName")
}
if server.Name == create.Name {
return buserr.New(constant.ErrNameIsExist)
}
if server.SsePath == create.SsePath {
return buserr.New("ErrSsePath")
}
}
if err := checkPortExist(create.Port); err != nil {
return err
}
if err := checkContainerName(create.ContainerName); err != nil {
return err
}
mcpDir := path.Join(constant.McpDir, create.Name)
mcpServer := &model.McpServer{
Name: create.Name,
ContainerName: create.ContainerName,
Port: create.Port,
Command: create.Command,
Status: constant.RuntimeNormal,
BaseURL: create.BaseURL,
SsePath: create.SsePath,
Dir: mcpDir,
}
if err := handleCreateParams(mcpServer, create.Environments, create.Volumes); err != nil {
return err
}
env := handleEnv(mcpServer)
filesOP := files.NewFileOp()
if !filesOP.Stat(mcpDir) {
_ = filesOP.CreateDir(mcpDir, 0644)
}
envPath := path.Join(mcpDir, ".env")
if err := gotenv.Write(env, envPath); err != nil {
return err
}
dockerComposePath := path.Join(mcpDir, "docker-compose.yml")
if err := filesOP.SaveFile(dockerComposePath, mcpServer.DockerCompose, 0644); err != nil {
return err
}
if err := mcpServerRepo.Create(mcpServer); err != nil {
return err
}
startMcp(mcpServer)
addProxy(mcpServer)
return syncMcpServerContainerStatus(mcpServer)
}
func (m McpServerService) Delete(id uint) error {
mcpServer, err := mcpServerRepo.GetFirst(commonRepo.WithByID(id))
if err != nil {
return err
}
composePath := path.Join(constant.McpDir, mcpServer.Name, "docker-compose.yml")
_, _ = compose.Down(composePath)
_ = files.NewFileOp().DeleteDir(path.Join(constant.McpDir, mcpServer.Name))
websiteID := GetWebsiteID()
if websiteID > 0 {
websiteService := NewIWebsiteService()
delProxyReq := request.WebsiteProxyDel{
ID: websiteID,
Name: mcpServer.Name,
}
_ = websiteService.DeleteProxy(delProxyReq)
}
return mcpServerRepo.DeleteBy(commonRepo.WithByID(id))
}
func (m McpServerService) Operate(req request.McpServerOperate) error {
mcpServer, err := mcpServerRepo.GetFirst(commonRepo.WithByID(req.ID))
if err != nil {
return err
}
composePath := path.Join(mcpServer.Dir, "docker-compose.yml")
var out string
switch req.Operate {
case "start":
out, err = compose.Up(composePath)
mcpServer.Status = constant.RuntimeRunning
case "stop":
out, err = compose.Down(composePath)
mcpServer.Status = constant.RuntimeStopped
case "restart":
out, err = compose.Restart(composePath)
mcpServer.Status = constant.RuntimeRunning
}
if err != nil {
mcpServer.Status = constant.RuntimeError
mcpServer.Message = out
}
return mcpServerRepo.Save(mcpServer)
}
func (m McpServerService) GetBindDomain() (response.McpBindDomainRes, error) {
var res response.McpBindDomainRes
websiteID := GetWebsiteID()
if websiteID == 0 {
return res, nil
}
website, err := websiteRepo.GetFirst(commonRepo.WithByID(websiteID))
if err != nil {
return res, err
}
res.WebsiteID = website.ID
res.Domain = website.PrimaryDomain
if website.WebsiteSSLID > 0 {
res.SSLID = website.WebsiteSSLID
ssl, _ := websiteSSLRepo.GetFirst(commonRepo.WithByID(website.WebsiteSSLID))
res.AcmeAccountID = ssl.AcmeAccountID
}
res.ConnUrl = fmt.Sprintf("%s://%s", strings.ToLower(website.Protocol), website.PrimaryDomain)
res.AllowIPs = GetAllowIps(website)
return res, nil
}
func (m McpServerService) BindDomain(req request.McpBindDomain) error {
nginxInstall, _ := getAppInstallByKey(constant.AppOpenresty)
if nginxInstall.ID == 0 {
return buserr.New("ErrOpenrestyInstall")
}
var (
ipList []string
err error
)
if len(req.IPList) > 0 {
ipList, err = common.HandleIPList(req.IPList)
if err != nil {
return err
}
}
if req.SSLID > 0 {
ssl, err := websiteSSLRepo.GetFirst(commonRepo.WithByID(req.SSLID))
if err != nil {
return err
}
if ssl.Pem == "" {
return buserr.New("ErrSSL")
}
}
createWebsiteReq := request.WebsiteCreate{
PrimaryDomain: req.Domain,
Alias: strings.ToLower(req.Domain),
Type: constant.Static,
}
websiteService := NewIWebsiteService()
if err := websiteService.CreateWebsite(createWebsiteReq); err != nil {
return err
}
website, err := websiteRepo.GetFirst(websiteRepo.WithAlias(strings.ToLower(req.Domain)))
if err != nil {
return err
}
_ = settingRepo.UpdateOrCreate("MCP_WEBSITE_ID", fmt.Sprintf("%d", website.ID))
if len(ipList) > 0 {
if err = ConfigAllowIPs(ipList, website); err != nil {
return err
}
}
if req.SSLID > 0 {
sslReq := request.WebsiteHTTPSOp{
WebsiteID: website.ID,
Enable: true,
Type: "existed",
WebsiteSSLID: req.SSLID,
HttpConfig: "HTTPSOnly",
}
if _, err = websiteService.OpWebsiteHTTPS(context.Background(), sslReq); err != nil {
return err
}
}
if err = addMCPProxy(website.ID); err != nil {
return err
}
return nil
}
func (m McpServerService) UpdateBindDomain(req request.McpBindDomainUpdate) error {
nginxInstall, _ := getAppInstallByKey(constant.AppOpenresty)
if nginxInstall.ID == 0 {
return buserr.New("ErrOpenrestyInstall")
}
var (
ipList []string
err error
)
if len(req.IPList) > 0 {
ipList, err = common.HandleIPList(req.IPList)
if err != nil {
return err
}
}
if req.SSLID > 0 {
ssl, err := websiteSSLRepo.GetFirst(commonRepo.WithByID(req.SSLID))
if err != nil {
return err
}
if ssl.Pem == "" {
return buserr.New("ErrSSL")
}
}
websiteService := NewIWebsiteService()
website, err := websiteRepo.GetFirst(commonRepo.WithByID(req.WebsiteID))
if err != nil {
return err
}
if err = ConfigAllowIPs(ipList, website); err != nil {
return err
}
if req.SSLID > 0 {
sslReq := request.WebsiteHTTPSOp{
WebsiteID: website.ID,
Enable: true,
Type: "existed",
WebsiteSSLID: req.SSLID,
HttpConfig: "HTTPSOnly",
}
if _, err = websiteService.OpWebsiteHTTPS(context.Background(), sslReq); err != nil {
return err
}
}
if website.WebsiteSSLID > 0 && req.SSLID == 0 {
sslReq := request.WebsiteHTTPSOp{
WebsiteID: website.ID,
Enable: false,
}
if _, err = websiteService.OpWebsiteHTTPS(context.Background(), sslReq); err != nil {
return err
}
}
go updateMcpConfig(website.ID)
return nil
}
func updateMcpConfig(websiteID uint) {
servers, _ := mcpServerRepo.List()
if len(servers) == 0 {
return
}
website, _ := websiteRepo.GetFirst(commonRepo.WithByID(websiteID))
websiteDomain := website.Domains[0]
var baseUrl string
if website.Protocol == constant.ProtocolHTTP {
baseUrl = fmt.Sprintf("http://%s", websiteDomain.Domain)
} else {
baseUrl = fmt.Sprintf("https://%s", websiteDomain.Domain)
}
for _, server := range servers {
if server.BaseURL != baseUrl {
server.BaseURL = baseUrl
go updateMcpServer(&server)
}
}
}
func addProxy(server *model.McpServer) {
websiteID := GetWebsiteID()
website, err := websiteRepo.GetFirst(commonRepo.WithByID(websiteID))
if err != nil {
global.LOG.Errorf("[mcp] add proxy failed, err: %v", err)
return
}
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
global.LOG.Errorf("[mcp] add proxy failed, err: %v", err)
return
}
fileOp := files.NewFileOp()
includeDir := path.Join(nginxInstall.GetPath(), "www", "sites", website.Alias, "proxy")
if !fileOp.Stat(includeDir) {
if err = fileOp.CreateDir(includeDir, 0644); err != nil {
return
}
}
config, err := parser.NewStringParser(string(nginx_conf.SSE)).Parse()
if err != nil {
return
}
includePath := path.Join(includeDir, server.Name+".conf")
config.FilePath = includePath
directives := config.Directives
location, ok := directives[0].(*components.Location)
if !ok {
return
}
location.UpdateDirective("proxy_pass", []string{fmt.Sprintf("http://127.0.1:%d%s", server.Port, server.SsePath)})
location.ChangePath("^~", server.SsePath)
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
global.LOG.Errorf("write config failed, err: %v", buserr.WithErr(constant.ErrUpdateBuWebsite, err))
return
}
nginxInclude := fmt.Sprintf("/www/sites/%s/proxy/*.conf", website.Alias)
if err = updateNginxConfig(constant.NginxScopeServer, []dto.NginxParam{{Name: "include", Params: []string{nginxInclude}}}, &website); err != nil {
global.LOG.Errorf("update nginx config failed, err: %v", err)
return
}
}
func addMCPProxy(websiteID uint) error {
servers, _ := mcpServerRepo.List()
if len(servers) == 0 {
return nil
}
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err != nil {
return err
}
website, err := websiteRepo.GetFirst(commonRepo.WithByID(websiteID))
if err != nil {
return err
}
fileOp := files.NewFileOp()
includeDir := path.Join(nginxInstall.GetPath(), "www", "sites", website.Alias, "proxy")
if !fileOp.Stat(includeDir) {
if err = fileOp.CreateDir(includeDir, 0644); err != nil {
return err
}
}
config, err := parser.NewStringParser(string(nginx_conf.SSE)).Parse()
if err != nil {
return err
}
websiteDomain := website.Domains[0]
var baseUrl string
if website.Protocol == constant.ProtocolHTTP {
baseUrl = fmt.Sprintf("http://%s", websiteDomain.Domain)
} else {
baseUrl = fmt.Sprintf("https://%s", websiteDomain.Domain)
}
if websiteDomain.Port != 80 && websiteDomain.Port != 443 {
baseUrl = fmt.Sprintf("%s:%d", baseUrl, websiteDomain.Port)
}
for _, server := range servers {
includePath := path.Join(includeDir, server.Name+".conf")
config.FilePath = includePath
directives := config.Directives
location, ok := directives[0].(*components.Location)
if !ok {
err = errors.New("error")
return err
}
location.UpdateDirective("proxy_pass", []string{fmt.Sprintf("http://127.0.1:%d%s", server.Port, server.SsePath)})
location.ChangePath("^~", server.SsePath)
if err = nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
return buserr.WithErr(constant.ErrUpdateBuWebsite, err)
}
server.BaseURL = baseUrl
go updateMcpServer(&server)
}
nginxInclude := fmt.Sprintf("/www/sites/%s/proxy/*.conf", website.Alias)
if err = updateNginxConfig(constant.NginxScopeServer, []dto.NginxParam{{Name: "include", Params: []string{nginxInclude}}}, &website); err != nil {
return err
}
return nil
}
func updateMcpServer(mcpServer *model.McpServer) error {
env := handleEnv(mcpServer)
if err := gotenv.Write(env, path.Join(mcpServer.Dir, ".env")); err != nil {
return err
}
composePath := path.Join(constant.McpDir, mcpServer.Name, "docker-compose.yml")
_, _ = compose.Down(composePath)
if _, err := compose.Up(composePath); err != nil {
mcpServer.Status = constant.RuntimeError
mcpServer.Message = err.Error()
}
return mcpServerRepo.Save(mcpServer)
}
func handleEnv(mcpServer *model.McpServer) gotenv.Env {
env := make(gotenv.Env)
env["CONTAINER_NAME"] = mcpServer.ContainerName
env["COMMAND"] = mcpServer.Command
env["PANEL_APP_PORT_HTTP"] = strconv.Itoa(mcpServer.Port)
env["BASE_URL"] = mcpServer.BaseURL
env["SSE_PATH"] = mcpServer.SsePath
env["HOST_IP"] = mcpServer.HostIP
envStr, _ := gotenv.Marshal(env)
mcpServer.Env = envStr
return env
}
func handleCreateParams(mcpServer *model.McpServer, environments []request.Environment, volumes []request.Volume) error {
var composeContent []byte
if mcpServer.ID == 0 {
composeContent = mcp.DefaultMcpCompose
} else {
composeContent = []byte(mcpServer.DockerCompose)
}
composeMap := make(map[string]interface{})
if err := yaml.Unmarshal(composeContent, &composeMap); err != nil {
return err
}
services, serviceValid := composeMap["services"].(map[string]interface{})
if !serviceValid {
return buserr.New(constant.ErrFileParse)
}
serviceName := ""
serviceValue := make(map[string]interface{})
if mcpServer.ID > 0 {
serviceName = mcpServer.Name
serviceValue = services[serviceName].(map[string]interface{})
} else {
for name, service := range services {
serviceName = name
serviceValue = service.(map[string]interface{})
break
}
delete(services, serviceName)
}
delete(serviceValue, "environment")
if len(environments) > 0 {
envMap := make(map[string]string)
for _, env := range environments {
envMap[env.Key] = env.Value
}
serviceValue["environment"] = envMap
}
delete(serviceValue, "volumes")
if len(volumes) > 0 {
volumeList := make([]string, 0)
for _, volume := range volumes {
volumeList = append(volumeList, fmt.Sprintf("%s:%s", volume.Source, volume.Target))
}
serviceValue["volumes"] = volumeList
}
services[mcpServer.Name] = serviceValue
composeByte, err := yaml.Marshal(composeMap)
if err != nil {
return err
}
mcpServer.DockerCompose = string(composeByte)
return nil
}
func startMcp(mcpServer *model.McpServer) {
composePath := path.Join(constant.McpDir, mcpServer.Name, "docker-compose.yml")
if mcpServer.Status != constant.RuntimeNormal {
_, _ = compose.Down(composePath)
}
if out, err := compose.Up(composePath); err != nil {
mcpServer.Status = constant.RuntimeError
mcpServer.Message = out
} else {
mcpServer.Status = constant.RuntimeRunning
mcpServer.Message = ""
}
}
func syncMcpServerContainerStatus(mcpServer *model.McpServer) error {
containerNames := []string{mcpServer.ContainerName}
cli, err := docker.NewClient()
if err != nil {
return err
}
defer cli.Close()
containers, err := cli.ListContainersByName(containerNames)
if err != nil {
return err
}
if len(containers) == 0 {
mcpServer.Status = constant.RuntimeStopped
return mcpServerRepo.Save(mcpServer)
}
container := containers[0]
switch container.State {
case "exited":
mcpServer.Status = constant.RuntimeError
case "running":
mcpServer.Status = constant.RuntimeRunning
case "paused":
mcpServer.Status = constant.RuntimeStopped
default:
if mcpServer.Status != constant.RuntimeBuildIng {
mcpServer.Status = constant.RuntimeStopped
}
}
return mcpServerRepo.Save(mcpServer)
}
func GetWebsiteID() uint {
websiteID, _ := settingRepo.Get(settingRepo.WithByKey("MCP_WEBSITE_ID"))
if websiteID.Value == "" {
return 0
}
websiteIDUint, _ := strconv.ParseUint(websiteID.Value, 10, 64)
return uint(websiteIDUint)
}

View File

@@ -496,6 +496,10 @@ func (w WebsiteService) DeleteWebsite(req request.WebsiteDelete) error {
if err := websiteDomainRepo.DeleteBy(ctx, websiteDomainRepo.WithWebsiteId(req.ID)); err != nil {
return err
}
websiteID := GetWebsiteID()
if req.ID == websiteID {
_ = settingRepo.UpdateOrCreate("MCP_WEBSITE_ID", "0")
}
tx.Commit()
uploadDir := path.Join(global.CONF.System.BaseDir, fmt.Sprintf("1panel/uploads/website/%s", website.Alias))

View File

@@ -17,4 +17,5 @@ var (
RuntimeDir = path.Join(DataDir, "runtime")
RecycleBinDir = "/.1panel_clash"
SSLLogDir = path.Join(global.CONF.System.DataDir, "log", "ssl")
McpDir = path.Join(DataDir, "mcp")
)

View File

@@ -288,6 +288,7 @@ SystemMode: "mode: "
#ai-tool
ErrOpenrestyInstall: 'Please install Openresty first'
ErrSSL: "Certificate content is empty, please check the certificate!"
ErrSsePath: "SSE path is duplicated"
#mobile app
ErrVerifyToken: 'Token verification error, please reset and scan again.'

View File

@@ -285,6 +285,7 @@ SystemMode: "モード: "
#ai-tool
ErrOpenrestyInstall: 'まず Openresty をインストールしてください'
ErrSSL: "証明書の内容が空です。証明書を確認してください!"
ErrSsePath: "SSE パスが重複しています"
#mobile app

View File

@@ -288,6 +288,7 @@ SystemMode: "모드: "
#ai-tool
ErrOpenrestyInstall: '먼저 Openresty를 설치하세요'
ErrSSL: "인증서 내용이 비어 있습니다. 인증서를 확인하세요!"
ErrSsePath: "SSE 경로가 중복되었습니다"
#mobile app

View File

@@ -287,7 +287,7 @@ SystemMode: "Mod: "
#ai-tool
ErrOpenrestyInstall: 'Sila pasang Openresty terlebih dahulu'
ErrSSL: "Kandungan sijil kosong, sila periksa sijil!"
ErrSsePath: "Laluan SSE bertindan"
#mobile app
ErrVerifyToken: 'Ralat pengesahan token, sila tetapkan semula dan imbas semula.'

View File

@@ -285,6 +285,7 @@ SystemMode: "modo: "
#ai-tool
ErrOpenrestyInstall: 'Por favor, instale o Openresty primeiro'
ErrSSL: "O conteúdo do certificado está vazio, por favor, verifique o certificado!"
ErrSsePath: "Caminho SSE duplicado"
#mobile app
ErrVerifyToken: 'Erro de verificação do token, por favor, reinicie e escaneie novamente.'

View File

@@ -288,6 +288,7 @@ SystemMode: "режим: "
#ai-tool
ErrOpenrestyInstall: "Пожалуйста, установите Openresty сначала"
ErrSSL: "Содержимое сертификата пустое, пожалуйста, проверьте сертификат!"
ErrSsePath: "Путь SSE дублируется"
#mobile app
ErrVerifyToken: 'шибка проверки токена, пожалуйста, сбросьте и отсканируйте снова.'

View File

@@ -288,6 +288,7 @@ SystemMode: "模式: "
#ai-tool
ErrOpenrestyInstall: "請先安裝 Openresty"
ErrSSL: "證書內容為空,請檢查證書!"
ErrSsePath: "SSE 路徑重複"
#mobile app
ErrVerifyToken: '令牌驗證錯誤,請重置後再次掃碼'

View File

@@ -288,6 +288,7 @@ SystemMode: "模式: "
#ai-tool
ErrOpenrestyInstall: '请先安装 Openresty'
ErrSSL: "证书内容为空,请检查证书!"
ErrSsePath: "SSE 路径重复"
#mobile app
ErrVerifyToken: '令牌验证错误,请重置后再次扫码'

View File

@@ -23,8 +23,11 @@ func Init() {
constant.SSLLogDir = path.Join(global.CONF.System.DataDir, "log", "ssl")
constant.McpDir = path.Join(constant.DataDir, "mcp")
dirs := []string{constant.DataDir, constant.ResourceDir, constant.AppResourceDir, constant.AppInstallDir,
global.CONF.System.Backup, constant.RuntimeDir, constant.LocalAppResourceDir, constant.RemoteAppResourceDir, constant.SSLLogDir}
global.CONF.System.Backup, constant.RuntimeDir, constant.LocalAppResourceDir, constant.RemoteAppResourceDir,
constant.SSLLogDir, constant.McpDir}
fileOp := files.NewFileOp()
for _, dir := range dirs {

View File

@@ -106,6 +106,8 @@ func Init() {
migrations.AddAppMenu,
migrations.AddAppPanelName,
migrations.AddLicenseVerify,
migrations.AddMcpServer,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

View File

@@ -459,3 +459,13 @@ var AddLicenseVerify = &gormigrate.Migration{
return nil
},
}
var AddMcpServer = &gormigrate.Migration{
ID: "20250401-add-mcp-server",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.McpServer{}); err != nil {
return err
}
return nil
},
}

View File

@@ -24,5 +24,6 @@ func commonGroups() []CommonRouter {
&ProcessRouter{},
&WebsiteCARouter{},
&AIToolsRouter{},
&McpServerRouter{},
}
}

View File

@@ -26,5 +26,14 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/domain/bind", baseApi.BindDomain)
aiToolsRouter.POST("/domain/get", baseApi.GetBindDomain)
aiToolsRouter.POST("/domain/update", baseApi.UpdateBindDomain)
aiToolsRouter.POST("/mcp/search", baseApi.PageMcpServers)
aiToolsRouter.POST("/mcp/server", baseApi.CreateMcpServer)
aiToolsRouter.POST("/mcp/server/update", baseApi.UpdateMcpServer)
aiToolsRouter.POST("/mcp/server/del", baseApi.DeleteMcpServer)
aiToolsRouter.POST("/mcp/server/op", baseApi.OperateMcpServer)
aiToolsRouter.POST("/mcp/domain/bind", baseApi.BindMcpDomain)
aiToolsRouter.GET("/mcp/domain/get", baseApi.GetMcpBindDomain)
aiToolsRouter.POST("/mcp/domain/update", baseApi.UpdateMcpBindDomain)
}
}

21
backend/router/ro_mcp.go Normal file
View File

@@ -0,0 +1,21 @@
package router
import (
v1 "github.com/1Panel-dev/1Panel/backend/app/api/v1"
"github.com/1Panel-dev/1Panel/backend/middleware"
"github.com/gin-gonic/gin"
)
type McpServerRouter struct {
}
func (m *McpServerRouter) InitRouter(Router *gin.RouterGroup) {
mcpRouter := Router.Group("mcp")
mcpRouter.Use(middleware.JwtAuth()).Use(middleware.SessionAuth()).Use(middleware.PasswordExpired())
baseApi := v1.ApiGroupApp.BaseApi
{
mcpRouter.POST("/search", baseApi.PageMcpServers)
mcpRouter.POST("/server", baseApi.CreateMcpServer)
}
}

View File

@@ -46,7 +46,9 @@ func NewLocation(directive IDirective) *Location {
location.Host = params[1]
}
case "proxy_cache":
location.Cache = true
if params[0] != "off" {
location.Cache = true
}
case "if":
if params[0] == "(" && params[1] == "$uri" && params[2] == "~*" {
dirs := dir.GetBlock().GetDirectives()

View File

@@ -0,0 +1,19 @@
services:
mcp-server:
image: supercorp/supergateway:latest
container_name: ${CONTAINER_NAME}
restart: unless-stopped
ports:
- "${HOST_IP}:${PANEL_APP_PORT_HTTP}:${PANEL_APP_PORT_HTTP}"
command: [
"--stdio", "${COMMAND}",
"--port", "${PANEL_APP_PORT_HTTP}",
"--baseUrl", "${BASE_URL}",
"--ssePath", "${SSE_PATH}",
"--messagePath", "${SSE_PATH}/messages"
]
networks:
- 1panel-network
networks:
1panel-network:
external: true

8
cmd/server/mcp/mcp.go Normal file
View File

@@ -0,0 +1,8 @@
package mcp
import (
_ "embed"
)
//go:embed compose.yml
var DefaultMcpCompose []byte

View File

@@ -37,3 +37,6 @@ var DomainNotFoundHTML []byte
//go:embed stop.html
var StopHTML []byte
//go:embed sse.conf
var SSE []byte

View File

@@ -0,0 +1,7 @@
location ^~ /github {
proxy_pass http://127.0.0.1:8001/github;
proxy_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection '';
chunked_transfer_encoding off;
}

View File

@@ -109,4 +109,73 @@ export namespace AI {
websiteID?: number;
connUrl: string;
}
export interface Environment {
key: string;
value: string;
}
export interface Volume {
source: string;
target: string;
}
export interface McpServer {
id: number;
name: string;
status: string;
baseUrl: string;
ssePath: string;
command: string;
port: number;
message: string;
createdAt?: string;
containerName: string;
environments: Environment[];
volumes: Volume[];
dir?: string;
hostIP: string;
}
export interface McpServerSearch extends ReqPage {
name: string;
}
export interface McpServerDelete {
id: number;
}
export interface McpServerOperate {
id: number;
operate: string;
}
export interface McpBindDomain {
domain: string;
sslID: number;
ipList: string;
}
export interface McpDomainRes {
domain: string;
sslID: number;
acmeAccountID: number;
allowIPs: string[];
websiteID?: number;
connUrl: string;
}
export interface McpBindDomainUpdate {
websiteID: number;
sslID: number;
ipList: string;
}
export interface ImportMcpServer {
name: string;
command: string;
ssePath: string;
containerName: string;
environments: Environment[];
}
}

View File

@@ -39,3 +39,35 @@ export const getBindDomain = (req: AI.BindDomainReq) => {
export const updateBindDomain = (req: AI.BindDomain) => {
return http.post(`/ai/domain/update`, req);
};
export const pageMcpServer = (req: AI.McpServerSearch) => {
return http.post<ResPage<AI.McpServer>>(`/ai/mcp/search`, req);
};
export const createMcpServer = (req: AI.McpServer) => {
return http.post(`/ai/mcp/server`, req);
};
export const updateMcpServer = (req: AI.McpServer) => {
return http.post(`/ai/mcp/server/update`, req);
};
export const deleteMcpServer = (req: AI.McpServerDelete) => {
return http.post(`/ai/mcp/server/del`, req);
};
export const operateMcpServer = (req: AI.McpServerOperate) => {
return http.post(`/ai/mcp/server/op`, req);
};
export const bindMcpDomain = (req: AI.McpBindDomain) => {
return http.post(`/ai/mcp/domain/bind`, req);
};
export const getMcpDomain = () => {
return http.get<AI.McpDomainRes>(`/ai/mcp/domain/get`);
};
export const updateMcpDomain = (req: AI.McpBindDomainUpdate) => {
return http.post(`/ai/mcp/domain/update`, req);
};

View File

@@ -2601,6 +2601,27 @@ const message = {
proxyHelper6: 'To disable proxy configuration, you can delete it from the website list.',
whiteListHelper: 'Restrict access to only IPs in the whitelist',
},
mcp: {
server: 'MCP Server',
create: 'Add Server',
edit: 'Edit Server',
commandHelper: 'For example: npx -y {0}',
baseUrl: 'External Access Path',
baseUrlHelper: 'For example: https://127.0.0.1:8080',
ssePath: 'SSE Path',
ssePathHelper: 'For example: /sse, note not to duplicate with other servers',
environment: 'Environment Variables',
envKey: 'Variable Name',
envValue: 'Variable Value',
externalUrl: 'External Connection Address',
operatorHelper: 'Will perform {1} operation on {0}, continue?',
domain: 'Default Access Address',
domainHelper: 'For example: 192.168.1.1 or example.com',
bindDomain: 'Bind Website',
commandPlaceHolder: 'Currently only supports commands started with npx and binary',
importMcpJson: 'Import MCP Server Configuration',
importMcpJsonError: 'mcpServers structure is incorrect',
},
};
export default {

View File

@@ -2570,6 +2570,27 @@ const message = {
proxyHelper6: 'プロキシ設定を無効にするにはウェブサイトリストから削除できます',
whiteListHelper: 'ホワイトリスト内のIPのみアクセスを許可する',
},
mcp: {
server: 'MCP サーバー',
create: 'サーバーを追加',
edit: 'サーバーを編集',
commandHelper: ': npx -y {0}',
baseUrl: '外部アクセスパス',
baseUrlHelper: ': https://127.0.0.1:8080',
ssePath: 'SSE パス',
ssePathHelper: ': /sse, 他のサーバーと重複しないように注意してください',
environment: '環境変数',
envKey: '変数名',
envValue: '変数値',
externalUrl: '外部接続アドレス',
operatorHelper: '{0} {1} 操作を実行します続行しますか',
domain: 'デフォルトアクセスアドレス',
domainHelper: ': 192.168.1.1 または example.com',
bindDomain: 'ウェブサイトをバインド',
commandPlaceHolder: '現在npx およびバイナリ起動のコマンドのみをサポートしています',
importMcpJson: 'MCP サーバー設定をインポート',
importMcpJsonError: 'mcpServers 構造が正しくありません',
},
};
export default {
...fit2cloudJaLocale,

View File

@@ -2530,6 +2530,27 @@ const message = {
proxyHelper6: '프록시 구성을 비활성화하려면 웹사이트 목록에서 삭제할 있습니다.',
whiteListHelper: '화이트리스트에 있는 IP만 접근 허용',
},
mcp: {
server: 'MCP サーバー',
create: 'サーバーを追加',
edit: 'サーバーを編集',
commandHelper: ': npx -y {0}',
baseUrl: '外部アクセスパス',
baseUrlHelper: ': https://127.0.0.1:8080',
ssePath: 'SSE パス',
ssePathHelper: ': /sse, 他のサーバーと重複しないように注意してください',
environment: '環境変数',
envKey: '変数名',
envValue: '変数値',
externalUrl: '外部接続アドレス',
operatorHelper: '{0} {1} 操作を実行します続行しますか',
domain: 'デフォルトアクセスアドレス',
domainHelper: ': 192.168.1.1 または example.com',
bindDomain: 'ウェブサイトをバインド',
commandPlaceHolder: '現在npx およびバイナリ起動のコマンドのみをサポートしています',
importMcpJson: 'MCP サーバー設定をインポート',
importMcpJsonError: 'mcpServers 構造が正しくありません',
},
};
export default {

View File

@@ -2629,6 +2629,27 @@ const message = {
proxyHelper6: 'Untuk melumpuhkan konfigurasi proksi, anda boleh memadamnya dari senarai laman web.',
whiteListHelper: 'Hadkan akses kepada hanya IP dalam senarai putih',
},
mcp: {
server: 'Pelayan MCP',
create: 'Tambah Pelayan',
edit: 'Edit Pelayan',
commandHelper: 'Contoh: npx -y {0}',
baseUrl: 'Laluan Akses Luar',
baseUrlHelper: 'Contoh: https://127.0.0.1:8080',
ssePath: 'Laluan SSE',
ssePathHelper: 'Contoh: /sse, berhati-hati jangan bertindan dengan pelayan lain',
environment: 'Pemboleh Ubah Persekitaran',
envKey: 'Nama Pemboleh Ubah',
envValue: 'Nilai Pemboleh Ubah',
externalUrl: 'Alamat Sambungan Luar',
operatorHelper: 'Akan melakukan operasi {1} pada {0}, teruskan?',
domain: 'Alamat Akses Lalai',
domainHelper: 'Contoh: 192.168.1.1 atau example.com',
bindDomain: 'Sematkan Laman Web',
commandPlaceHolder: 'Kini hanya menyokong perintah yang bermula dengan npx dan binari',
importMcpJson: 'Import Konfigurasi Pelayan MCP',
importMcpJsonError: 'Struktur mcpServers tidak betul',
},
};
export default {

View File

@@ -2626,6 +2626,27 @@ const message = {
proxyHelper6: 'Para desativar a configuração de proxy, você pode excluí-la da lista de sites.',
whiteListHelper: 'Restringir o acesso apenas aos IPs na lista branca',
},
mcp: {
server: 'Servidor MCP',
create: 'Adicionar Servidor',
edit: 'Editar Servidor',
commandHelper: 'Por exemplo: npx -y {0}',
baseUrl: 'Caminho de Acesso Externo',
baseUrlHelper: 'Por exemplo: https://127.0.0.1:8080',
ssePath: 'Caminho SSE',
ssePathHelper: 'Por exemplo: /sse, tome cuidado para não duplicar com outros servidores',
environment: 'Variáveis de Ambiente',
envKey: 'Nome da Variável',
envValue: 'Valor da Variável',
externalUrl: 'Endereço de Conexão Externo',
operatorHelper: 'Será realizada a operação {1} no {0}, continuar?',
domain: 'Endereço de Acesso Padrão',
domainHelper: 'Por exemplo: 192.168.1.1 ou example.com',
bindDomain: 'Vincular Site',
commandPlaceHolder: 'Atualmente, apenas comandos iniciados com npx e binário são suportados',
importMcpJson: 'Importar Configuração do Servidor MCP',
importMcpJsonError: 'A estrutura mcpServers está incorreta',
},
};
export default {

View File

@@ -2623,6 +2623,28 @@ const message = {
proxyHelper6: 'Чтобы отключить настройку прокси, вы можете удалить её из списка сайтов.',
whiteListHelper: 'Ограничить доступ только для IP-адресов из белого списка',
},
mcp: {
server: 'Сервер MCP',
create: 'Добавить сервер',
edit: 'Редактировать сервер',
commandHelper: 'Например: npx -y {0}',
baseUrl: 'Внешний путь доступа',
baseUrlHelper: 'Например: https://127.0.0.1:8080',
ssePath: 'Путь SSE',
ssePathHelper: 'Например: /sse, будьте осторожны, чтобы не дублировать с другими серверами',
environment: 'Переменные среды',
envKey: 'Имя переменной',
envValue: 'Значение переменной',
externalUrl: 'Внешний адрес подключения',
operatorHelper: 'Будет выполнена операция {1} на {0}, продолжить?',
domain: 'Адрес доступа по умолчанию',
domainHelper: 'Например: 192.168.1.1 или example.com',
bindDomain: 'Привязать сайт',
commandPlaceHolder:
'В настоящее время поддерживаются только команды, запускаемые с помощью npx и бинарных файлов',
importMcpJson: 'Импортировать конфигурацию сервера MCP',
importMcpJsonError: 'Структура mcpServers некорректна',
},
};
export default {

View File

@@ -2436,6 +2436,27 @@ const message = {
proxyHelper6: '如需關閉代理配置可以在網站列表中刪除',
whiteListHelper: '限制僅白名單中的 IP 可瀏覽',
},
mcp: {
server: 'MCP 伺服器',
create: '添加伺服器',
edit: '編輯伺服器',
commandHelper: '例如npx -y {0}',
baseUrl: '外部訪問路徑',
baseUrlHelper: '例如https://127.0.0.1:8080',
ssePath: 'SSE 路徑',
ssePathHelper: '例如/sse,注意不要與其他伺服器重複',
environment: '環境變數',
envKey: '變數名',
envValue: '變數值',
externalUrl: '外部連接地址',
operatorHelper: '將對 {0} 進行 {1} 操作是否繼續',
domain: '默認訪問地址',
domainHelper: '例如192.168.1.1 或者 example.com',
bindDomain: '綁定網站',
commandPlaceHolder: '當前僅支持 npx 和二進制啟動的命令',
importMcpJson: '導入 MCP 伺服器配置',
importMcpJsonError: 'mcpServers 結構不正確',
},
};
export default {
...fit2cloudTwLocale,

View File

@@ -2438,6 +2438,27 @@ const message = {
proxyHelper6: '如需关闭代理配置可以在网站列表中删除',
whiteListHelper: '限制仅白名单中的 IP 可访问',
},
mcp: {
server: 'MCP 服务器',
create: '添加服务器',
edit: '编辑服务器',
commandHelper: '例如npx -y {0}',
baseUrl: '外部访问路径',
baseUrlHelper: '例如https://127.0.0.1:8080',
ssePath: 'SSE 路径',
ssePathHelper: '例如/sse,注意不要与其他服务器重复',
environment: '环境变量',
envKey: '变量名',
envValue: '变量值',
externalUrl: '外部连接地址',
operatorHelper: '将对 {0} 进行 {1} 操作是否继续',
domain: '默认访问地址',
domainHelper: '例如192.168.1.1 或者 example.com',
bindDomain: '绑定网站',
commandPlaceHolder: '当前仅支持 npx 和二进制启动的命令',
importMcpJson: '导入 MCP 服务器配置',
importMcpJsonError: 'mcpServers 结构不正确',
},
};
export default {
...fit2cloudZhLocale,

View File

@@ -28,6 +28,15 @@ const databaseRouter = {
requiresAuth: true,
},
},
{
path: '/ai/mcp',
name: 'MCPServer',
component: () => import('@/views/ai/mcp/server/index.vue'),
meta: {
title: 'MCP',
requiresAuth: true,
},
},
],
};

View File

@@ -0,0 +1,19 @@
<template>
<div>
<RouterButton :buttons="buttons" />
<LayoutContent>
<router-view></router-view>
</LayoutContent>
</div>
</template>
<script lang="ts" setup>
import i18n from '@/lang';
const buttons = [
{
label: i18n.global.t('container.server'),
path: '/ai/mcp/servers',
},
];
</script>

View File

@@ -0,0 +1,253 @@
<template>
<el-drawer
v-model="open"
:destroy-on-close="true"
:close-on-click-modal="false"
:close-on-press-escape="false"
size="50%"
>
<template #header>
<DrawerHeader :header="$t('mcp.bindDomain')" :back="handleClose" />
</template>
<div v-loading="loading">
<el-form ref="formRef" label-position="top" @submit.prevent :model="req" :rules="rules">
<el-row type="flex" justify="center">
<el-col :span="22">
<el-alert class="common-prompt" :closable="false" type="warning">
<template #default>
<ul>
<li>{{ $t('aitool.proxyHelper1') }}</li>
<li>{{ $t('aitool.proxyHelper2') }}</li>
<li>{{ $t('aitool.proxyHelper3') }}</li>
</ul>
</template>
</el-alert>
<el-form-item :label="$t('website.domain')" prop="domain">
<el-input v-model.trim="req.domain" :disabled="operate === 'update'" />
<span class="input-help">
{{ $t('aitool.proxyHelper4') }}
</span>
<span class="input-help">
{{ $t('aitool.proxyHelper6') }}
<el-link
class="pageRoute"
icon="Position"
@click="toWebsite(req.websiteID)"
type="primary"
>
{{ $t('firewall.quickJump') }}
</el-link>
</span>
</el-form-item>
<el-form-item :label="$t('xpack.waf.whiteList') + ' IP'" prop="ipList">
<el-input
:rows="3"
type="textarea"
clearable
v-model="req.ipList"
:placeholder="$t('xpack.waf.ipGroupHelper')"
/>
<span class="input-help">
{{ $t('aitool.whiteListHelper') }}
</span>
</el-form-item>
<el-form-item>
<el-checkbox v-model="req.enableSSL" @change="changeSSL">
{{ $t('website.enable') + ' ' + 'HTTPS' }}
</el-checkbox>
</el-form-item>
<el-form-item
:label="$t('website.acmeAccountManage')"
prop="acmeAccountID"
v-if="req.enableSSL"
>
<el-select
v-model="req.acmeAccountID"
:placeholder="$t('website.selectAcme')"
@change="listSSL"
>
<el-option :key="0" :label="$t('website.imported')" :value="0"></el-option>
<el-option
v-for="(acme, index) in acmeAccounts"
:key="index"
:label="acme.email"
:value="acme.id"
>
<span>
{{ acme.email }}
<el-tag class="ml-5">{{ getAccountName(acme.type) }}</el-tag>
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item :label="$t('website.ssl')" prop="sslID" v-if="req.enableSSL">
<el-select
v-model="req.sslID"
:placeholder="$t('website.selectSSL')"
@change="changeSSl(req.sslID)"
>
<el-option
v-for="(ssl, index) in ssls"
:key="index"
:label="ssl.primaryDomain"
:value="ssl.id"
></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<template #footer>
<el-button @click="handleClose">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="onSubmit(formRef)">
{{ $t('commons.button.add') }}
</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { Website } from '@/api/interface/website';
import { ListSSL, SearchAcmeAccount } from '@/api/modules/website';
import { Rules } from '@/global/form-rules';
import { FormInstance, FormRules } from 'element-plus';
import { reactive, ref } from 'vue';
import { getAccountName } from '@/utils/util';
import { bindMcpDomain, getMcpDomain, updateMcpDomain } from '@/api/modules/ai';
import { MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
const open = ref(false);
const operate = ref('create');
const loading = ref(false);
const ssls = ref([]);
const websiteSSL = ref<Website.SSL>();
const acmeAccounts = ref();
const formRef = ref();
const req = ref({
domain: '',
sslID: undefined,
ipList: '',
acmeAccountID: 0,
enableSSL: false,
allowIPs: [],
websiteID: 0,
});
const rules = reactive<FormRules>({
domain: [Rules.domainWithPort],
sslID: [Rules.requiredSelectBusiness],
});
const emit = defineEmits(['close']);
const handleClose = () => {
emit('close');
open.value = false;
};
const acceptParams = () => {
search();
open.value = true;
};
const changeSSl = (sslid: number) => {
const res = ssls.value.filter((element: Website.SSL) => {
return element.id == sslid;
});
websiteSSL.value = res[0];
};
const changeSSL = () => {
if (!req.value.enableSSL) {
req.value.sslID = undefined;
} else {
listAcmeAccount();
}
};
const listSSL = () => {
const sslReq = {
acmeAccountID: String(req.value.acmeAccountID),
};
ListSSL(sslReq).then((res) => {
ssls.value = res.data || [];
if (ssls.value.length > 0) {
let exist = false;
for (const ssl of ssls.value) {
if (ssl.id === req.value.sslID) {
exist = true;
break;
}
}
if (!exist) {
req.value.sslID = ssls.value[0].id;
}
changeSSl(req.value.sslID);
} else {
req.value.sslID = undefined;
}
});
};
const listAcmeAccount = () => {
SearchAcmeAccount({ page: 1, pageSize: 100 }).then((res) => {
acmeAccounts.value = res.data.items || [];
listSSL();
});
};
const onSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate(async (valid) => {
if (!valid) return;
if (operate.value === 'update') {
await updateMcpDomain(req.value);
} else {
await bindMcpDomain(req.value);
}
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
handleClose();
});
};
const search = async () => {
try {
const res = await getMcpDomain();
if (res.data.websiteID > 0) {
operate.value = 'update';
req.value.domain = res.data.domain;
req.value.websiteID = res.data.websiteID;
if (res.data.allowIPs && res.data.allowIPs.length > 0) {
req.value.ipList = res.data.allowIPs.join('\n');
}
if (res.data.sslID > 0) {
req.value.enableSSL = true;
req.value.sslID = res.data.sslID;
req.value.acmeAccountID = res.data.acmeAccountID;
listAcmeAccount();
}
}
} catch (e) {}
};
const toWebsite = (websiteID: number) => {
if (websiteID != undefined && websiteID > 0) {
window.location.href = `/websites/${websiteID}/config/basic`;
} else {
window.location.href = '/websites';
}
};
defineExpose({
acceptParams,
});
</script>
<style lang="scss" scoped>
.pageRoute {
font-size: 12px;
margin-left: 5px;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<el-dialog v-model="submitVisible" :destroy-on-close="true" :close-on-click-modal="false" width="40%">
<template #header>
{{ $t('mcp.importMcpJson') }}
</template>
<div>
<el-input
v-model="mcpServerJson"
type="textarea"
:rows="15"
placeholder='{
"mcpServers": {
"postgres": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://localhost/mydb"
]
}
}
}'
></el-input>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="onCancel">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="onConfirm">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import i18n from '@/lang';
import { MsgError } from '@/utils/message';
import { ref } from 'vue';
const submitVisible = ref(false);
const mcpServerJson = ref();
const mcpServerConfig = ref();
const acceptParams = (): void => {
mcpServerJson.value = '';
submitVisible.value = true;
};
const emit = defineEmits(['confirm', 'cancel']);
const onConfirm = async () => {
try {
const data = JSON.parse(mcpServerJson.value);
if (!data.mcpServers || typeof data.mcpServers !== 'object') {
throw new Error(i18n.global.t('mcp.importMcpJsonError'));
}
mcpServerConfig.value = Object.entries(data.mcpServers).map(([name, config]: any) => ({
name,
command: [config.command, ...config.args].join(' '),
environments: data.env ? Object.entries(data.env).map(([key, value]) => ({ key, value })) : [],
ssePath: '/' + name,
containerName: name,
}));
} catch (error) {
MsgError(error);
return;
}
emit('confirm', mcpServerConfig.value);
submitVisible.value = false;
};
const onCancel = async () => {
emit('cancel');
submitVisible.value = false;
};
defineExpose({
acceptParams,
});
</script>

View File

@@ -0,0 +1,244 @@
<template>
<div>
<RouterMenu />
<LayoutContent :title="$t('container.server')" v-loading="loading">
<template #toolbar>
<div class="flex flex-wrap gap-3">
<el-button type="primary" @click="openCreate">
{{ $t('mcp.create') }}
</el-button>
<el-button type="primary" plain @click="openDomain">
{{ $t('mcp.bindDomain') }}
</el-button>
</div>
</template>
<template #main>
<ComplexTable :pagination-config="paginationConfig" :data="items" @search="search()">
<el-table-column
:label="$t('commons.table.name')"
fix
prop="name"
min-width="120px"
show-overflow-tooltip
>
<template #default="{ row }">
<el-text type="primary" class="cursor-pointer" @click="openDetail(row)">
{{ row.name }}
</el-text>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.port')" prop="port" max-width="50px">
<template #default="{ row }">
{{ row.port }}
</template>
</el-table-column>
<el-table-column :label="$t('mcp.externalUrl')" prop="baseUrl" min-width="200px">
<template #default="{ row }">
{{ row.baseUrl + row.ssePath }}
<CopyButton :content="row.baseUrl + row.ssePath" type="icon" />
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" prop="status" max-width="50px">
<template #default="{ row }">
<el-popover
v-if="row.status === 'error'"
placement="bottom"
:width="400"
trigger="hover"
:content="row.message"
popper-class="max-h-[300px] overflow-auto"
>
<template #reference>
<Status :key="row.status" :status="row.status"></Status>
</template>
</el-popover>
<div v-else>
<Status :key="row.status" :status="row.status"></Status>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('commons.button.log')" prop="path" min-width="90px">
<template #default="{ row }">
<el-button
@click="openLog(row)"
link
type="primary"
:disabled="row.status !== 'running' && row.status !== 'error'"
>
{{ $t('website.check') }}
</el-button>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
:label="$t('commons.table.date')"
:formatter="dateFormat"
show-overflow-tooltip
min-width="120"
fix
/>
<fu-table-operations
:ellipsis="mobile ? 0 : 10"
:min-width="mobile ? 'auto' : 400"
:buttons="buttons"
:label="$t('commons.table.operate')"
fixed="right"
fix
/>
</ComplexTable>
</template>
</LayoutContent>
<McpServerOperate ref="createRef" @close="searchWithTimeOut" />
<OpDialog ref="opRef" @search="search" />
<ComposeLogs ref="composeLogRef" />
<BindDomain ref="bindDomainRef" @close="searchWithTimeOut" />
</div>
</template>
<script lang="ts" setup>
import { AI } from '@/api/interface/ai';
import { deleteMcpServer, operateMcpServer, pageMcpServer } from '@/api/modules/ai';
import RouterMenu from '@/views/ai/mcp/index.vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { dateFormat } from '@/utils/util';
import McpServerOperate from './operate/index.vue';
import ComposeLogs from '@/components/compose-log/index.vue';
import { GlobalStore } from '@/store';
import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message';
import BindDomain from './bind/index.vue';
const globalStore = GlobalStore();
const loading = ref(false);
const createRef = ref();
const opRef = ref();
const composeLogRef = ref();
const bindDomainRef = ref();
const items = ref<AI.McpServer[]>([]);
const paginationConfig = reactive({
cacheSizeKey: 'mcp-server-page-size',
currentPage: 1,
pageSize: 10,
total: 0,
});
const mobile = computed(() => {
return globalStore.isMobile();
});
const buttons = [
{
label: i18n.global.t('commons.button.edit'),
click: (row: AI.McpServer) => {
openDetail(row);
},
},
{
label: i18n.global.t('commons.button.start'),
click: (row: AI.McpServer) => {
opServer(row, 'start');
},
disabled: (row: AI.McpServer) => {
return row.status === 'running';
},
},
{
label: i18n.global.t('commons.button.stop'),
click: (row: AI.McpServer) => {
opServer(row, 'stop');
},
disabled: (row: AI.McpServer) => {
return row.status === 'stopped';
},
},
{
label: i18n.global.t('commons.button.restart'),
click: (row: AI.McpServer) => {
opServer(row, 'restart');
},
},
{
label: i18n.global.t('commons.button.delete'),
click: (row: AI.McpServer) => {
deleteServer(row);
},
},
];
const searchWithTimeOut = () => {
search();
setTimeout(() => {
search();
}, 1000);
};
const search = () => {
loading.value = true;
pageMcpServer({
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
name: '',
}).then((res) => {
items.value = res.data.items;
paginationConfig.total = res.data.total;
loading.value = false;
});
};
const openDetail = (row: AI.McpServer) => {
createRef.value.acceptParams(row);
};
const openCreate = () => {
const maxPort = Math.max(...items.value.map((item) => item.port));
createRef.value.acceptParams({ port: maxPort + 1 });
};
const openLog = (row: AI.McpServer) => {
composeLogRef.value.acceptParams({ compose: row.dir + '/docker-compose.yml', resource: row.name });
};
const deleteServer = async (row: AI.McpServer) => {
try {
opRef.value.acceptParams({
title: i18n.global.t('commons.button.delete'),
names: [row.name],
msg: i18n.global.t('commons.msg.operatorHelper', [
i18n.global.t('mcp.server'),
i18n.global.t('commons.button.delete'),
]),
api: deleteMcpServer,
params: { id: row.id },
});
} catch (error) {
MsgError(error);
}
};
const opServer = async (row: AI.McpServer, operate: string) => {
ElMessageBox.confirm(
i18n.global.t('mcp.operatorHelper', [i18n.global.t('mcp.server'), i18n.global.t('commons.button.' + operate)]),
i18n.global.t('commons.button.' + operate),
{
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
},
).then(async () => {
try {
await operateMcpServer({ id: row.id, operate: operate });
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
search();
} catch (error) {
MsgError(error);
}
});
};
const openDomain = () => {
bindDomainRef.value.acceptParams();
};
onMounted(() => {
search();
});
</script>

View File

@@ -0,0 +1,251 @@
<template>
<el-drawer
:destroy-on-close="true"
:close-on-click-modal="false"
:close-on-press-escape="false"
v-model="open"
size="50%"
>
<template #header>
<DrawerHeader
:header="$t('mcp.' + mode)"
:hideResource="mode == 'create'"
:resource="mcpServer.name"
:back="handleClose"
/>
</template>
<el-row v-loading="loading">
<el-col :span="22" :offset="1">
<el-form ref="mcpServerForm" label-position="top" :model="mcpServer" label-width="125px" :rules="rules">
<el-form-item>
<el-button @click="importRef.acceptParams()" type="primary" plain>
{{ $t('mcp.importMcpJson') }}
</el-button>
</el-form-item>
<el-form-item :label="$t('commons.table.name')" prop="name">
<el-input v-model="mcpServer.name" :disabled="mode == 'edit'" />
</el-form-item>
<el-form-item :label="$t('runtime.runScript')" prop="command">
<el-input
v-model="mcpServer.command"
type="textarea"
:rows="3"
:placeholder="$t('mcp.commandPlaceHolder')"
></el-input>
<span class="input-help">
{{ $t('mcp.commandHelper', ['@modelcontextprotocol/server-github']) }}
</span>
</el-form-item>
<div>
<el-text>{{ $t('mcp.environment') }}</el-text>
<div class="mt-1">
<el-row :gutter="20" v-for="(env, index) in mcpServer.environments" :key="index">
<el-col :span="8">
<el-form-item :prop="`environments.${index}.key`" :rules="rules.key">
<el-input v-model="env.key" :placeholder="$t('mcp.envKey')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :prop="`environments.${index}.value`" :rules="rules.value">
<el-input v-model="env.value" :placeholder="$t('mcp.envValue')" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item>
<el-button type="primary" @click="removeEnv(index)" link class="mt-1">
{{ $t('commons.button.delete') }}
</el-button>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="4">
<el-button class="mb-2" @click="addEnv">{{ $t('commons.button.add') }}</el-button>
</el-col>
</el-row>
</div>
</div>
<Volumes :volumes="mcpServer.volumes" />
<el-row :gutter="20">
<el-col :span="6">
<el-form-item :label="$t('commons.table.port')" prop="port">
<el-input v-model.number="mcpServer.port" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="$t('app.allowPort')" prop="hostIP">
<el-switch
v-model="mcpServer.hostIP"
:active-value="'0.0.0.0'"
:inactive-value="'127.0.0.1'"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('mcp.baseUrl')" prop="baseUrl">
<el-input v-model.trim="mcpServer.baseUrl"></el-input>
<span class="input-help">
{{ $t('mcp.baseUrlHelper') }}
</span>
</el-form-item>
<el-form-item :label="$t('app.containerName')" prop="containerName">
<el-input v-model.trim="mcpServer.containerName"></el-input>
</el-form-item>
<el-form-item :label="$t('mcp.ssePath')" prop="ssePath">
<el-input v-model.trim="mcpServer.ssePath"></el-input>
<span class="input-help">
{{ $t('mcp.ssePathHelper') }}
</span>
</el-form-item>
</el-form>
</el-col>
</el-row>
<template #footer>
<span>
<el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" @click="submit(mcpServerForm)" :disabled="loading">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</el-drawer>
<Import ref="importRef" @confirm="getImport" />
</template>
<script lang="ts" setup>
import { AI } from '@/api/interface/ai';
import { createMcpServer, getMcpDomain, updateMcpServer } from '@/api/modules/ai';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message';
import { FormInstance } from 'element-plus';
import { ref, watch } from 'vue';
import Volumes from '../volume/index.vue';
import Import from '../import/index.vue';
const open = ref(false);
const mode = ref('create');
const loading = ref(false);
const mcpServerForm = ref();
const importRef = ref();
const newMcpServer = () => {
return {
id: 0,
name: '',
port: 8000,
status: '',
message: '',
baseUrl: '',
ssePath: '',
command: '',
containerName: '',
environments: [],
volumes: [],
hostIP: '127.0.0.1',
};
};
const em = defineEmits(['close']);
const mcpServer = ref(newMcpServer());
const rules = ref({
name: [Rules.requiredInput, Rules.appName],
command: [Rules.requiredInput],
port: [Rules.requiredInput, Rules.port],
containerName: [Rules.requiredInput],
baseUrl: [Rules.requiredInput],
ssePath: [Rules.requiredInput],
key: [Rules.requiredInput],
value: [Rules.requiredInput],
});
const acceptParams = async (params: AI.McpServer) => {
mode.value = params.id ? 'edit' : 'create';
if (mode.value == 'edit') {
mcpServer.value = params;
if (!mcpServer.value.environments) {
mcpServer.value.environments = [];
}
if (!mcpServer.value.volumes) {
mcpServer.value.volumes = [];
}
} else {
mcpServer.value = newMcpServer();
if (params.port) {
mcpServer.value.port = params.port;
}
try {
const res = await getMcpDomain();
mcpServer.value.baseUrl = res.data.connUrl;
} catch (error) {
MsgError(error);
}
}
open.value = true;
};
watch(
() => mcpServer.value.name,
(newVal) => {
if (newVal && mode.value == 'create') {
mcpServer.value.containerName = newVal;
mcpServer.value.ssePath = '/' + newVal;
}
},
{ deep: true },
);
const addEnv = () => {
mcpServer.value.environments.push({
key: '',
value: '',
});
};
const removeEnv = (index: number) => {
mcpServer.value.environments.splice(index, 1);
};
const handleClose = () => {
open.value = false;
em('close', false);
};
const getImport = async (data: AI.ImportMcpServer[]) => {
if (!data) {
return;
}
const importServer = data[0];
mcpServer.value.name = importServer.name;
mcpServer.value.containerName = importServer.containerName;
mcpServer.value.ssePath = importServer.ssePath;
mcpServer.value.command = importServer.command;
mcpServer.value.environments = importServer.environments || [];
};
const submit = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
await formEl.validate(async (valid) => {
if (!valid) {
return;
}
try {
loading.value = true;
if (mode.value == 'create') {
await createMcpServer(mcpServer.value);
MsgSuccess(i18n.global.t('commons.msg.createSuccess'));
} else {
await updateMcpServer(mcpServer.value);
MsgSuccess(i18n.global.t('commons.msg.updateSuccess'));
}
handleClose();
} catch (error) {
MsgError(error);
} finally {
loading.value = false;
}
});
};
defineExpose({
acceptParams,
});
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="mt-2">
<el-text>{{ $t('container.mount') }}</el-text>
<div class="mt-2">
<el-row :gutter="20" v-for="(volume, index) in volumes" :key="index">
<el-col :span="8">
<el-form-item :prop="`volumes.${index}.source`" :rules="rules.value">
<el-input v-model="volume.source" :placeholder="$t('container.hostOption')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :prop="`volumes.${index}.target`" :rules="rules.value">
<el-input v-model="volume.target" :placeholder="$t('container.containerDir')" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item>
<el-button type="primary" @click="removeEnv(index)" link class="mt-1">
{{ $t('commons.button.delete') }}
</el-button>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="4">
<el-button @click="addEnv">{{ $t('commons.button.add') }}</el-button>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, reactive } from 'vue';
import { FormRules } from 'element-plus';
import { Rules } from '@/global/form-rules';
import { AI } from '@/api/interface/ai';
const props = defineProps({
volumes: {
type: Array<AI.Volume>,
required: true,
},
});
const rules = reactive<FormRules>({
value: [Rules.requiredInput],
});
const addEnv = () => {
props.volumes.push({
source: '',
target: '',
});
};
const removeEnv = (index: number) => {
props.volumes.splice(index, 1);
};
</script>

5
go.mod
View File

@@ -1,6 +1,8 @@
module github.com/1Panel-dev/1Panel
go 1.22.3
go 1.23
toolchain go1.23.7
require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
@@ -170,6 +172,7 @@ require (
github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mark3labs/mcp-go v0.14.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/miekg/dns v1.1.62 // indirect

2
go.sum
View File

@@ -658,6 +658,8 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.14.1 h1:NsieyFbuWQaeZSWSHPvJ5TwJdQwu+1jmivAIVljeouY=
github.com/mark3labs/mcp-go v0.14.1/go.mod h1:xBB350hekQsJAK7gJAii8bcEoWemboLm2mRm5/+KBaU=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=