diff --git a/agent/app/api/v2/app.go b/agent/app/api/v2/app.go index cc2aa54fc..7686155ad 100644 --- a/agent/app/api/v2/app.go +++ b/agent/app/api/v2/app.go @@ -8,6 +8,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/utils/appicon" + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/gin-gonic/gin" ) @@ -160,10 +161,25 @@ func (b *BaseApi) GetAppDetailByID(c *gin.Context) { // @Router /apps/install [post] // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"安装应用 [name]","formatEN":"Install app [name]"} func (b *BaseApi) InstallApp(c *gin.Context) { + mcpRequest, trustedMCP := mcpapi.Check(c.Request) + if mcpRequest && !trustedMCP { + c.AbortWithStatus(http.StatusForbidden) + return + } var req request.AppInstallCreate if err := helper.CheckBindAndValidate(&req, c); err != nil { return } + if mcpRequest { + req.MCPPreserveWebsiteDir = true + if value, exists := req.Params["WEBSITE_DIR"]; exists { + directory, ok := value.(string) + if !ok || directory == "" || directory != settingService.GetWebsiteDir() { + c.AbortWithStatus(http.StatusForbidden) + return + } + } + } install, err := appService.Install(req, true) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/api/v2/app_install.go b/agent/app/api/v2/app_install.go index 1ced903ce..b165a88cf 100644 --- a/agent/app/api/v2/app_install.go +++ b/agent/app/api/v2/app_install.go @@ -1,7 +1,11 @@ package v2 import ( + "net/http" + "github.com/1Panel-dev/1Panel/agent/app/dto/request" + "github.com/1Panel-dev/1Panel/agent/app/service" + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/dto" @@ -336,11 +340,25 @@ func (b *BaseApi) UpdateAppConfig(c *gin.Context) { // @Security Timestamp // @Router /apps/installed/info/:appInstallId [get] func (b *BaseApi) GetAppInstallInfo(c *gin.Context) { + mcpRequest, trustedMCP := mcpapi.Check(c.Request) + if mcpRequest && !trustedMCP { + c.AbortWithStatus(http.StatusForbidden) + return + } appInstallId, err := helper.GetIntParamByKey(c, "appInstallId") if err != nil { helper.BadRequest(c, err) return } + if mcpRequest { + info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), appInstallId) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, info) + return + } info, err := appInstallService.GetAppInstallInfo(appInstallId) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/api/v2/database.go b/agent/app/api/v2/database.go index 6e16478c8..15bcb8ebc 100644 --- a/agent/app/api/v2/database.go +++ b/agent/app/api/v2/database.go @@ -2,9 +2,12 @@ package v2 import ( "encoding/base64" + "net/http" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/service" + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/gin-gonic/gin" ) @@ -98,11 +101,25 @@ func (b *BaseApi) SearchDatabase(c *gin.Context) { // @Param type path string true "type" // @Router /databases/db/list/:type [get] func (b *BaseApi) ListDatabase(c *gin.Context) { + mcpRequest, trustedMCP := mcpapi.Check(c.Request) + if mcpRequest && !trustedMCP { + c.AbortWithStatus(http.StatusForbidden) + return + } dbType, err := helper.GetStrParamByKey(c, "type") if err != nil { helper.BadRequest(c, err) return } + if mcpRequest { + list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType) + if err != nil { + helper.BadRequest(c, err) + return + } + helper.SuccessWithData(c, list) + return + } list, err := databaseService.List(dbType) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/api/v2/database_mysql.go b/agent/app/api/v2/database_mysql.go index f467deee2..4d7219974 100644 --- a/agent/app/api/v2/database_mysql.go +++ b/agent/app/api/v2/database_mysql.go @@ -3,9 +3,12 @@ package v2 import ( "context" "encoding/base64" + "net/http" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/service" + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/gin-gonic/gin" ) @@ -19,6 +22,11 @@ import ( // @Router /databases [post] // @x-panel-log {"bodyKeys":["name"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建 mysql 数据库 [name]","formatEN":"create mysql database [name]"} func (b *BaseApi) CreateMysql(c *gin.Context) { + mcpRequest, trustedMCP := mcpapi.Check(c.Request) + if mcpRequest && !trustedMCP { + c.AbortWithStatus(http.StatusForbidden) + return + } var req dto.MysqlDBCreate if err := helper.CheckBindAndValidate(&req, c); err != nil { return @@ -32,6 +40,16 @@ func (b *BaseApi) CreateMysql(c *gin.Context) { } req.Password = string(password) } + if mcpRequest { + created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req) + if err != nil { + helper.InternalServer(c, err) + return + } + nodeID, _ := mcpapi.NodeID(c.Request) + helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID}) + return + } if _, err := mysqlService.Create(context.Background(), req); err != nil { helper.InternalServer(c, err) @@ -513,11 +531,25 @@ func (b *BaseApi) LoadRemoteAccess(c *gin.Context) { // @Security Timestamp // @Router /databases/status [post] func (b *BaseApi) LoadStatus(c *gin.Context) { + mcpRequest, trustedMCP := mcpapi.Check(c.Request) + if mcpRequest && !trustedMCP { + c.AbortWithStatus(http.StatusForbidden) + return + } var req dto.OperationWithNameAndType if err := helper.CheckBindAndValidate(&req, c); err != nil { return } + if mcpRequest { + data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, data) + return + } data, err := mysqlService.LoadStatus(req) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/api/v2/helper/helper.go b/agent/app/api/v2/helper/helper.go index b5e4e0454..56f02ab91 100644 --- a/agent/app/api/v2/helper/helper.go +++ b/agent/app/api/v2/helper/helper.go @@ -12,6 +12,7 @@ import ( "sync" "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "gorm.io/gorm" "github.com/1Panel-dev/1Panel/agent/app/dto" @@ -22,6 +23,7 @@ import ( ) func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) { + mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err) res := dto.Response{ Code: code, Message: "", @@ -32,6 +34,7 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) { } func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) { + mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err) res := dto.Response{ Code: code, ErrorCode: businessCode, @@ -42,6 +45,7 @@ func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey stri } func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) { + mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err) res := dto.Response{ Code: code, Data: data, diff --git a/agent/app/dto/request/app.go b/agent/app/dto/request/app.go index eeaea57df..94207bf6a 100644 --- a/agent/app/dto/request/app.go +++ b/agent/app/dto/request/app.go @@ -26,6 +26,8 @@ type AppInstallCreate struct { ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURL string `json:"modelAccountBaseURL"` + MCPPreserveWebsiteDir bool `json:"-"` + AppContainerConfig NodePushConfig } diff --git a/agent/app/service/app_mcp.go b/agent/app/service/app_mcp.go new file mode 100644 index 000000000..38cb9afff --- /dev/null +++ b/agent/app/service/app_mcp.go @@ -0,0 +1,53 @@ +package service + +import ( + "errors" + "os" + "path" + + "github.com/1Panel-dev/1Panel/agent/app/dto/request" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "gorm.io/gorm" +) + +func prepareMCPWebsiteDirectory(req request.AppInstallCreate) error { + changed := errors.New("MCP website directory changed; inspect the installation before retrying") + directory, ok := req.Params["WEBSITE_DIR"].(string) + if !ok || directory == "" || global.DB == nil { + return changed + } + return global.DB.Transaction(func(tx *gorm.DB) error { + var setting model.Setting + err := tx.Where("key = ?", "WEBSITE_DIR").First(&setting).Error + missing := errors.Is(err, gorm.ErrRecordNotFound) + if err != nil && !missing { + return err + } + current := setting.Value + if current == "" { + current = path.Join(global.Dir.BaseDir, "1panel", "www") + } + if directory != current { + return changed + } + if missing { + if err := tx.Create(&model.Setting{Key: "WEBSITE_DIR", Value: directory}).Error; err != nil { + return err + } + } else if setting.Value == "" { + result := tx.Model(&model.Setting{}).Where("id = ? AND value = ?", setting.ID, "").Update("value", directory) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return changed + } + } + if err := os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm); err != nil { + return err + } + return nil + }) +} diff --git a/agent/app/service/app_utils.go b/agent/app/service/app_utils.go index 585202d74..67da2deac 100644 --- a/agent/app/service/app_utils.go +++ b/agent/app/service/app_utils.go @@ -2020,6 +2020,9 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) { func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error { if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") { + if req.MCPPreserveWebsiteDir { + return prepareMCPWebsiteDirectory(req) + } if dir, ok := req.Params["WEBSITE_DIR"]; ok { siteDir := dir.(string) if siteDir == "" || !strings.HasPrefix(siteDir, "/") { diff --git a/agent/app/service/database_mysql.go b/agent/app/service/database_mysql.go index 2e7aa5988..f7ac9d259 100644 --- a/agent/app/service/database_mysql.go +++ b/agent/app/service/database_mysql.go @@ -433,6 +433,10 @@ func (u *MysqlService) ListDBOption() ([]dto.MysqlOption, error) { } func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) { + return u.create(ctx, req, false) +} + +func (u *MysqlService) create(ctx context.Context, req dto.MysqlDBCreate, mcpBounded bool) (*model.DatabaseMysql, error) { if cmd.CheckIllegal(req.Name, req.Username, req.Password, req.Format, req.Collation, req.Permission) { return nil, buserr.New("ErrCmdIllegal") } @@ -470,13 +474,13 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode return nil, errors.New("cannot set root as user name") } - cli, version, err := LoadMysqlClientByFrom(req.Database) + cli, version, err := loadMysqlClientByFrom(req.Database, mcpBounded) if err != nil { return nil, err } createItem.MysqlName = req.Database defer cli.Close() - if err := cli.Create(client.CreateInfo{ + createInfo := client.CreateInfo{ Name: req.Name, Format: req.Format, Collation: req.Collation, @@ -485,9 +489,31 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode Permission: req.Permission, Version: version, Timeout: 300, - }); err != nil { + } + if mcpBounded { + local, ok := cli.(*client.Local) + if !ok { + return nil, errors.New("an installed local MySQL instance is required") + } + deadline := time.Now().Add(300 * time.Second) + if writeDeadline, ok := ctx.Deadline(); ok && writeDeadline.Add(-10*time.Second).Before(deadline) { + deadline = writeDeadline.Add(-10 * time.Second) + } + sqlContext, cancel := context.WithDeadline(ctx, deadline) + err = local.CreateWithContext(sqlContext, createInfo) + cancel() + } else { + err = cli.Create(createInfo) + } + if err != nil { return nil, err } + if mcpBounded { + if err := persistMCPMysqlMetadata(ctx, req, permissionHosts, &createItem); err != nil { + return nil, err + } + return &createItem, nil + } if len(req.Username) != 0 { if err := saveDatabaseUserCredentials(dbType, req.Database, req.Username, req.Permission, req.Password, req.Description); err != nil { return nil, err @@ -506,6 +532,45 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode return &createItem, nil } +func persistMCPMysqlMetadata(ctx context.Context, req dto.MysqlDBCreate, hosts []string, item *model.DatabaseMysql) error { + return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if req.Username != "" { + key := global.CONF.Base.EncryptKey + if key == "" { + var setting model.Setting + if err := tx.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil { + return err + } + key = setting.Value + } + password, err := encrypt.StringEncryptWithKey(req.Password, key) + if err != nil { + return err + } + for _, host := range hosts { + var user model.DatabaseUser + err := tx.Where("type = ? AND database = ? AND username = ? AND host = ?", "mysql", req.Database, req.Username, host).First(&user).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + user.Type, user.Database, user.Username, user.Host = "mysql", req.Database, req.Username, host + user.Password, user.IsDelete = password, false + if req.Description != "" { + user.Description = req.Description + } + if err := tx.Save(&user).Error; err != nil { + return err + } + grant := model.DatabaseUserGrant{Type: "mysql", Database: req.Database, DBName: req.Name, Username: req.Username, Host: host} + if err := tx.Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).FirstOrCreate(&grant).Error; err != nil { + return err + } + } + } + return tx.Create(item).Error + }) +} + func (u *MysqlService) ListUsers(req dto.MysqlUserSearch) ([]dto.MysqlUser, error) { dbType, err := resolveDatabaseUserType(req.Database) if err != nil { @@ -1398,7 +1463,14 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS return nil, err } - statusMap, err := executeSqlForMaps(app.ContainerName, app.Key, app.Password, "show global status;") + return loadMysqlStatus(req, app, executeSqlForMaps, executeSqlForRows) +} + +func loadMysqlStatus(req dto.OperationWithNameAndType, app *repo.RootInfo, + maps func(string, string, string, string) (map[string]string, error), + rowsQuery func(string, string, string, string) ([]string, error), +) (*dto.MysqlStatus, error) { + statusMap, err := maps(app.ContainerName, app.Key, app.Password, "show global status;") if err != nil { return nil, err } @@ -1426,7 +1498,7 @@ func (u *MysqlService) LoadStatus(req dto.OperationWithNameAndType) (*dto.MysqlS if common.CompareAppVersion(app.Version, "8.4.0") && (req.Type == constant.AppMysql || req.Type == constant.AppMysqlCluster) { masterStatus = "show binary log status;" } - rows, err := executeSqlForRows(app.ContainerName, app.Key, app.Password, masterStatus) + rows, err := rowsQuery(app.ContainerName, app.Key, app.Password, masterStatus) if err != nil { return nil, err } @@ -1531,6 +1603,10 @@ func updateMyCnf(oldFiles []string, group string, param string, value interface{ } func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) { + return loadMysqlClientByFrom(database, false) +} + +func loadMysqlClientByFrom(database string, mcpLocalOnly bool) (mysql.MysqlClient, string, error) { var ( dbInfo client.DBInfo version string @@ -1542,6 +1618,9 @@ func LoadMysqlClientByFrom(database string) (mysql.MysqlClient, string, error) { if err != nil { return nil, "", err } + if mcpLocalOnly && (databaseItem.ID == 0 || databaseItem.From != "local" || databaseItem.Type != "mysql" || databaseItem.AppInstallID == 0) { + return nil, "", errors.New("an installed local MySQL instance is required") + } dbInfo.Type = databaseItem.Type dbInfo.From = databaseItem.From dbInfo.Database = database diff --git a/agent/app/service/database_mysql_mcp.go b/agent/app/service/database_mysql_mcp.go new file mode 100644 index 000000000..dbbb0ee04 --- /dev/null +++ b/agent/app/service/database_mysql_mcp.go @@ -0,0 +1,24 @@ +package service + +import ( + "context" + "errors" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" +) + +func (u *MysqlService) CreateForMCP(ctx context.Context, req dto.MysqlDBCreate) (*model.DatabaseMysql, error) { + if req.From != "local" || ctx.Err() != nil { + return nil, errors.New("invalid MCP database target or request") + } + writeContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 310*time.Second) + defer cancel() + created, err := u.create(writeContext, req, true) + if err != nil { + return nil, mcpapi.SanitizeBusinessError(err, "database creation failed; inspect the invocation before retrying") + } + return created, nil +} diff --git a/agent/app/service/mcp_mysql_errors.go b/agent/app/service/mcp_mysql_errors.go new file mode 100644 index 000000000..69dac3ada --- /dev/null +++ b/agent/app/service/mcp_mysql_errors.go @@ -0,0 +1,50 @@ +package service + +import ( + "errors" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" +) + +const mcpMysqlStoppedMessage = "The selected MySQL instance is not running. Start the database service and try the query again." +const mcpMysqlConnectionFailedMessage = "Cannot connect to the selected MySQL instance. Check the database service, connection settings and panel logs." + +var errMCPMysqlStopped = mcpapi.NewBusinessError(mcpapi.CodeMySQLStopped, mcpMysqlStoppedMessage) +var errMCPMysqlConnectionFailed = mcpapi.NewBusinessError(mcpapi.CodeMySQLConnectionFailed, mcpMysqlConnectionFailedMessage) + +var mcpMysqlStoppedPattern = regexp.MustCompile(`^Error response from daemon: [Cc]ontainer [A-Za-z0-9][A-Za-z0-9_.-]{0,127} is not running$`) +var mcpMysqlConnectionPatterns = []*regexp.Regexp{ + regexp.MustCompile(`^ERROR 2002 \(HY000\): Can't connect to local MySQL server through socket '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`), + regexp.MustCompile(`^ERROR 2003 \(HY000\): Can't connect to MySQL server on '[^'\r\n]{1,1000}' \([0-9]{1,10}\)$`), + regexp.MustCompile(`^ERROR 1045 \(28000\): Access denied for user '[^'\r\n]{1,128}'@'[^'\r\n]{1,255}' \(using password: (YES|NO)\)$`), +} + +func classifyMCPMysqlReadFailure(stderr string) error { + generic := errors.New("local MySQL status query failed") + if len(stderr) == 0 || len(stderr) > 2048 || !utf8.ValidString(stderr) { + return generic + } + warning := "mysql: [Warning] Using a password on the command line interface can be insecure." + stderr = strings.TrimPrefix(stderr, warning+"\n") + if strings.HasSuffix(stderr, "\r\n") { + stderr = strings.TrimSuffix(stderr, "\r\n") + } else { + stderr = strings.TrimSuffix(stderr, "\n") + } + if strings.IndexFunc(stderr, unicode.IsControl) >= 0 { + return generic + } + if mcpMysqlStoppedPattern.MatchString(stderr) { + return errMCPMysqlStopped + } + for _, pattern := range mcpMysqlConnectionPatterns { + if pattern.MatchString(stderr) { + return errMCPMysqlConnectionFailed + } + } + return generic +} diff --git a/agent/app/service/mcp_queries.go b/agent/app/service/mcp_queries.go new file mode 100644 index 000000000..9140f987b --- /dev/null +++ b/agent/app/service/mcp_queries.go @@ -0,0 +1,189 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os/exec" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/dto/response" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/app/repo" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "gorm.io/gorm" +) + +const mcpMysqlQueryTimeout = 30 * time.Second +const mcpMysqlQueryOutputLimit = 1 << 20 + +func mcpLocalMysqlQuery(ctx context.Context) *gorm.DB { + return global.DB.WithContext(ctx).Table("databases AS d"). + Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name"). + Joins("JOIN apps AS a ON a.id = ai.app_id"). + Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql). + Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "") +} + +func (u *DatabaseService) ListForMCP(ctx context.Context, dbType string) ([]dto.DatabaseOption, error) { + if dbType != constant.AppMysql { + return nil, errors.New("only installed local MySQL instances are supported") + } + result := make([]dto.DatabaseOption, 0) + err := mcpLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error + if err != nil { + return nil, errors.New("cannot read local MySQL instances") + } + return result, nil +} + +type mcpMysqlReadRunner func(context.Context, string, string, string) (string, error) + +func (u *MysqlService) LoadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType) (*dto.MysqlStatus, error) { + return u.loadStatusForMCP(ctx, req, runMCPMysqlRead) +} +func (u *MysqlService) loadStatusForMCP(ctx context.Context, req dto.OperationWithNameAndType, run mcpMysqlReadRunner) (*dto.MysqlStatus, error) { + if req.Type != constant.AppMysql || req.Name == "" || len(req.Name) > 128 || strings.TrimSpace(req.Name) != req.Name || strings.ContainsAny(req.Name, "\x00\r\n/\\") || ctx.Err() != nil { + return nil, errors.New("an explicitly named installed local MySQL instance is required") + } + ctx, cancel := context.WithTimeout(ctx, mcpMysqlQueryTimeout) + defer cancel() + var target struct { + ID uint + ContainerName, Version, Env string + } + if err := mcpLocalMysqlQuery(ctx).Where("d.name = ?", req.Name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&target).Error; err != nil || target.ID == 0 { + return nil, errors.New("the installed local MySQL instance is unavailable") + } + var env map[string]any + if json.Unmarshal([]byte(target.Env), &env) != nil { + return nil, errors.New("local MySQL configuration is unavailable") + } + password, ok := env["PANEL_DB_ROOT_PASSWORD"].(string) + if !ok || password == "" { + return nil, errors.New("local MySQL configuration is unavailable") + } + app := &repo.RootInfo{ContainerName: target.ContainerName, Version: target.Version, Key: constant.AppMysql, Password: password} + rows := func(container, _, password, sql string) ([]string, error) { + if ctx.Err() != nil { + return nil, errors.New("local MySQL status query failed") + } + output, err := run(ctx, container, password, sql) + if err != nil { + if err == errMCPMysqlStopped || err == errMCPMysqlConnectionFailed { + return nil, err + } + return nil, errors.New("local MySQL status query failed") + } + return strings.Split(output, "\n"), nil + } + maps := func(container, dbType, password, sql string) (map[string]string, error) { + lines, err := rows(container, dbType, password, sql) + if err != nil { + return nil, err + } + values := map[string]string{} + for _, line := range lines { + if pair := strings.Split(line, "\t"); len(pair) == 2 { + values[pair[0]] = pair[1] + } + } + return values, nil + } + return loadMysqlStatus(req, app, maps, rows) +} + +type mcpQueryOutput struct { + buffer bytes.Buffer + mu sync.Mutex + exceeded bool +} + +func (b *mcpQueryOutput) Write(data []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if len(data) > mcpMysqlQueryOutputLimit-b.buffer.Len() { + b.exceeded = true + return 0, errors.New("query output limit exceeded") + } + return b.buffer.Write(data) +} +func (b *mcpQueryOutput) Len() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.Len() +} +func (b *mcpQueryOutput) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.String() +} +func runMCPMysqlRead(ctx context.Context, container, password, sql string) (string, error) { + switch sql { + case "show global status;", "show master status;", "show binary log status;": + default: + return "", errors.New("unsupported MySQL monitoring query") + } + command := exec.CommandContext(ctx, "docker", "exec", container, "mysql", "-uroot", "-p"+password, "-e", sql) + output := &mcpQueryOutput{} + var stderr bytes.Buffer + command.Stdout, command.Stderr = output, io.MultiWriter(output, &stderr) + runErr := command.Run() + if ctx.Err() != nil || output.exceeded { + return "", errors.New("local MySQL status query failed") + } + if runErr != nil { + return "", classifyMCPMysqlReadFailure(stderr.String()) + } + value := strings.ReplaceAll(output.String(), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "") + if strings.HasPrefix(value, "ERROR ") { + return "", errors.New("local MySQL status query failed") + } + return value, nil +} + +func (a *AppInstallService) GetAppInstallInfoForMCP(ctx context.Context, id uint) (*response.AppInstallInfo, error) { + var installed model.AppInstall + if id == 0 { + return nil, errors.New("invalid installed application") + } + err := global.DB.WithContext(ctx).Preload("App").Where("id = ?", id).First(&installed).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &response.AppInstallInfo{Status: constant.StatusDeleted}, nil + } + if err != nil { + return nil, errors.New("installed application metadata is unavailable") + } + var env map[string]any + if json.Unmarshal([]byte(installed.Env), &env) != nil { + return nil, errors.New("installed application metadata is unavailable") + } + result := &response.AppInstallInfo{ID: installed.ID, Name: installed.Name, Version: installed.Version, Status: installed.Status, HttpPort: installed.HttpPort, AppKey: installed.App.Key, AppPorts: []int{}} + for key, value := range env { + if !strings.Contains(strings.ToUpper(key), "PANEL_APP_PORT") { + continue + } + var port int + switch item := value.(type) { + case string: + port, _ = strconv.Atoi(item) + case float64: + if item == float64(int(item)) { + port = int(item) + } + } + if port > 0 && port <= 65535 { + result.AppPorts = append(result.AppPorts, port) + } + } + sort.Ints(result.AppPorts) + return result, nil +} diff --git a/agent/app/service/nginx_utils.go b/agent/app/service/nginx_utils.go index 9143e7200..1a0ee2a84 100644 --- a/agent/app/service/nginx_utils.go +++ b/agent/app/service/nginx_utils.go @@ -78,9 +78,17 @@ func getNginxParamsByKeys(scope string, keys []string, website *model.Website) ( var res []response.NginxParam var block components.IBlock if scope == constant.NginxScopeHttp { - block = nginxFull.RootConfig.Config.FindHttp() + httpBlock := nginxFull.RootConfig.Config.FindHttp() + if httpBlock == nil { + return nil, fmt.Errorf("nginx HTTP configuration block is missing") + } + block = httpBlock } else { - block = nginxFull.SiteConfig.Config.FindServers()[0] + servers := nginxFull.SiteConfig.Config.FindServers() + if len(servers) == 0 { + return nil, fmt.Errorf("website nginx server block is missing") + } + block = servers[0] } for _, key := range keys { dirs := block.FindDirectives(key) diff --git a/agent/app/service/website.go b/agent/app/service/website.go index 297055e4e..b228ddfde 100644 --- a/agent/app/service/website.go +++ b/agent/app/service/website.go @@ -974,11 +974,16 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS, if err != nil { return res, err } + applyWebsiteHTTPSParams(&res, params) + return res, nil +} + +func applyWebsiteHTTPSParams(res *response.WebsiteHTTPS, params []response.NginxParam) { for _, p := range params { if p.Name == "ssl_protocols" { res.SSLProtocol = p.Params } - if p.Name == "ssl_ciphers" { + if p.Name == "ssl_ciphers" && len(p.Params) > 0 { res.Algorithm = p.Params[0] } if p.Name == "add_header" && len(p.Params) > 0 { @@ -996,7 +1001,6 @@ func (w WebsiteService) GetWebsiteHTTPS(websiteId uint) (response.WebsiteHTTPS, } } } - return res, nil } func (w WebsiteService) OpWebsiteHTTPS(ctx context.Context, req request.WebsiteHTTPSOp) (*response.WebsiteHTTPS, error) { diff --git a/agent/app/service/website_utils.go b/agent/app/service/website_utils.go index 65b3107f3..4331562ed 100644 --- a/agent/app/service/website_utils.go +++ b/agent/app/service/website_utils.go @@ -1454,10 +1454,10 @@ func getWebsiteDomains(domains []request.WebsiteDomain, defaultHTTPPort, default return } domainModel.Domain = strings.ToLower(domainModel.Domain) - domainModel.Port = domain.Port if domain.Port == 0 { domain.Port = defaultHTTPPort } + domainModel.Port = domain.Port domainModel.SSL = domain.SSL domainModel.WebsiteID = websiteID domainModels = append(domainModels, domainModel) diff --git a/agent/init/router/mcp_runtime.go b/agent/init/router/mcp_runtime.go new file mode 100644 index 000000000..486d38344 --- /dev/null +++ b/agent/init/router/mcp_runtime.go @@ -0,0 +1,61 @@ +package router + +import ( + "bytes" + "encoding/json" + "io" + "net" + "net/http" + + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/middleware" + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" + "github.com/1Panel-dev/1Panel/agent/utils/mcpruntime" + "github.com/gin-gonic/gin" +) + +func registerMCPRuntime(engine *gin.Engine) { + controller := mcpruntime.New() + engine.POST("/internal/mcp/docker", mcpDockerHandler(controller)) + engine.GET(mcpapi.CapabilitiesPath, middleware.Certificate(), mcpCapabilitiesHandler()) +} + +func mcpCapabilitiesHandler() gin.HandlerFunc { + return func(ctx *gin.Context) { + if !mcpapi.TrustedTransport(ctx.Request) { + ctx.AbortWithStatus(http.StatusForbidden) + return + } + ctx.JSON(http.StatusOK, gin.H{"contractVersion": mcpapi.ContractVersion, "features": []string{ + "operation-bound-identity", "stable-node-target", "resource-guards", + }}) + } +} + +func mcpDockerHandler(controller *mcpruntime.Controller) gin.HandlerFunc { + return func(ctx *gin.Context) { + local, ok := ctx.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !global.IsMaster || !ok || local.Network() != "unix" { + ctx.AbortWithStatus(http.StatusForbidden) + return + } + data, err := io.ReadAll(io.LimitReader(ctx.Request.Body, 8193)) + if err != nil || len(data) > 8192 { + ctx.AbortWithStatus(http.StatusBadRequest) + return + } + var req mcpruntime.Request + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&req) != nil || decoder.Decode(new(any)) != io.EOF { + ctx.AbortWithStatus(http.StatusBadRequest) + return + } + response, err := controller.Handle(ctx.Request.Context(), req) + if err != nil { + ctx.JSON(http.StatusBadRequest, mcpruntime.Response{Error: err.Error()}) + return + } + ctx.JSON(http.StatusOK, response) + } +} diff --git a/agent/init/router/router.go b/agent/init/router/router.go index 9ecaa9813..911e12532 100644 --- a/agent/init/router/router.go +++ b/agent/init/router/router.go @@ -16,6 +16,7 @@ var ( func Routers() *gin.Engine { Router = gin.Default() Router.Use(i18n.UseI18n()) + registerMCPRuntime(Router) PrivateGroup := Router.Group("/api/v2") if !global.IsMaster { diff --git a/agent/middleware/certificate.go b/agent/middleware/certificate.go index efb8162b4..9cbfb5571 100644 --- a/agent/middleware/certificate.go +++ b/agent/middleware/certificate.go @@ -10,10 +10,15 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/utils/cmd" + "github.com/1Panel-dev/1Panel/agent/utils/mcpapi" "github.com/1Panel-dev/1Panel/agent/utils/xpack" "github.com/gin-gonic/gin" ) +var loadNodeProxyID = func() (string, error) { + return cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("cat", "/etc/1panel/.nodeProxyID") +} + func Certificate() gin.HandlerFunc { return func(c *gin.Context) { if global.IsMaster { @@ -25,16 +30,23 @@ func Certificate() gin.HandlerFunc { return } conn := c.Request.Header.Get("Connection") - if conn == "Upgrade" { + mcpRequest, _ := mcpapi.Check(c.Request) + mcpTransport := mcpRequest || c.Request.URL.Path == mcpapi.CapabilitiesPath + if conn == "Upgrade" && !mcpTransport { c.Next() return } masterProxyID := c.Request.Header.Get("Proxy-Id") - proxyID, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("cat", "/etc/1panel/.nodeProxyID") + proxyID, err := loadNodeProxyID() + if mcpTransport && (err != nil || strings.TrimSpace(proxyID) == "" || strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID)) { + c.AbortWithStatus(http.StatusForbidden) + return + } if err == nil && len(proxyID) != 0 && strings.TrimSpace(proxyID) != strings.TrimSpace(masterProxyID) { helper.InternalServer(c, fmt.Errorf("err proxy id")) return } + c.Request = mcpapi.BindVerifiedNodeTransport(c.Request) c.Next() } } diff --git a/agent/utils/mcpapi/errors.go b/agent/utils/mcpapi/errors.go new file mode 100644 index 000000000..d74bd670d --- /dev/null +++ b/agent/utils/mcpapi/errors.go @@ -0,0 +1,67 @@ +package mcpapi + +import ( + "errors" + "net/http" + + "github.com/1Panel-dev/1Panel/agent/buserr" +) + +const ( + BusinessCodeHeader = "X-Panel-MCP-Business-Code" + CodeMySQLStopped = "MCP_MYSQL_STOPPED" + CodeMySQLConnectionFailed = "MCP_MYSQL_CONNECTION_FAILED" +) + +type codedError struct { + code string + message string +} + +func (e *codedError) Error() string { return e.message } + +func allowedBusinessCode(code string) string { + switch code { + case "ErrAliasIsExist", "ErrDomainIsUsed", "ErrPortInUsed", "ErrPortInOtherApp", "ErrPortExist", "ErrRecordExist", "ErrDatabaseIsExist", CodeMySQLStopped, CodeMySQLConnectionFailed: + return code + default: + return "" + } +} + +func BusinessErrorCode(err error) string { + var coded *codedError + if errors.As(err, &coded) && coded != nil { + return allowedBusinessCode(coded.code) + } + var business buserr.BusinessError + if errors.As(err, &business) { + return allowedBusinessCode(business.Msg) + } + var businessPointer *buserr.BusinessError + if errors.As(err, &businessPointer) && businessPointer != nil { + return allowedBusinessCode(businessPointer.Msg) + } + return "" +} + +func NewBusinessError(code, message string) error { + return &codedError{code: allowedBusinessCode(code), message: message} +} + +func SanitizeBusinessError(err error, safeMessage string) error { + return NewBusinessError(BusinessErrorCode(err), safeMessage) +} + +func SetBusinessErrorHeader(request *http.Request, header http.Header, err error) { + if request == nil || header == nil { + return + } + if present, trusted := Check(request); !present || !trusted { + return + } + header.Del(BusinessCodeHeader) + if code := BusinessErrorCode(err); code != "" { + header.Set(BusinessCodeHeader, code) + } +} diff --git a/agent/utils/mcpapi/request.go b/agent/utils/mcpapi/request.go new file mode 100644 index 000000000..408534c3b --- /dev/null +++ b/agent/utils/mcpapi/request.go @@ -0,0 +1,79 @@ +package mcpapi + +import ( + "context" + "net" + "net/http" + "strconv" + + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/google/uuid" +) + +const ( + InvocationHeader = "X-Panel-MCP-Invocation" + NodeHeader = "X-Panel-MCP-Node" + MethodHeader = "X-Panel-MCP-Method" + PathHeader = "X-Panel-MCP-Path" + CapabilitiesPath = "/api/v2/internal/mcp/capabilities" + ContractVersion = 4 +) + +type verifiedNodeTransportKey struct{} + +func BindVerifiedNodeTransport(request *http.Request) *http.Request { + if request.TLS == nil || !request.TLS.HandshakeComplete || len(request.TLS.VerifiedChains) == 0 { + return request + } + return request.WithContext(context.WithValue(request.Context(), verifiedNodeTransportKey{}, request.TLS)) +} + +func validNodeID(id string) bool { + if id == "local" { + return true + } + value, err := strconv.ParseUint(id, 10, 64) + return err == nil && value != 0 && strconv.FormatUint(value, 10) == id +} + +func TrustedTransport(request *http.Request) bool { + if global.IsMaster { + local, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr) + return ok && local.Network() == "unix" + } + verified := request.Context().Value(verifiedNodeTransportKey{}) + return request.TLS != nil && verified == request.TLS +} + +func Check(request *http.Request) (present, trusted bool) { + present = false + for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} { + if len(request.Header.Values(name)) != 0 { + present = true + } + } + if !present { + return false, false + } + for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} { + if len(request.Header.Values(name)) != 1 { + return true, false + } + } + if _, err := uuid.Parse(request.Header.Get(InvocationHeader)); err != nil { + return true, false + } + nodeID := request.Header.Get(NodeHeader) + if !validNodeID(nodeID) || request.Header.Get(MethodHeader) != request.Method || request.Header.Get(PathHeader) != request.URL.Path || request.URL.RawQuery != "" { + return true, false + } + return true, TrustedTransport(request) && ((nodeID == "local") == global.IsMaster) +} + +func NodeID(request *http.Request) (string, bool) { + _, trusted := Check(request) + if !trusted { + return "", false + } + return request.Header.Get(NodeHeader), true +} diff --git a/agent/utils/mcpruntime/docker.go b/agent/utils/mcpruntime/docker.go new file mode 100644 index 000000000..8fc323bc2 --- /dev/null +++ b/agent/utils/mcpruntime/docker.go @@ -0,0 +1,530 @@ +package mcpruntime + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "github.com/1Panel-dev/1Panel/agent/utils/docker" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/system" + "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +const ( + ImageRepository = "1panel/1panel-mcp-server" + ContainerName = "1panel-mcp" + previousName = "1panel-mcp-previous" + SocketDirectory = "/etc/1panel/mcp-runtime" + CoreSocket = SocketDirectory + "/core.sock" + adapterBinary = "/usr/local/bin/mcp-1panel" + ownerLabel = "io.1panel.mcp.managed" + imageLabel = "io.1panel.mcp.image" + portLabel = "io.1panel.mcp.port" + versionLabel = "io.1panel.mcp.version" + maxCatalogBytes = 2 << 20 + maxLogBytes = 64 << 10 + contractVersion = 5 + protocol = "streamable-http" +) + +var semanticVersion = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$`) +var digestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) +var ErrUnsupported = errors.New("MCP Docker runtime requires a local rootful Linux Docker daemon without user namespace remapping") + +type Request struct { + Operation string `json:"operation"` + Image string `json:"image"` + Port int `json:"port"` + Enabled bool `json:"enabled"` + Restart bool `json:"restart"` + ImageID string `json:"imageID,omitempty"` +} + +type State struct { + Installed bool `json:"installed"` + Status string `json:"status"` + Ready bool `json:"ready"` + Reason string `json:"reason,omitempty"` + AdapterVersion string `json:"adapterVersion,omitempty"` + Image string `json:"image,omitempty"` + ImageID string `json:"imageID,omitempty"` +} + +type Response struct { + State *State `json:"state,omitempty"` + Catalog json.RawMessage `json:"catalog,omitempty"` + Logs string `json:"logs,omitempty"` + Error string `json:"error,omitempty"` + ImageID string `json:"imageID,omitempty"` +} + +type DockerClient interface { + Close() error + DaemonHost() string + Info(context.Context) (system.Info, error) + ImageInspect(context.Context, string, ...client.ImageInspectOption) (image.InspectResponse, error) + ImagePull(context.Context, string, image.PullOptions) (io.ReadCloser, error) + ContainerInspect(context.Context, string) (container.InspectResponse, error) + ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error) + ContainerStart(context.Context, string, container.StartOptions) error + ContainerStop(context.Context, string, container.StopOptions) error + ContainerRemove(context.Context, string, container.RemoveOptions) error + ContainerRename(context.Context, string, string) error + ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error) +} + +type metadata struct { + Status string `json:"status"` + AdapterVersion string `json:"adapterVersion"` + ContractVersion int `json:"contractVersion"` + Protocol string `json:"protocol"` +} + +type verifiedImage struct { + metadata metadata + catalog []byte +} + +type Controller struct { + newClient func() (DockerClient, error) + platform string + health func(context.Context, int) (metadata, error) + checkDirectory func() error + mutation chan struct{} + probes chan struct{} + cacheMu sync.Mutex + cache map[string]verifiedImage +} + +func New() *Controller { + return newController(func() (DockerClient, error) { return docker.NewDockerClient() }) +} + +func newController(factory func() (DockerClient, error)) *Controller { + return &Controller{newClient: factory, platform: runtime.GOOS, health: readHealth, + checkDirectory: checkSocketDirectory, mutation: make(chan struct{}, 1), probes: make(chan struct{}, 8), cache: make(map[string]verifiedImage)} +} + +func ValidateImage(value string) error { + if len(value) > 180 { + return errors.New("invalid MCP Docker image") + } + if strings.HasPrefix(value, ImageRepository+"@") && digestPattern.MatchString(strings.TrimPrefix(value, ImageRepository+"@")) { + return nil + } + if strings.HasPrefix(value, ImageRepository+":") && semanticVersion.MatchString(strings.TrimPrefix(value, ImageRepository+":")) { + return nil + } + return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest") +} + +func (c *Controller) Handle(ctx context.Context, req Request) (Response, error) { + switch req.Operation { + case "prepare", "catalog", "candidate_catalog", "status", "apply", "install", "rollback", "rollback_absent", "pull", "logs": + default: + return Response{}, errors.New("invalid MCP Docker operation") + } + if err := ValidateImage(req.Image); err != nil { + return Response{}, err + } + if req.Port < 1024 || req.Port > 65535 { + return Response{}, errors.New("MCP listen port must be between 1024 and 65535") + } + timeout := 60 * time.Second + if req.Operation == "catalog" || req.Operation == "candidate_catalog" || req.Operation == "logs" || req.Operation == "prepare" { + timeout = 5 * time.Second + } + if req.Operation == "status" { + timeout = 8 * time.Second + } + if req.Operation == "pull" { + timeout = 4 * time.Minute + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if req.Operation == "apply" || req.Operation == "install" || req.Operation == "rollback" || req.Operation == "rollback_absent" || req.Operation == "pull" { + select { + case c.mutation <- struct{}{}: + defer func() { <-c.mutation }() + case <-ctx.Done(): + return Response{}, ctx.Err() + } + } + cli, err := c.newClient() + if err != nil { + return unavailable(req, "Docker daemon is unavailable") + } + defer cli.Close() + if err = c.supported(ctx, cli); err != nil { + return unavailable(req, err.Error()) + } + switch req.Operation { + case "prepare": + case "catalog", "candidate_catalog": + var img image.InspectResponse + img, err = c.resolve(ctx, cli, req.Image) + if err == nil && req.Operation == "catalog" { + var current *container.InspectResponse + current, err = c.owned(ctx, cli, ContainerName) + if err == nil && current != nil && isRunning(current) && current.Config.Labels[imageLabel] == req.Image && current.Image != img.ID { + err = errors.New("MCP image reference changed; restart the adapter before reading its catalog") + } + } + if err == nil { + var verified verifiedImage + verified, err = c.verify(ctx, cli, img.ID, req.Image) + if err == nil { + return Response{Catalog: append(json.RawMessage(nil), verified.catalog...), ImageID: img.ID}, nil + } + } + case "status": + return c.status(ctx, cli, req), nil + case "apply": + err = c.apply(ctx, cli, req) + case "install": + err = c.installRuntime(ctx, cli, req) + case "rollback": + err = c.rollback(ctx, cli, req) + case "rollback_absent": + err = c.rollbackAbsent(ctx, cli, req) + case "pull": + err = c.pullImage(ctx, cli, req) + case "logs": + var current *container.InspectResponse + current, err = c.owned(ctx, cli, ContainerName) + if err == nil && current == nil { + err = errors.New("MCP container is not installed") + } + if err == nil { + var data []byte + data, err = readLogs(ctx, cli, current.ID, maxLogBytes, "100") + if err == nil { + return Response{Logs: redactLog(string(data))}, nil + } + } + } + return Response{}, err +} + +func unavailable(req Request, reason string) (Response, error) { + if req.Operation == "status" { + return Response{State: &State{Status: "unsupported", Image: req.Image, Reason: reason}}, nil + } + return Response{}, errors.New(reason) +} + +func (c *Controller) supported(ctx context.Context, cli DockerClient) error { + if c.platform != "linux" || !strings.HasPrefix(cli.DaemonHost(), "unix:///") { + return ErrUnsupported + } + info, err := cli.Info(ctx) + if err != nil { + return errors.New("Docker daemon is unavailable") + } + if info.OSType != "linux" { + return ErrUnsupported + } + for _, option := range info.SecurityOptions { + if strings.Contains(option, "rootless") || strings.Contains(option, "userns") { + return ErrUnsupported + } + } + return nil +} + +func (c *Controller) resolve(ctx context.Context, cli DockerClient, ref string) (image.InspectResponse, error) { + img, err := cli.ImageInspect(ctx, ref) + if err != nil { + return image.InspectResponse{}, errors.New("MCP Docker image is unavailable locally; load it or install the selected version") + } + if !digestPattern.MatchString(img.ID) || img.Os != "linux" || img.Config == nil || len(img.Config.Volumes) > 0 { + return image.InspectResponse{}, errors.New("invalid MCP Docker image identity or platform") + } + return img, nil +} + +func (c *Controller) owned(ctx context.Context, cli DockerClient, name string) (*container.InspectResponse, error) { + info, err := cli.ContainerInspect(ctx, name) + if errdefs.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, errors.New("MCP container state is unavailable") + } + if info.ContainerJSONBase == nil || info.Config == nil || info.Config.Labels[ownerLabel] != "true" { + return nil, errors.New("MCP container name is occupied by an unmanaged container") + } + return &info, nil +} + +func isRunning(info *container.InspectResponse) bool { + return info != nil && info.State != nil && info.State.Running +} + +func (c *Controller) status(ctx context.Context, cli DockerClient, req Request) Response { + state := &State{Status: "not_installed", Image: req.Image} + img, err := c.resolve(ctx, cli, req.Image) + if err == nil { + state.ImageID = img.ID + } + current, inspectErr := c.owned(ctx, cli, ContainerName) + if inspectErr != nil { + state.Status = "unhealthy" + state.Reason = inspectErr.Error() + return Response{State: state} + } + if current == nil { + if err != nil { + state.Reason = err.Error() + } else { + state.Reason = "MCP image is loaded; install it to create the managed container" + } + return Response{State: state} + } + state.Installed = true + state.ImageID = current.Image + state.AdapterVersion = current.Config.Labels[versionLabel] + state.Status = "stopped" + if !isRunning(current) { + if req.Enabled { + state.Reason = "MCP container is not running" + } + return Response{State: state} + } + state.Status = "running" + if !req.Enabled { + state.Reason = "MCP access is disabled" + return Response{State: state} + } + if err != nil || current.Image != img.ID { + state.Status = "unhealthy" + state.Reason = "MCP container does not match the configured image or listen port" + return Response{State: state} + } + verified, verifyErr := c.verify(ctx, cli, current.Image, req.Image) + if verifyErr != nil || !matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) { + state.Status = "unhealthy" + state.Reason = "MCP container configuration or metadata does not match the verified image" + return Response{State: state} + } + state.AdapterVersion = verified.metadata.AdapterVersion + health, healthErr := c.health(ctx, req.Port) + if healthErr != nil || health.Status != "ok" || !validMetadata(health) || health.AdapterVersion != state.AdapterVersion { + state.Status = "unhealthy" + state.Reason = "MCP adapter health or contract is invalid" + return Response{State: state} + } + state.Ready = true + return Response{State: state} +} + +func (c *Controller) verify(ctx context.Context, cli DockerClient, id, ref string) (verifiedImage, error) { + c.cacheMu.Lock() + cached, ok := c.cache[id] + c.cacheMu.Unlock() + if !ok { + select { + case c.probes <- struct{}{}: + defer func() { <-c.probes }() + default: + return verifiedImage{}, errors.New("MCP image catalog is busy; retry shortly") + } + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + version, err := c.probe(probeCtx, cli, id, "--version", 4096) + if err != nil || json.Unmarshal(version, &cached.metadata) != nil || !validMetadata(cached.metadata) { + return verifiedImage{}, errors.New("MCP image version or contract is incompatible with this panel") + } + cached.catalog, err = c.probe(probeCtx, cli, id, "--catalog", maxCatalogBytes) + var manifest struct { + ContractVersion int `json:"contractVersion"` + Tools []json.RawMessage `json:"tools"` + } + if err != nil || json.Unmarshal(cached.catalog, &manifest) != nil || manifest.ContractVersion != contractVersion || len(manifest.Tools) > 256 { + return verifiedImage{}, errors.New("MCP image catalog is invalid or incompatible") + } + c.cacheMu.Lock() + if len(c.cache) >= 32 { + c.cache = make(map[string]verifiedImage) + } + c.cache[id] = cached + c.cacheMu.Unlock() + } + if strings.HasPrefix(ref, ImageRepository+":") && strings.TrimPrefix(cached.metadata.AdapterVersion, "v") != strings.TrimPrefix(strings.TrimPrefix(ref, ImageRepository+":"), "v") { + return verifiedImage{}, errors.New("MCP image version does not match its selected tag") + } + return cached, nil +} + +func validMetadata(value metadata) bool { + return semanticVersion.MatchString(value.AdapterVersion) && value.ContractVersion == contractVersion && value.Protocol == protocol +} + +func safeConfig(id string, args []string) (*container.Config, *container.HostConfig) { + pids := int64(128) + config := &container.Config{Image: id, Entrypoint: []string{adapterBinary}, Cmd: args, User: "0:0", Env: []string{}, Labels: map[string]string{ownerLabel: "true"}, NetworkDisabled: true, Healthcheck: &container.HealthConfig{Test: []string{"NONE"}}} + host := &container.HostConfig{NetworkMode: "none", ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges:true"}, IpcMode: "private", + Resources: container.Resources{Memory: 256 << 20, MemorySwap: 256 << 20, NanoCPUs: 1_000_000_000, PidsLimit: &pids}, + LogConfig: container.LogConfig{Type: "json-file", Config: map[string]string{"max-size": "3m", "max-file": "1"}}} + return config, host +} + +func (c *Controller) probe(ctx context.Context, cli DockerClient, id, flag string, limit int) (data []byte, err error) { + config, host := safeConfig(id, []string{flag}) + created, err := cli.ContainerCreate(ctx, config, host, nil, nil, "") + if err != nil { + return nil, errors.New("MCP image probe could not be created") + } + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cleanupErr := cli.ContainerRemove(cleanupCtx, created.ID, container.RemoveOptions{Force: true}) + if cleanupErr != nil && !errdefs.IsNotFound(cleanupErr) { + err = errors.Join(err, errors.New("MCP image probe cleanup failed")) + } + }() + if err = cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { + return nil, errors.New("MCP image probe failed to start") + } + for { + info, inspectErr := cli.ContainerInspect(ctx, created.ID) + if inspectErr != nil || info.State == nil { + return nil, errors.New("MCP image probe state is unavailable") + } + if info.State.Status == "exited" { + if info.State.ExitCode != 0 { + return nil, errors.New("MCP image probe exited unsuccessfully") + } + break + } + if info.State.Dead { + return nil, errors.New("MCP image probe exited unsuccessfully") + } + select { + case <-ctx.Done(): + return nil, errors.New("MCP image probe timed out") + case <-time.After(25 * time.Millisecond): + } + } + return readLogs(ctx, cli, created.ID, limit, "all") +} + +type boundedWriter struct { + bytes.Buffer + limit int +} + +func (b *boundedWriter) Write(value []byte) (int, error) { + if b.Len()+len(value) > b.limit { + return 0, errors.New("MCP output exceeds permitted size") + } + return b.Buffer.Write(value) +} + +func readLogs(ctx context.Context, cli DockerClient, id string, limit int, tail string) ([]byte, error) { + reader, err := cli.ContainerLogs(ctx, id, container.LogsOptions{ShowStdout: true, ShowStderr: true, Tail: tail}) + if err != nil { + return nil, errors.New("MCP container logs are unavailable") + } + defer reader.Close() + stdout, stderr := &boundedWriter{limit: limit}, &boundedWriter{limit: limit} + if err = readDockerStreams(reader, stdout, stderr, 2*limit+64<<10); err != nil { + return nil, errors.New("MCP output exceeds permitted size or is invalid") + } + if tail == "all" { + return stdout.Bytes(), nil + } + combined := append(append([]byte(nil), stdout.Bytes()...), stderr.Bytes()...) + if len(combined) > limit { + combined = combined[:limit] + } + return combined, nil +} + +func readDockerStreams(reader io.Reader, stdout, stderr *boundedWriter, maxRaw int) error { + total := 0 + for { + var header [8]byte + _, err := io.ReadFull(reader, header[:]) + if err == io.EOF { + return nil + } + if err != nil { + return err + } + size := int64(binary.BigEndian.Uint32(header[4:])) + total += len(header) + if size > int64(maxRaw-total) || header[1] != 0 || header[2] != 0 || header[3] != 0 { + return errors.New("invalid Docker log frame") + } + var output *boundedWriter + switch header[0] { + case 1: + output = stdout + case 2: + output = stderr + default: + return errors.New("invalid Docker log stream") + } + if size > int64(output.limit-output.Len()) { + return errors.New("MCP output exceeds permitted size") + } + if _, err = io.CopyN(output, reader, size); err != nil { + return err + } + total += int(size) + } +} + +func checkSocketDirectory() error { + info, err := os.Lstat(SocketDirectory) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0022 != 0 { + return errors.New("MCP runtime socket directory is missing or unsafe") + } + return nil +} + +func readHealth(ctx context.Context, port int) (metadata, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/healthz", port), nil) + if err != nil { + return metadata{}, err + } + client := &http.Client{Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + defer client.CloseIdleConnections() + resp, err := client.Do(req) + if err != nil { + return metadata{}, errors.New("MCP health endpoint is unavailable") + } + defer resp.Body.Close() + var result metadata + data, readErr := io.ReadAll(io.LimitReader(resp.Body, 4097)) + if resp.StatusCode != 200 || readErr != nil || len(data) > 4096 || json.Unmarshal(data, &result) != nil || result.Status != "ok" || !validMetadata(result) { + return metadata{}, errors.New("MCP health response is incompatible") + } + return result, nil +} + +var bearerPattern = regexp.MustCompile(`(?i)bearer\s+[^\s"']+`) +var sensitivePattern = regexp.MustCompile(`(?i)("?(?:password|token|secret|authorization)"?\s*[:=]\s*)("[^"]*"|[^\s,}]+)`) + +func redactLog(value string) string { + return sensitivePattern.ReplaceAllString(bearerPattern.ReplaceAllString(value, "Bearer [REDACTED]"), "${1}[REDACTED]") +} diff --git a/agent/utils/mcpruntime/lifecycle.go b/agent/utils/mcpruntime/lifecycle.go new file mode 100644 index 000000000..6da1f3660 --- /dev/null +++ b/agent/utils/mcpruntime/lifecycle.go @@ -0,0 +1,441 @@ +package mcpruntime + +import ( + "context" + "encoding/json" + "errors" + "io" + "reflect" + "strconv" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/errdefs" +) + +func (c *Controller) pullImage(ctx context.Context, cli DockerClient, req Request) error { + _, err := cli.ImageInspect(ctx, req.Image) + if err != nil && !errdefs.IsNotFound(err) { + return errors.New("MCP Docker image inspection failed") + } + if errdefs.IsNotFound(err) { + pullCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + reader, pullErr := cli.ImagePull(pullCtx, req.Image, image.PullOptions{}) + if pullErr != nil { + return errors.New("MCP Docker image download failed") + } + defer reader.Close() + decoder := json.NewDecoder(io.LimitReader(reader, 16<<20)) + for { + var message struct { + Error string `json:"error"` + ErrorDetail *struct { + Message string `json:"message"` + } `json:"errorDetail"` + } + if err = decoder.Decode(&message); err == io.EOF { + break + } else if err != nil { + return errors.New("MCP Docker image download was interrupted") + } + if message.Error != "" || message.ErrorDetail != nil { + return errors.New("MCP Docker image download failed") + } + } + } + img, err := c.resolve(ctx, cli, req.Image) + if err != nil { + return err + } + _, err = c.verify(ctx, cli, img.ID, req.Image) + return err +} + +func (c *Controller) installRuntime(ctx context.Context, cli DockerClient, req Request) error { + before, err := c.owned(ctx, cli, ContainerName) + if err != nil { + return err + } + wasRunning := isRunning(before) + activate := req + activate.Enabled = true + activate.Restart = true + if err = c.apply(ctx, cli, activate); err != nil { + return err + } + if req.Enabled { + return nil + } + current, err := c.owned(ctx, cli, ContainerName) + if err == nil && current == nil { + err = errors.New("installed MCP container is unavailable") + } + if err == nil && current != nil { + err = stopContainer(ctx, cli, current.ID) + } + if err == nil { + return nil + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + var restoreErr error + if before != nil { + port, parseErr := strconv.Atoi(before.Config.Labels[portLabel]) + old := Request{Operation: "rollback", Image: before.Config.Labels[imageLabel], ImageID: before.Image, Port: port, Enabled: wasRunning} + if parseErr != nil || ValidateImage(old.Image) != nil { + restoreErr = errors.New("old MCP runtime configuration is invalid") + } + if restoreErr == nil { + restoreErr = c.rollback(rollbackCtx, cli, old) + } + } else if current != nil { + restoreErr = cli.ContainerRemove(rollbackCtx, current.ID, container.RemoveOptions{Force: true}) + } + return errors.Join(errors.New("installed MCP container could not be left stopped"), restoreErr) +} + +func runtimeConfig(id string, req Request, version string) (*container.Config, *container.HostConfig) { + config, host := safeConfig(id, []string{"--mode=panel", "--listen=127.0.0.1:" + strconv.Itoa(req.Port), "--panel-socket=" + CoreSocket}) + config.NetworkDisabled = false + config.Labels[imageLabel] = req.Image + config.Labels[portLabel] = strconv.Itoa(req.Port) + config.Labels[versionLabel] = version + host.NetworkMode = "host" + host.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyUnlessStopped} + host.Mounts = []mount.Mount{{Type: mount.TypeBind, Source: SocketDirectory, Target: SocketDirectory, ReadOnly: true, BindOptions: &mount.BindOptions{Propagation: mount.PropagationRPrivate}}} + host.LogConfig.Config = map[string]string{"max-size": "1m", "max-file": "3"} + return config, host +} + +func matchesRuntime(info *container.InspectResponse, id string, req Request, version string) bool { + if info == nil || info.ContainerJSONBase == nil || info.Config == nil || info.HostConfig == nil || info.Image != id { + return false + } + wantConfig, wantHost := runtimeConfig(id, req, version) + cfg, host := info.Config, info.HostConfig + if cfg.Labels[versionLabel] != version || cfg.Labels[imageLabel] != req.Image || cfg.Labels[portLabel] != strconv.Itoa(req.Port) || cfg.User != wantConfig.User || cfg.Tty || !reflect.DeepEqual(cfg.Entrypoint, wantConfig.Entrypoint) || !reflect.DeepEqual(cfg.Cmd, wantConfig.Cmd) { + return false + } + if host.NetworkMode != "host" || host.Privileged || !host.ReadonlyRootfs || !reflect.DeepEqual(host.CapDrop, wantHost.CapDrop) || len(host.CapAdd) != 0 || !reflect.DeepEqual(host.SecurityOpt, wantHost.SecurityOpt) || len(host.Binds) != 0 || len(host.VolumesFrom) != 0 || len(host.Devices) != 0 || len(host.DeviceRequests) != 0 || len(host.Mounts) != 1 { + return false + } + m := host.Mounts[0] + if m.Type != mount.TypeBind || m.Source != SocketDirectory || m.Target != SocketDirectory || !m.ReadOnly { + return false + } + if host.PidMode != "" || host.UsernsMode != "" || host.UTSMode != "" || (host.IpcMode != "" && host.IpcMode != "private") || host.PublishAllPorts || len(host.PortBindings) != 0 || host.Resources.Memory != wantHost.Memory || host.Resources.MemorySwap != wantHost.MemorySwap || host.Resources.NanoCPUs != wantHost.NanoCPUs || host.Resources.PidsLimit == nil || *host.Resources.PidsLimit != *wantHost.PidsLimit { + return false + } + return host.RestartPolicy.Name == container.RestartPolicyUnlessStopped +} + +func matchesRuntimeWithCapabilities(ctx context.Context, cli DockerClient, info *container.InspectResponse, id string, req Request, version string) bool { + if matchesRuntime(info, id, req, version) { + return true + } + if info == nil || info.ContainerJSONBase == nil || info.HostConfig == nil || info.HostConfig.MemorySwap != -1 { + return false + } + adjusted := *info + base := *info.ContainerJSONBase + host := *info.HostConfig + _, wantHost := runtimeConfig(id, req, version) + host.MemorySwap = wantHost.MemorySwap + base.HostConfig = &host + adjusted.ContainerJSONBase = &base + if !matchesRuntime(&adjusted, id, req, version) { + return false + } + daemon, err := cli.Info(ctx) + return err == nil && !daemon.SwapLimit +} + +func stopContainer(ctx context.Context, cli DockerClient, id string) error { + seconds := 10 + if err := cli.ContainerStop(ctx, id, container.StopOptions{Timeout: &seconds}); err != nil && !errdefs.IsNotModified(err) { + return errors.New("MCP container could not be stopped") + } + return nil +} + +func (c *Controller) apply(ctx context.Context, cli DockerClient, req Request) error { + current, err := c.owned(ctx, cli, ContainerName) + if err != nil { + return err + } + if !req.Enabled { + if current == nil || !isRunning(current) { + return nil + } + return stopContainer(ctx, cli, current.ID) + } + if err = c.checkDirectory(); err != nil { + return err + } + img, err := c.resolve(ctx, cli, req.Image) + if err != nil { + return err + } + if !digestPattern.MatchString(req.ImageID) || img.ID != req.ImageID { + return errors.New("MCP image identity changed after catalog validation; retry the operation") + } + verified, err := c.verify(ctx, cli, img.ID, req.Image) + if err != nil { + return err + } + if matchesRuntimeWithCapabilities(ctx, cli, current, img.ID, req, verified.metadata.AdapterVersion) { + wasRunning := isRunning(current) + if wasRunning && !req.Restart { + return c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion) + } + if wasRunning { + if err = stopContainer(ctx, cli, current.ID); err != nil { + return err + } + } + if err = cli.ContainerStart(ctx, current.ID, container.StartOptions{}); err == nil { + err = c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion) + } + if err == nil { + return nil + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + stopErr := stopContainer(rollbackCtx, cli, current.ID) + if wasRunning { + stopErr = errors.Join(stopErr, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})) + } + return errors.Join(errors.New("MCP container failed to start or become healthy"), stopErr) + } + previous, err := c.owned(ctx, cli, previousName) + if err != nil { + return err + } + if isRunning(previous) { + return errors.New("MCP previous container is unexpectedly running; resolve its state before upgrading") + } + if matchesRuntimeWithCapabilities(ctx, cli, previous, img.ID, req, verified.metadata.AdapterVersion) { + return c.restorePrevious(ctx, cli, current, previous, req, verified.metadata.AdapterVersion) + } + if previous != nil { + if err = cli.ContainerRemove(ctx, previous.ID, container.RemoveOptions{}); err != nil { + return errors.New("MCP previous container could not be removed") + } + } + wasRunning := isRunning(current) + if current != nil { + if wasRunning { + if err = stopContainer(ctx, cli, current.ID); err != nil { + return err + } + } + if err = cli.ContainerRename(ctx, current.ID, previousName); err != nil { + if wasRunning { + rollbackCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + err = errors.Join(err, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})) + } + return errors.Join(errors.New("MCP old container could not be retained for rollback"), err) + } + } + config, host := runtimeConfig(img.ID, req, verified.metadata.AdapterVersion) + created, err := cli.ContainerCreate(ctx, config, host, nil, nil, ContainerName) + if err == nil { + err = cli.ContainerStart(ctx, created.ID, container.StartOptions{}) + } + if err == nil { + err = c.waitHealthy(ctx, cli, created.ID, req, verified.metadata.AdapterVersion) + } + if err == nil { + return nil + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + var rollbackErr error + if created.ID != "" { + rollbackErr = cli.ContainerRemove(rollbackCtx, created.ID, container.RemoveOptions{Force: true}) + } + if current != nil && rollbackErr == nil { + rollbackErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName) + if rollbackErr == nil && wasRunning { + rollbackErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}) + } + } + return errors.Join(errors.New("MCP replacement failed to start or become healthy; previous container restored where possible"), rollbackErr) +} + +func (c *Controller) restorePrevious(ctx context.Context, cli DockerClient, current, previous *container.InspectResponse, req Request, version string) error { + wasRunning := isRunning(current) + if wasRunning { + if err := stopContainer(ctx, cli, current.ID); err != nil { + return err + } + } + temporary := ContainerName + "-rollback-" + strings.TrimPrefix(previous.ID, "sha256:")[:min(12, len(strings.TrimPrefix(previous.ID, "sha256:")))] + if current != nil { + if err := cli.ContainerRename(ctx, current.ID, temporary); err != nil { + rollbackCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if wasRunning { + err = errors.Join(err, cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{})) + } + return errors.Join(errors.New("MCP current container could not be retained for rollback"), err) + } + } + if err := cli.ContainerRename(ctx, previous.ID, ContainerName); err != nil { + rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var restoreErr error + if current != nil { + restoreErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName) + if restoreErr == nil && wasRunning { + restoreErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}) + } + } + return errors.Join(errors.New("MCP previous container could not be restored"), restoreErr) + } + var err error + if req.Enabled { + err = cli.ContainerStart(ctx, previous.ID, container.StartOptions{}) + if err == nil { + err = c.waitHealthy(ctx, cli, previous.ID, req, version) + } + } + if err == nil { + if current != nil { + err = cli.ContainerRename(ctx, current.ID, previousName) + } + if err == nil { + return nil + } + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + rollbackErr := stopContainer(rollbackCtx, cli, previous.ID) + rollbackErr = errors.Join(rollbackErr, cli.ContainerRename(rollbackCtx, previous.ID, previousName)) + if current != nil && rollbackErr == nil { + rollbackErr = cli.ContainerRename(rollbackCtx, current.ID, ContainerName) + if rollbackErr == nil && wasRunning { + rollbackErr = cli.ContainerStart(rollbackCtx, current.ID, container.StartOptions{}) + } + } + return errors.Join(errors.New("MCP previous container failed its health check"), rollbackErr) +} + +func (c *Controller) rollback(ctx context.Context, cli DockerClient, req Request) error { + if (req.ImageID != "" && !digestPattern.MatchString(req.ImageID)) || (req.Enabled && req.ImageID == "") { + return errors.New("invalid MCP rollback image identity") + } + current, err := c.owned(ctx, cli, ContainerName) + if err != nil { + return err + } + previous, err := c.owned(ctx, cli, previousName) + if err != nil { + return err + } + var target *container.InspectResponse + for _, candidate := range []*container.InspectResponse{current, previous} { + if candidate != nil && candidate.Image == req.ImageID && candidate.Config.Labels[imageLabel] == req.Image && candidate.Config.Labels[portLabel] == strconv.Itoa(req.Port) { + target = candidate + break + } + } + if target == nil { + if !req.Enabled { + if isRunning(current) { + return stopContainer(ctx, cli, current.ID) + } + return nil + } + return errors.New("the previous MCP runtime instance is unavailable for rollback") + } + verified, err := c.verify(ctx, cli, target.Image, req.Image) + if err != nil { + return err + } + if !matchesRuntimeWithCapabilities(ctx, cli, target, req.ImageID, req, verified.metadata.AdapterVersion) { + return errors.New("the previous MCP runtime configuration is unsafe or incompatible") + } + if req.Enabled { + if err = c.checkDirectory(); err != nil { + return err + } + } + if target == previous { + if isRunning(previous) { + return errors.New("MCP previous container is unexpectedly running") + } + return c.restorePrevious(ctx, cli, current, previous, req, verified.metadata.AdapterVersion) + } + if !req.Enabled { + if isRunning(current) { + return stopContainer(ctx, cli, current.ID) + } + return nil + } + if !isRunning(current) { + if err = cli.ContainerStart(ctx, current.ID, container.StartOptions{}); err != nil { + return errors.New("MCP rollback instance failed to start") + } + } + return c.waitHealthy(ctx, cli, current.ID, req, verified.metadata.AdapterVersion) +} + +// rollbackAbsent removes only the fixed owned instance created by a failed +// first installation. It never resolves a tag, pulls, or creates a runtime. +func (c *Controller) rollbackAbsent(ctx context.Context, cli DockerClient, req Request) error { + if req.Enabled || !digestPattern.MatchString(req.ImageID) { + return errors.New("invalid MCP absent-runtime rollback identity") + } + current, err := c.owned(ctx, cli, ContainerName) + if err != nil || current == nil { + return err + } + if current.Image != req.ImageID || current.Config.Labels[imageLabel] != req.Image || current.Config.Labels[portLabel] != strconv.Itoa(req.Port) { + return errors.New("MCP runtime changed after installation; absent-runtime rollback was rejected") + } + if _, err = c.resolve(ctx, cli, current.Image); err != nil { + return err + } + verified, err := c.verify(ctx, cli, current.Image, req.Image) + if err != nil { + return err + } + if !matchesRuntimeWithCapabilities(ctx, cli, current, req.ImageID, req, verified.metadata.AdapterVersion) { + return errors.New("MCP installed runtime configuration is unsafe or incompatible") + } + if err = cli.ContainerRemove(ctx, current.ID, container.RemoveOptions{Force: true}); err != nil && !errdefs.IsNotFound(err) { + return errors.New("MCP failed installation could not be removed") + } + return nil +} + +func (c *Controller) waitHealthy(ctx context.Context, cli DockerClient, id string, req Request, version string) error { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + for { + if ctx.Err() != nil { + return errors.New("MCP container did not become healthy before the timeout") + } + info, err := cli.ContainerInspect(ctx, id) + if err == nil && isRunning(&info) { + if info.Config == nil || info.Config.Labels[ownerLabel] != "true" || !matchesRuntimeWithCapabilities(ctx, cli, &info, req.ImageID, req, version) { + return errors.New("MCP container configuration or metadata does not match the verified image") + } + health, healthErr := c.health(ctx, req.Port) + if healthErr == nil && ctx.Err() == nil && validMetadata(health) && health.Status == "ok" && health.AdapterVersion == version { + return nil + } + } + select { + case <-ctx.Done(): + return errors.New("MCP container did not become healthy before the timeout") + case <-time.After(250 * time.Millisecond): + } + } +} diff --git a/agent/utils/mysql/client/local.go b/agent/utils/mysql/client/local.go index 89bb03827..5093b8422 100644 --- a/agent/utils/mysql/client/local.go +++ b/agent/utils/mysql/client/local.go @@ -26,6 +26,8 @@ type Local struct { Database string Password string ContainerName string + // sqlContext belongs to a request-scoped copy, never the reusable client. + sqlContext context.Context } func NewLocal(command []string, dbType, containerName, password, database string) *Local { @@ -47,6 +49,26 @@ func (r *Local) Create(info CreateInfo) error { return nil } +// CreateWithContext shares one deadline across all creation and rollback SQL. +// Copying the client keeps concurrent or subsequent ordinary calls independent. +func (r *Local) CreateWithContext(ctx context.Context, info CreateInfo) error { + if ctx == nil { + return errors.New("mysql creation context is required") + } + scoped := *r + scoped.PrefixCommand = append([]string(nil), r.PrefixCommand...) + scoped.sqlContext = ctx + return scoped.Create(info) +} + +func (r *Local) commandContext(timeout uint) (context.Context, context.CancelFunc) { + parent := r.sqlContext + if parent == nil { + parent = context.Background() + } + return context.WithTimeout(parent, time.Duration(timeout)*time.Second) +} + func (r *Local) CreateDatabase(info CreateInfo) error { createSql := fmt.Sprintf("create database `%s` default character set %s collate %s", info.Name, info.Format, info.Collation) if len(info.Collation) == 0 { @@ -396,15 +418,18 @@ func (r *Local) SyncDB(version string) ([]SyncDBInfo, error) { func (r *Local) Close() {} func (r *Local) ExecSQL(command string, timeout uint) error { - itemCommand := r.PrefixCommand[:] + itemCommand := append([]string(nil), r.PrefixCommand...) itemCommand = append(itemCommand, command) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + ctx, cancel := r.commandContext(timeout) defer cancel() cmd := exec.CommandContext(ctx, "docker", itemCommand...) stdout, err := cmd.CombinedOutput() if errors.Is(ctx.Err(), context.DeadlineExceeded) { return buserr.New("ErrExecTimeOut") } + if ctx.Err() != nil { + return ctx.Err() + } stdStr := strings.ReplaceAll(string(stdout), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "") if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") { return errors.New(stdStr) @@ -413,15 +438,18 @@ func (r *Local) ExecSQL(command string, timeout uint) error { } func (r *Local) ExecSQLForRows(command string, timeout uint) ([]string, error) { - itemCommand := r.PrefixCommand[:] + itemCommand := append([]string(nil), r.PrefixCommand...) itemCommand = append(itemCommand, command) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + ctx, cancel := r.commandContext(timeout) defer cancel() cmd := exec.CommandContext(ctx, "docker", itemCommand...) stdout, err := cmd.CombinedOutput() if errors.Is(ctx.Err(), context.DeadlineExceeded) { return nil, buserr.New("ErrExecTimeOut") } + if ctx.Err() != nil { + return nil, ctx.Err() + } stdStr := strings.ReplaceAll(string(stdout), "mysql: [Warning] Using a password on the command line interface can be insecure.\n", "") if err != nil || strings.HasPrefix(string(stdStr), "ERROR ") { return nil, errors.New(stdStr) diff --git a/core/app/api/v2/helper/helper.go b/core/app/api/v2/helper/helper.go index 4f7d10e2b..5247b4815 100644 --- a/core/app/api/v2/helper/helper.go +++ b/core/app/api/v2/helper/helper.go @@ -10,10 +10,14 @@ import ( "github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/global" "github.com/1Panel-dev/1Panel/core/i18n" + mcpmeta "github.com/1Panel-dev/1Panel/core/utils/mcpapi/metadata" "github.com/gin-gonic/gin" ) func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) { + if msgKey == "ErrProxy" && ctx.GetBool("MCP_API_AUTH") && ctx.Request != nil && mcpmeta.TrustedRequest(ctx.Request) { + ctx.Header(mcpmeta.BusinessCodeHeader, "ErrProxy") + } res := dto.Response{ Code: code, Message: "", diff --git a/core/app/api/v2/mcp.go b/core/app/api/v2/mcp.go new file mode 100644 index 000000000..662ff2e06 --- /dev/null +++ b/core/app/api/v2/mcp.go @@ -0,0 +1,374 @@ +package v2 + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/service" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/gin-gonic/gin" +) + +func mcpRead(c *gin.Context, value any) bool { + data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)) + if err == nil { + err = contract.DecodeObject(data, value) + } + if err == nil { + switch value.(type) { + case *dto.MCPCredentialCreate, *dto.MCPCredentialUpdate: + var fields struct { + ExpiresAt json.RawMessage `json:"expiresAt"` + } + if json.Unmarshal(data, &fields) != nil || len(fields.ExpiresAt) == 0 { + err = contract.Invalid() + } + } + } + if err != nil { + mcpManagementError(c, contract.Invalid()) + return false + } + return true +} +func mcpManagementError(c *gin.Context, err error) { + code := http.StatusInternalServerError + errorCode := "MCP_OPERATION_FAILED" + invocationID := "" + var known *contract.Error + if errors.As(err, &known) { + code = known.Status + errorCode = known.Code + invocationID = known.InvocationID + } else if errors.Is(err, service.ErrMCPSettingsRevision) { + code = 409 + errorCode = "MCP_CONFLICT" + } + c.Header("Cache-Control", "no-store") + c.JSON(200, struct { + dto.Response + ErrorCode string `json:"errorCode"` + InvocationID string `json:"invocationId,omitempty"` + }{Response: dto.Response{Code: code, Message: mcpManagementMessage(errorCode)}, ErrorCode: errorCode, InvocationID: invocationID}) + c.Abort() +} +func mcpManagement(c *gin.Context, write bool, admin bool) (*service.MCPService, auth.APIKeyOwner, bool) { + s := service.NewMCPService() + owner, err := s.ManagementOwner(c, write) + if err != nil { + mcpManagementError(c, err) + return nil, owner, false + } + if admin && !owner.IsSuperAdmin { + mcpManagementError(c, contract.Forbidden()) + return nil, owner, false + } + c.Header("Cache-Control", "no-store") + return s, owner, true +} +func (b *BaseApi) MCPCatalog(c *gin.Context) { + s, owner, ok := mcpManagement(c, false, false) + if ok { + catalog, err := s.Catalog(owner) + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, catalog) + } +} +func (b *BaseApi) MCPNodes(c *gin.Context) { + s, owner, ok := mcpManagement(c, false, false) + if !ok { + return + } + if c.Request.URL.RawQuery != "" { + mcpManagementError(c, contract.Invalid()) + return + } + nodes, err := s.Nodes(owner) + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, nodes) +} +func (b *BaseApi) SearchMCPCredentials(c *gin.Context) { + s, owner, ok := mcpManagement(c, false, false) + if !ok { + return + } + var req dto.MCPCredentialSearch + if !mcpRead(c, &req) { + return + } + result, err := s.Search(owner, req) + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, result) +} +func (b *BaseApi) CreateMCPCredential(c *gin.Context) { + s, owner, ok := mcpManagement(c, true, false) + if !ok { + return + } + var req dto.MCPCredentialCreate + if !mcpRead(c, &req) { + return + } + result, err := s.Create(owner, req) + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, result) +} +func (b *BaseApi) UpdateMCPCredential(c *gin.Context) { + s, owner, ok := mcpManagement(c, true, false) + if !ok { + return + } + var req dto.MCPCredentialUpdate + if !mcpRead(c, &req) { + return + } + if err := s.Update(owner, req); err != nil { + mcpManagementError(c, err) + return + } + helper.Success(c) +} +func (b *BaseApi) StatusMCPCredential(c *gin.Context) { + s, owner, ok := mcpManagement(c, true, false) + if !ok { + return + } + var req dto.MCPCredentialStatus + if !mcpRead(c, &req) { + return + } + if err := s.Status(owner, req); err != nil { + mcpManagementError(c, err) + return + } + helper.Success(c) +} +func (b *BaseApi) RevokeMCPCredential(c *gin.Context) { + s, owner, ok := mcpManagement(c, true, false) + if !ok { + return + } + var req dto.MCPCredentialMutation + if !mcpRead(c, &req) { + return + } + if err := s.Revoke(owner, req); err != nil { + mcpManagementError(c, err) + return + } + helper.Success(c) +} +func (b *BaseApi) SearchMCPInvocations(c *gin.Context) { + s, owner, ok := mcpManagement(c, false, false) + if !ok { + return + } + var req dto.MCPInvocationSearch + if !mcpRead(c, &req) { + return + } + result, err := s.Invocations(owner, req) + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, result) +} +func (b *BaseApi) MCPServiceStatus(c *gin.Context) { + _, owner, ok := mcpManagement(c, false, false) + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + result, err := service.NewMCPRuntimeService().Status(ctx) + if err != nil { + mcpManagementError(c, err) + return + } + result.Endpoint = mcpPublicEndpoint(c, result.Endpoint) + result.CanManageService = owner.IsSuperAdmin + helper.SuccessWithData(c, result) +} +func (b *BaseApi) MCPSettings(c *gin.Context) { + _, _, ok := mcpManagement(c, false, false) + if !ok { + return + } + result, err := service.NewMCPRuntimeService().Settings() + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, result) +} +func (b *BaseApi) UpdateMCPSettings(c *gin.Context) { + _, _, ok := mcpManagement(c, true, true) + if !ok { + return + } + var req dto.MCPRuntimeSettingsUpdate + if !mcpRead(c, &req) { + return + } + result, err := service.NewMCPRuntimeService().Update(req) + if err != nil { + mcpServiceError(c, err) + return + } + helper.SuccessWithData(c, result) +} +func (b *BaseApi) OperateMCPService(c *gin.Context) { + _, _, ok := mcpManagement(c, true, true) + if !ok { + return + } + var req struct { + Operation string `json:"operation"` + } + if !mcpRead(c, &req) { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 55*time.Second) + defer cancel() + result, err := service.NewMCPRuntimeService().Operate(ctx, req.Operation) + if err != nil { + mcpServiceError(c, err) + return + } + helper.SuccessWithData(c, result) +} +func (b *BaseApi) InstallMCPService(c *gin.Context) { + _, _, ok := mcpManagement(c, true, true) + if !ok { + return + } + var req dto.MCPRuntimeInstall + if !mcpRead(c, &req) { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Minute) + defer cancel() + result, err := service.NewMCPRuntimeService().Install(ctx, req) + if err != nil { + mcpServiceError(c, err) + return + } + helper.SuccessWithData(c, result) +} +func (b *BaseApi) MCPServiceLogs(c *gin.Context) { + _, _, ok := mcpManagement(c, false, true) + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + result, err := service.NewMCPRuntimeService().Logs(ctx) + if err != nil { + mcpServiceError(c, err) + return + } + helper.SuccessWithData(c, result) +} +func mcpServiceError(c *gin.Context, err error) { + var known *contract.Error + if errors.Is(err, service.ErrMCPSettingsRevision) || errors.As(err, &known) { + mcpManagementError(c, err) + return + } + helper.InternalServer(c, err) +} + +func MCPRuntimeTools(c *gin.Context) { + c.Header("Cache-Control", "no-store") + token, err := service.MCPBearer(c) + if err != nil { + MCPRuntimeError(c, err) + return + } + if !mcpRuntimeProof(c, token) { + return + } + result, err := service.NewMCPService().RuntimeTools(c, token) + if err != nil { + MCPRuntimeError(c, err) + return + } + c.JSON(200, result) +} +func MCPRuntimeInvoke(c *gin.Context) { + c.Header("Cache-Control", "no-store") + token, err := service.MCPBearer(c) + if err != nil { + MCPRuntimeError(c, err) + return + } + if !mcpRuntimeProof(c, token) { + return + } + data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)) + var req contract.Invoke + if err == nil { + err = contract.DecodeObject(data, &req) + } + if err == nil { + req.NodeID, err = contract.NormalizeNodeID(req.NodeID) + } + if err != nil || req.AdmissionID == "" { + MCPRuntimeError(c, contract.Invalid()) + return + } + result, err := service.NewMCPService().Invoke(c, token, req) + if err != nil { + MCPRuntimeError(c, err) + return + } + c.JSON(200, result) +} +func MCPRuntimeError(c *gin.Context, err error) { + var known *contract.Error + if !errors.As(err, &known) { + known = contract.Fail("MCP_EXECUTION_FAILED", 503, "MCP service is unavailable") + } + c.Header("Cache-Control", "no-store") + if known.RetryAfterSeconds > 0 { + c.Header("Retry-After", strconv.Itoa(known.RetryAfterSeconds)) + } + c.JSON(known.Status, known) + c.Abort() +} + +func mcpPublicEndpoint(c *gin.Context, configured string) string { + if configured != "" { + return configured + } + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if c.Request.Host == "" { + return "" + } + return (&url.URL{Scheme: scheme, Host: c.Request.Host, Path: "/mcp"}).String() +} diff --git a/core/app/api/v2/mcp_admission.go b/core/app/api/v2/mcp_admission.go new file mode 100644 index 000000000..95d6d1fea --- /dev/null +++ b/core/app/api/v2/mcp_admission.go @@ -0,0 +1,70 @@ +package v2 + +import ( + "io" + "time" + + "github.com/1Panel-dev/1Panel/core/app/service" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/gin-gonic/gin" + "net/http" +) + +func mcpRuntimeProof(c *gin.Context, token string) bool { + source, err := contract.VerifyAccessProof(c.GetHeader(contract.AccessProofHeader), token, time.Now()) + if err != nil { + MCPRuntimeError(c, err) + return false + } + c.Set("MCP_CLIENT_IP", source) + return true +} + +func MCPRuntimeAdmit(c *gin.Context) { + c.Header("Cache-Control", "no-store") + token, err := service.MCPBearer(c) + if err != nil { + MCPRuntimeError(c, err) + return + } + if !mcpRuntimeProof(c, token) { + return + } + data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)) + var req contract.Admission + if err != nil || contract.DecodeObject(data, &req) != nil { + MCPRuntimeError(c, contract.Invalid()) + return + } + result, err := service.NewMCPService().Admit(c, token, req) + if err != nil { + MCPRuntimeError(c, err) + return + } + c.JSON(200, result) +} + +func MCPRuntimeComplete(c *gin.Context) { + c.Header("Cache-Control", "no-store") + token, err := service.MCPBearer(c) + if err != nil { + MCPRuntimeError(c, err) + return + } + data, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, 1024)) + var req contract.Completion + if err != nil || contract.DecodeObject(data, &req) != nil { + MCPRuntimeError(c, contract.Invalid()) + return + } + result, err := service.NewMCPService().CompleteWithOutcome(token, req) + if err != nil { + MCPRuntimeError(c, err) + return + } + if req.ErrorCode != "" { + c.JSON(http.StatusOK, result) + return + } + c.Status(204) +} diff --git a/core/app/api/v2/mcp_enrollment.go b/core/app/api/v2/mcp_enrollment.go new file mode 100644 index 000000000..6b58f8d6a --- /dev/null +++ b/core/app/api/v2/mcp_enrollment.go @@ -0,0 +1,64 @@ +package v2 + +import ( + "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/service" + "github.com/gin-gonic/gin" +) + +func (b *BaseApi) RevealMCPCredential(c *gin.Context) { + s, owner, ok := mcpManagement(c, true, false) + if !ok { + return + } + var req dto.MCPCredentialMutation + if !mcpRead(c, &req) { + return + } + result, err := s.Reveal(owner, req) + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, result) +} + +func (b *BaseApi) RotateMCPCredential(c *gin.Context) { + s, owner, ok := mcpManagement(c, true, false) + if !ok { + return + } + var req dto.MCPCredentialMutation + if !mcpRead(c, &req) { + return + } + result, err := s.Rotate(owner, req) + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, result) +} + +func (b *BaseApi) ExportMCPCredential(c *gin.Context) { + s, owner, ok := mcpManagement(c, true, false) + if !ok { + return + } + var req dto.MCPEnrollmentExport + if !mcpRead(c, &req) { + return + } + settings, err := service.NewMCPRuntimeService().Settings() + if err != nil { + mcpManagementError(c, err) + return + } + result, err := s.ExportMarkdown(owner, req, mcpPublicEndpoint(c, settings.ExternalURL), c.GetHeader("Accept-Language")) + if err != nil { + mcpManagementError(c, err) + return + } + helper.SuccessWithData(c, result) +} diff --git a/core/app/api/v2/mcp_errors.go b/core/app/api/v2/mcp_errors.go new file mode 100644 index 000000000..2ea337be3 --- /dev/null +++ b/core/app/api/v2/mcp_errors.go @@ -0,0 +1,36 @@ +package v2 + +import "github.com/1Panel-dev/1Panel/core/i18n" + +// Management responses use panel localization while runtime errors retain their +// stable machine contract. Never render raw errors or credential data here. +func mcpManagementMessage(code string) string { + key := "ErrInternalServer" + switch code { + case "MCP_INVALID_ARGUMENTS": + key = "ErrInvalidParams" + case "MCP_UNAUTHENTICATED": + key = "ErrNotLogin" + case "MCP_FORBIDDEN": + key = "ErrRBAC" + case "MCP_CONFLICT": + key = "ErrMCPConflict" + case "MCP_POLICY_CHANGED", "MCP_SCHEMA_MISMATCH": + key = "ErrMCPPolicyChanged" + case "MCP_CATALOG_UNAVAILABLE": + key = "ErrMCPCatalogUnavailable" + case "MCP_DISABLED": + key = "ErrMCPDisabled" + case "MCP_SECRET_KEY_UNAVAILABLE": + key = "ErrMCPSecretKeyUnavailable" + case "MCP_SECRET_INVALID": + key = "ErrMCPSecretInvalid" + case "MCP_CREDENTIAL_INACTIVE": + key = "ErrMCPCredentialInactive" + case "MCP_AUDIT_UNAVAILABLE": + key = "ErrMCPAuditUnavailable" + case "MCP_ENDPOINT_UNAVAILABLE": + key = "ErrMCPEndpointUnavailable" + } + return i18n.GetMsgWithMap(key, nil) +} diff --git a/core/app/dto/mcp.go b/core/app/dto/mcp.go new file mode 100644 index 000000000..f83209c1a --- /dev/null +++ b/core/app/dto/mcp.go @@ -0,0 +1,62 @@ +package dto + +import "time" + +type MCPCredentialFields struct { + Name string `json:"name"` + Description string `json:"description"` + ExpiresAt *time.Time `json:"expiresAt"` + Tools []string `json:"tools"` + ToolPolicies map[string]string `json:"toolPolicies"` + ToolReadOnly map[string]bool `json:"toolReadOnly"` + NodeIDs []string `json:"nodeIds"` +} +type MCPNodeItem struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + IsLocal bool `json:"isLocal"` +} +type MCPCredentialCreate struct { + RequestID string `json:"requestID"` + MCPCredentialFields +} +type MCPCredentialMutation struct { + ID string `json:"id"` + Revision uint64 `json:"revision"` +} +type MCPCredentialUpdate struct { + MCPCredentialMutation + MCPCredentialFields +} +type MCPCredentialStatus struct { + MCPCredentialMutation + Status string `json:"status"` +} +type MCPCredentialItem struct { + ID string `json:"id"` + MCPCredentialFields + KeyHint string `json:"keyHint"` + Status string `json:"status"` + Revision uint64 `json:"revision"` + CreatedAt time.Time `json:"createdAt"` + OwnerName string `json:"ownerName"` + StaleTools []string `json:"staleTools"` +} +type MCPCredentialCreated struct { + Item MCPCredentialItem `json:"item"` + Token string `json:"token"` + AlreadyCreated bool `json:"alreadyCreated,omitempty"` +} +type MCPCredentialSearch struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + ExcludeRevoked bool `json:"excludeRevoked"` +} +type MCPInvocationSearch struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + CredentialID string `json:"credentialId"` + ToolID string `json:"toolId"` + Status string `json:"status"` +} diff --git a/core/app/dto/mcp_enrollment.go b/core/app/dto/mcp_enrollment.go new file mode 100644 index 000000000..17411dd2a --- /dev/null +++ b/core/app/dto/mcp_enrollment.go @@ -0,0 +1,16 @@ +package dto + +type MCPCredentialSecret struct { + ID string `json:"id"` + Token string `json:"token"` +} + +type MCPEnrollmentExport struct { + MCPCredentialMutation + Client string `json:"client"` +} + +type MCPEnrollmentDocument struct { + Filename string `json:"filename"` + Content string `json:"content"` +} diff --git a/core/app/dto/mcp_runtime.go b/core/app/dto/mcp_runtime.go new file mode 100644 index 000000000..2f0a8b5ef --- /dev/null +++ b/core/app/dto/mcp_runtime.go @@ -0,0 +1,43 @@ +package dto + +type MCPRuntimeSettings struct { + RuntimeType string `json:"runtimeType"` + DockerImage string `json:"dockerImage"` + Enabled bool `json:"enabled"` + ListenPort int `json:"listenPort"` + ExternalURL string `json:"externalUrl"` + Revision uint64 `json:"revision"` + AccessPolicy string `json:"accessPolicy"` + AllowedIPs []string `json:"allowedIPs"` +} + +type MCPRuntimeSettingsUpdate = MCPRuntimeSettings + +type MCPRuntimeInstall struct { + DockerImage string `json:"dockerImage"` + ListenPort int `json:"listenPort"` + ExternalURL string `json:"externalUrl"` + Revision uint64 `json:"revision"` +} + +type MCPRuntimeStatus struct { + RuntimeType string `json:"runtimeType"` + DockerImage string `json:"dockerImage"` + ContainerName string `json:"containerName"` + ImageID string `json:"imageId"` + Enabled bool `json:"enabled"` + Installed bool `json:"installed"` + Status string `json:"status"` + Ready bool `json:"ready"` + Reason string `json:"reason"` + AdapterVersion string `json:"adapterVersion"` + ContractVersion int `json:"contractVersion"` + Protocol string `json:"protocol"` + InternalEndpoint string `json:"internalEndpoint"` + Endpoint string `json:"endpoint"` + CanManageService bool `json:"canManageService"` +} + +type MCPRuntimeLogs struct { + Content string `json:"content"` +} diff --git a/core/app/model/mcp.go b/core/app/model/mcp.go new file mode 100644 index 000000000..d725ea4e1 --- /dev/null +++ b/core/app/model/mcp.go @@ -0,0 +1,64 @@ +package model + +import "time" + +type MCPCredential struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"-"` + OwnerType string `gorm:"not null;uniqueIndex:idx_mcp_request;uniqueIndex:idx_mcp_name;index:idx_mcp_owner" json:"-"` + OwnerID string `gorm:"not null;uniqueIndex:idx_mcp_request;uniqueIndex:idx_mcp_name;index:idx_mcp_owner" json:"-"` + OwnerName string `json:"-"` + Name string `gorm:"not null" json:"-"` + ActiveName *string `gorm:"uniqueIndex:idx_mcp_name" json:"-"` + Description string `json:"-"` + NodeIDs string `gorm:"type:text;not null" json:"-"` + RequestID string `gorm:"not null;uniqueIndex:idx_mcp_request" json:"-"` + RequestHash string `json:"-"` + TokenHash string `gorm:"uniqueIndex;not null" json:"-"` + SecretCiphertext string `gorm:"type:text" json:"-"` + SecretVersion int `gorm:"not null;default:0" json:"-"` + KeyHint string `json:"-"` + Status string `gorm:"not null" json:"-"` + ExpiresAt *time.Time `json:"-"` + Revision uint64 `gorm:"not null" json:"-"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` + RevokedAt *time.Time `json:"-"` +} +type MCPGrant struct { + CredentialID string `gorm:"primaryKey"` + ToolID string `gorm:"primaryKey"` + PolicyHash string `gorm:"not null"` + AuthorizationSnapshot string `gorm:"type:text;not null" json:"-"` +} + +type MCPInvocation struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + RequestID string `json:"requestId"` + CredentialID string `gorm:"index" json:"credentialId"` + CredentialName string `json:"credentialName"` + OwnerType string `gorm:"index:idx_mcp_invocation_owner" json:"-"` + OwnerID string `gorm:"index:idx_mcp_invocation_owner" json:"-"` + OwnerName string `json:"ownerName"` + ToolID string `gorm:"index" json:"toolId"` + OperationID string `json:"operationId"` + PolicyHash string `json:"policyHash"` + NodeID string `json:"nodeId"` + Revision uint64 `json:"revision"` + Status string `gorm:"index" json:"status"` + Code string `json:"code"` + StartedAt time.Time `gorm:"index" json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + DurationMs int64 `json:"durationMs"` +} + +type MCPSetting struct { + RuntimeType string `gorm:"not null;default:docker"` + DockerImage string `gorm:"not null;default:1panel/1panel-mcp-server:v0.0.1"` + ID uint `gorm:"primaryKey"` + Enabled bool `gorm:"not null;default:false"` + ListenPort int `gorm:"not null;default:18081"` + ExternalURL string + AccessPolicy string `gorm:"not null;default:inherit"` + AllowedIPs string `gorm:"type:text;not null;default:'[]'"` + Revision uint64 `gorm:"not null;default:1"` +} diff --git a/core/app/repo/mcp.go b/core/app/repo/mcp.go new file mode 100644 index 000000000..af939b172 --- /dev/null +++ b/core/app/repo/mcp.go @@ -0,0 +1,31 @@ +package repo + +import ( + "github.com/1Panel-dev/1Panel/core/app/model" + "gorm.io/gorm" +) + +type MCPRepo struct{ DB *gorm.DB } + +func (r MCPRepo) Token(hash string) (model.MCPCredential, error) { + var key model.MCPCredential + err := r.DB.Where("token_hash = ?", hash).First(&key).Error + return key, err +} +func (r MCPRepo) Grants(id string) ([]model.MCPGrant, error) { + grants := []model.MCPGrant{} + err := r.DB.Where("credential_id = ?", id).Order("tool_id").Find(&grants).Error + return grants, err +} +func (r MCPRepo) ReplaceTools(id string, grants []model.MCPGrant) error { + if err := r.DB.Where("credential_id = ?", id).Delete(&model.MCPGrant{}).Error; err != nil { + return err + } + for _, grant := range grants { + grant.CredentialID = id + if err := r.DB.Create(&grant).Error; err != nil { + return err + } + } + return nil +} diff --git a/core/app/service/mcp.go b/core/app/service/mcp.go new file mode 100644 index 000000000..c45dabb33 --- /dev/null +++ b/core/app/service/mcp.go @@ -0,0 +1,793 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "slices" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/global" + "github.com/1Panel-dev/1Panel/core/utils/encrypt" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/1Panel-dev/1Panel/core/utils/xpack" + "github.com/1Panel-dev/1Panel/core/utils/xpack/providers" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +var mcpMutationMu sync.Mutex +var mcpExecutionSlots = make(chan struct{}, 8) +var mcpCallWindow = struct { + sync.Mutex + entries map[string]mcpWindow +}{entries: make(map[string]mcpWindow)} + +type mcpWindow struct { + start time.Time + calls int +} + +func admitMCPCall(id string, now time.Time) bool { + mcpCallWindow.Lock() + defer mcpCallWindow.Unlock() + window, exists := mcpCallWindow.entries[id] + if !exists || now.Sub(window.start) >= time.Minute { + if len(mcpCallWindow.entries) >= 4096 { + for key, value := range mcpCallWindow.entries { + if now.Sub(value.start) >= time.Minute { + delete(mcpCallWindow.entries, key) + } + } + if len(mcpCallWindow.entries) >= 4096 && !exists { + return false + } + } + window = mcpWindow{start: now} + } + if window.calls >= 120 { + return false + } + window.calls++ + mcpCallWindow.entries[id] = window + return true +} + +type MCPManagementAuthorizer interface { + AuthorizeMCPManagement(auth.APIKeyOwner, string, []string) error +} +type MCPAPIAuthorizer interface { + AuthorizeMCPAPI(auth.APIKeyOwner, string, string, string) error +} +type MCPExecutor func(context.Context, contract.APIRequest) (any, error) +type MCPService struct { + DB *gorm.DB + Provider auth.APIKeyOwnerProvider + Authorizer MCPManagementAuthorizer + APIAuthorizer MCPAPIAuthorizer + NodeProvider providers.MCPNodeProvider + Execute MCPExecutor + Enabled func() (bool, error) + Now func() time.Time + LoadCatalog func() ([]contract.Tool, error) + SecretKey func() (string, error) + EnrollmentAudit func(*gorm.DB, *model.OperationLog) error +} + +var NewMCPService = func() *MCPService { + owner, _ := xpack.AuthProvider.(auth.APIKeyOwnerProvider) + authorizer, _ := xpack.AuthProvider.(MCPManagementAuthorizer) + apiAuthorizer, _ := xpack.AuthProvider.(MCPAPIAuthorizer) + nodeProvider, _ := xpack.AuthProvider.(providers.MCPNodeProvider) + return &MCPService{DB: global.DB, Provider: owner, Authorizer: authorizer, APIAuthorizer: apiAuthorizer, NodeProvider: nodeProvider, Execute: executeMCPAPI, Now: time.Now, SecretKey: encrypt.APIKeyEncryptionKey, LoadCatalog: func() ([]contract.Tool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return NewMCPRuntimeService().Catalog(ctx) + }, Enabled: func() (bool, error) { + var setting struct{ Enabled bool } + err := global.DB.Table("mcp_settings").Where("id = ?", 1).Take(&setting).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return setting.Enabled, err + }} +} + +func (s *MCPService) authorize(owner auth.APIKeyOwner, codes ...string) error { + if s.Authorizer == nil || s.Authorizer.AuthorizeMCPManagement(owner, "local", codes) != nil { + return contract.Forbidden() + } + return nil +} + +func (s *MCPService) authorizeAPI(owner auth.APIKeyOwner, nodeID, method, path string) error { + if s.APIAuthorizer == nil || s.APIAuthorizer.AuthorizeMCPAPI(owner, nodeID, method, path) != nil { + return contract.Forbidden() + } + return nil +} +func (s *MCPService) authorizeToolOnNode(owner auth.APIKeyOwner, nodeID string, tool contract.Tool) error { + for _, op := range tool.Operations { + if err := s.authorizeAPI(owner, nodeID, op.Method, contract.PermissionPath(op)); err != nil { + return err + } + } + return nil +} +func (s *MCPService) ManagementOwner(c *gin.Context, write bool) (auth.APIKeyOwner, error) { + if c.GetHeader("Authorization") != "" { + return auth.APIKeyOwner{}, contract.Forbidden() + } + if _, err := auth.RequireAPIKeySession(c); err != nil { + return auth.APIKeyOwner{}, contract.Fail("MCP_UNAUTHENTICATED", 401, "A panel session is required") + } + if s.Provider == nil { + return auth.APIKeyOwner{}, contract.Forbidden() + } + owner, err := s.Provider.CurrentAPIKeyOwner(c) + if err != nil { + return auth.APIKeyOwner{}, contract.Forbidden() + } + code := "mcp_access_view" + if write { + code = "mcp_access_manage" + } + if err = s.authorize(owner, code); err != nil { + return auth.APIKeyOwner{}, err + } + return owner, nil +} +func mcpOwnerQuery(db *gorm.DB, owner auth.APIKeyOwner) *gorm.DB { + return db.Where("owner_type = ? AND owner_id = ?", owner.Type, owner.ID) +} +func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 } +func (s *MCPService) catalog() ([]contract.Tool, error) { + unavailable := func() error { + return contract.Fail("MCP_CATALOG_UNAVAILABLE", 503, "The installed MCP tool catalog is unavailable or incompatible") + } + if s.LoadCatalog == nil { + return nil, unavailable() + } + tools, err := s.LoadCatalog() + if err != nil { + return nil, unavailable() + } + tools, err = contract.ValidateCatalog(tools) + if err != nil { + return nil, unavailable() + } + return tools, nil +} +func catalogIndex(tools []contract.Tool) map[string]contract.Tool { + index := make(map[string]contract.Tool, len(tools)) + for _, tool := range tools { + index[tool.ToolID] = tool + } + return index +} +func mcpGrantAllows(grant model.MCPGrant, tool contract.Tool) bool { + return grant.ToolID == tool.ToolID && grant.PolicyHash != "" && + contract.ToolAuthorizationAllows(grant.AuthorizationSnapshot, grant.PolicyHash, tool) +} +func mcpPolicyChanged() error { + return contract.Fail("MCP_POLICY_CHANGED", 409, "Tool API policy changed; reload the catalog and explicitly save the credential authorization again") +} +func (s *MCPService) Catalog(owner auth.APIKeyOwner) (map[string]any, error) { + catalog, err := s.catalog() + if err != nil { + return nil, err + } + nodes, err := s.Nodes(owner) + if err != nil { + return nil, err + } + tools := []contract.Tool{} + toolNodeIDs := make(map[string][]string) + nodeIDs := mcpNodeItemIDs(nodes) + for _, tool := range catalog { + allowed := []string{} + for _, nodeID := range nodeIDs { + if mcpIsNodeDiscovery(tool) || s.authorizeToolOnNode(owner, nodeID, tool) == nil { + allowed = append(allowed, nodeID) + } + } + if len(allowed) > 0 || mcpIsNodeDiscovery(tool) { + tools = append(tools, tool) + toolNodeIDs[tool.ToolID] = allowed + } + } + return map[string]any{"contractVersion": contract.ContractVersion, "scope": "nodes", "tools": tools, "toolNodeIds": toolNodeIDs}, nil +} +func (s *MCPService) validateFields(owner auth.APIKeyOwner, fields *dto.MCPCredentialFields, checkExpiry bool) ([]model.MCPGrant, error) { + fields.Name = strings.TrimSpace(fields.Name) + fields.Description = strings.TrimSpace(fields.Description) + if fields.Name == "" || utf8.RuneCountInString(fields.Name) > 64 || utf8.RuneCountInString(fields.Description) > 256 || strings.ContainsAny(fields.Name, "\x00\r\n") || len(fields.Tools) > 256 { + return nil, contract.Invalid() + } + if checkExpiry && fields.ExpiresAt != nil && !fields.ExpiresAt.After(s.Now()) { + return nil, contract.Invalid() + } + if err := s.validateNodeIDs(owner, &fields.NodeIDs); err != nil { + return nil, err + } + catalog, err := s.catalog() + if err != nil { + return nil, err + } + index := catalogIndex(catalog) + slices.Sort(fields.Tools) + fields.Tools = slices.Compact(fields.Tools) + if len(fields.ToolPolicies) != len(fields.Tools) { + return nil, mcpPolicyChanged() + } + if len(fields.ToolReadOnly) != len(fields.Tools) { + return nil, mcpPolicyChanged() + } + grants := make([]model.MCPGrant, 0, len(fields.Tools)) + for _, id := range fields.Tools { + tool, ok := index[id] + if !ok || fields.ToolPolicies[id] == "" || fields.ToolPolicies[id] != tool.PolicyHash { + return nil, mcpPolicyChanged() + } + readOnly, reviewed := fields.ToolReadOnly[id] + if !reviewed || readOnly != tool.ReadOnly { + return nil, mcpPolicyChanged() + } + if err := s.authorizeToolOnNodes(owner, fields.NodeIDs, tool); err != nil { + return nil, err + } + snapshot, err := contract.ToolAuthorizationSnapshot(tool) + if err != nil { + return nil, err + } + grants = append(grants, model.MCPGrant{ToolID: id, PolicyHash: tool.PolicyHash, AuthorizationSnapshot: snapshot}) + } + return grants, nil +} +func (s *MCPService) credentialItem(key model.MCPCredential, catalog map[string]contract.Tool) (dto.MCPCredentialItem, error) { + nodeIDs, err := mcpCredentialNodeIDs(key) + if err != nil { + return dto.MCPCredentialItem{}, err + } + grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID) + if err != nil { + return dto.MCPCredentialItem{}, err + } + tools := []string{} + policies := map[string]string{} + readOnly := map[string]bool{} + stale := []string{} + for _, grant := range grants { + tools = append(tools, grant.ToolID) + policies[grant.ToolID] = grant.PolicyHash + tool, exists := catalog[grant.ToolID] + if exists { + readOnly[grant.ToolID] = tool.ReadOnly + } + if exists && mcpGrantAllows(grant, tool) { + // Bind this compatibility decision to the catalog used to calculate + // it, while leaving the stored consent baseline unchanged. + policies[grant.ToolID] = tool.PolicyHash + } else { + stale = append(stale, grant.ToolID) + } + } + status := key.Status + if status != "Revoked" && key.ExpiresAt != nil && !s.Now().Before(*key.ExpiresAt) { + status = "Expired" + } + return dto.MCPCredentialItem{ID: key.ID, MCPCredentialFields: dto.MCPCredentialFields{Name: key.Name, Description: key.Description, ExpiresAt: key.ExpiresAt, Tools: tools, ToolPolicies: policies, ToolReadOnly: readOnly, NodeIDs: nodeIDs}, StaleTools: stale, KeyHint: key.KeyHint, Status: status, Revision: key.Revision, CreatedAt: key.CreatedAt, OwnerName: key.OwnerName}, nil +} +func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch) (map[string]any, error) { + if !validMCPPage(req.Page, req.PageSize) { + return nil, contract.Invalid() + } + q := mcpOwnerQuery(s.DB.Model(&model.MCPCredential{}), owner) + if req.ExcludeRevoked { + q = q.Where("status <> ?", "Revoked") + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, err + } + keys := []model.MCPCredential{} + if err := q.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&keys).Error; err != nil { + return nil, err + } + items := []dto.MCPCredentialItem{} + catalog, _ := s.catalog() + index := catalogIndex(catalog) + for _, key := range keys { + item, err := s.credentialItem(key, index) + if err != nil { + return nil, err + } + items = append(items, item) + } + return map[string]any{"items": items, "total": total}, nil +} +func tokenDigest(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} +func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate) (result *dto.MCPCredentialCreated, resultErr error) { + defer func() { + if resultErr != nil { + if err := s.auditEnrollment(s.DB, owner, "create", dto.MCPCredentialMutation{}, resultErr); err != nil { + result, resultErr = nil, err + } + } + }() + if _, err := uuid.Parse(req.RequestID); err != nil { + return nil, contract.Invalid() + } + grants, err := s.validateFields(owner, &req.MCPCredentialFields, true) + if err != nil { + return nil, err + } + fields, _ := json.Marshal(req.MCPCredentialFields) + requestHash := tokenDigest(string(fields)) + var key model.MCPCredential + var token string + repeated := false + mcpMutationMu.Lock() + err = s.DB.Transaction(func(tx *gorm.DB) error { + err := mcpOwnerQuery(tx, owner).Where("request_id = ?", req.RequestID).First(&key).Error + if err == nil { + if key.RequestHash != requestHash { + return contract.Fail("MCP_CONFLICT", 409, "Creation request already used") + } + repeated = true + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var count int64 + if err = mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ?", req.Name).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return contract.Fail("MCP_CONFLICT", 409, "Credential name already exists") + } + secret := make([]byte, 32) + if _, err = rand.Read(secret); err != nil { + return err + } + token = "mcp_" + hex.EncodeToString(secret) + key = model.MCPCredential{ID: uuid.NewString(), OwnerType: owner.Type, OwnerID: owner.ID, OwnerName: owner.Name, Name: req.Name, ActiveName: &req.Name, Description: req.Description, NodeIDs: mcpEncodeNodeIDs(req.NodeIDs), RequestID: req.RequestID, RequestHash: requestHash, TokenHash: tokenDigest(token), KeyHint: token[:8] + "…" + token[len(token)-4:], Status: "Enable", ExpiresAt: req.ExpiresAt, Revision: 1} + if err = s.sealCredentialToken(&key, token); err != nil { + return err + } + if err = tx.Create(&key).Error; err != nil { + return err + } + if err = (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants); err != nil { + return err + } + return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil) + }) + mcpMutationMu.Unlock() + if err != nil { + return nil, err + } + catalog, _ := s.catalog() + item, err := s.credentialItem(key, catalogIndex(catalog)) + if err != nil { + return nil, err + } + return &dto.MCPCredentialCreated{Item: item, Token: token, AlreadyCreated: repeated}, nil +} +func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutation, change func(*gorm.DB, *model.MCPCredential) error) error { + if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 { + return contract.Invalid() + } + mcpMutationMu.Lock() + defer mcpMutationMu.Unlock() + return s.DB.Transaction(func(tx *gorm.DB) error { + var key model.MCPCredential + if err := mcpOwnerQuery(tx, owner).Where("id = ?", req.ID).First(&key).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return contract.Forbidden() + } + return err + } + if key.Revision != req.Revision || key.Status == "Revoked" { + return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry") + } + if err := change(tx, &key); err != nil { + return err + } + if key.Status == "Revoked" { + key.SecretCiphertext = "" + key.SecretVersion = 0 + } + key.Revision++ + result := tx.Model(&model.MCPCredential{}).Where("id = ? AND revision = ?", key.ID, req.Revision).Select("name", "active_name", "description", "node_ids", "status", "expires_at", "revision", "revoked_at", "secret_ciphertext", "secret_version", "token_hash", "key_hint").Updates(&key) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry") + } + return nil + }) +} +func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate) error { + if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 { + return contract.Invalid() + } + grants, err := s.validateFields(owner, &req.MCPCredentialFields, true) + if err != nil { + return err + } + return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error { + var count int64 + if err := mcpOwnerQuery(tx.Model(&model.MCPCredential{}), owner).Where("active_name = ? AND id <> ?", req.Name, key.ID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return contract.Fail("MCP_CONFLICT", 409, "Credential name already exists") + } + key.Name = req.Name + key.ActiveName = &req.Name + key.Description = req.Description + key.NodeIDs = mcpEncodeNodeIDs(req.NodeIDs) + key.ExpiresAt = req.ExpiresAt + return (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants) + }) +} +func (s *MCPService) Status(owner auth.APIKeyOwner, req dto.MCPCredentialStatus) error { + if req.Status != "Enable" && req.Status != "Disable" { + return contract.Invalid() + } + return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error { + if req.Status == "Enable" { + catalog, err := s.catalog() + if err != nil { + return err + } + index := catalogIndex(catalog) + grants, err := (repo.MCPRepo{DB: tx}).Grants(key.ID) + if err != nil { + return err + } + nodeIDs, err := mcpCredentialNodeIDs(*key) + if err != nil { + return err + } + fields := dto.MCPCredentialFields{Name: key.Name, Description: key.Description, ExpiresAt: key.ExpiresAt, ToolPolicies: map[string]string{}, ToolReadOnly: map[string]bool{}, NodeIDs: nodeIDs} + for _, grant := range grants { + tool, exists := index[grant.ToolID] + if !exists || !mcpGrantAllows(grant, tool) { + return mcpPolicyChanged() + } + fields.Tools = append(fields.Tools, grant.ToolID) + fields.ToolPolicies[grant.ToolID] = tool.PolicyHash + fields.ToolReadOnly[grant.ToolID] = tool.ReadOnly + } + if _, err = s.validateFields(owner, &fields, true); err != nil { + return err + } + } + key.Status = req.Status + return nil + }) +} +func (s *MCPService) Revoke(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) error { + return s.mutate(owner, req, func(tx *gorm.DB, key *model.MCPCredential) error { + now := s.Now() + key.Status = "Revoked" + key.RevokedAt = &now + key.ActiveName = nil + return (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, nil) + }) +} +func (s *MCPService) Authenticate(c *gin.Context, token string) (model.MCPCredential, auth.APIKeyOwner, error) { + var key model.MCPCredential + var owner auth.APIKeyOwner + if len(token) != 68 || !strings.HasPrefix(token, "mcp_") { + return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential") + } + if _, err := hex.DecodeString(token[4:]); err != nil { + return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential") + } + var err error + key, err = (repo.MCPRepo{DB: s.DB}).Token(tokenDigest(token)) + if err != nil { + return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential") + } + if key.Status != "Enable" || key.ExpiresAt != nil && !s.Now().Before(*key.ExpiresAt) { + return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "MCP credential is inactive or expired") + } + if s.Provider == nil { + return key, owner, contract.Forbidden() + } + owner, err = s.Provider.ResolveAPIKeyOwner(c, key.OwnerType, key.OwnerID) + if err != nil { + return key, owner, contract.Forbidden() + } + if s.Enabled == nil { + return key, owner, contract.Fail("MCP_DISABLED", 503, "MCP access is disabled") + } + enabled, err := s.Enabled() + if err != nil || !enabled { + return key, owner, contract.Fail("MCP_DISABLED", 503, "MCP access is disabled") + } + return key, owner, nil +} +func (s *MCPService) RuntimeTools(c *gin.Context, token string) (map[string]any, error) { + key, _, err := s.Authenticate(c, token) + if err != nil { + return nil, err + } + catalog, err := s.catalog() + if err != nil { + return nil, err + } + grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID) + if err != nil { + return nil, err + } + policies := make(map[string]model.MCPGrant, len(grants)) + for _, grant := range grants { + policies[grant.ToolID] = grant + } + latest, owner, err := s.Authenticate(c, token) + if err != nil { + return nil, err + } + if latest.Revision != key.Revision { + return nil, contract.Forbidden() + } + nodes, err := s.credentialNodes(latest, owner) + if err != nil { + return nil, err + } + tools := []contract.Tool{} + for _, tool := range catalog { + if grant, exists := policies[tool.ToolID]; exists && mcpGrantAllows(grant, tool) && s.authorizeToolOnNodes(owner, mcpNodeItemIDs(nodes), tool) == nil { + tools = append(tools, tool) + } + } + return map[string]any{"contractVersion": contract.ContractVersion, "tools": tools}, nil +} + +func (s *MCPService) authorizeInvocation(key model.MCPCredential, owner auth.APIKeyOwner, req contract.Invoke) (contract.Tool, error) { + catalog, err := s.catalog() + if err != nil { + return contract.Tool{}, err + } + tool, ok := catalogIndex(catalog)[req.ToolID] + if !ok { + return contract.Tool{}, contract.Fail("MCP_UNKNOWN_TOOL", 400, "Unknown tool") + } + if req.SchemaVersion != tool.SchemaVersion || req.PolicyHash == "" || req.PolicyHash != tool.PolicyHash { + return contract.Tool{}, mcpPolicyChanged() + } + if !slices.ContainsFunc(tool.Operations, func(op contract.Operation) bool { return op.ID == req.OperationID }) { + return contract.Tool{}, contract.Forbidden() + } + grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID) + if err != nil { + return contract.Tool{}, err + } + granted := false + for _, grant := range grants { + if grant.ToolID != tool.ToolID { + continue + } + if !mcpGrantAllows(grant, tool) { + return contract.Tool{}, mcpPolicyChanged() + } + granted = true + break + } + if !granted { + return contract.Tool{}, contract.Forbidden() + } + if err := s.authorizeInvocationNode(key, owner, req.NodeID, tool); err != nil { + return contract.Tool{}, err + } + return tool, nil +} +func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (*contract.InvocationResult, error) { + started := s.Now() + nodeID, nodeErr := contract.NormalizeNodeID(req.NodeID) + if nodeErr == nil { + req.NodeID = nodeID + } + done, err := useMCPAdmission(token, req) + if err != nil { + return nil, err + } + defer done() + record := model.MCPInvocation{ID: uuid.NewString(), ToolID: safeMCPAuditID(req.ToolID), RequestID: safeMCPAuditID(req.RequestID), OperationID: safeMCPAuditID(req.OperationID), PolicyHash: safeMCPPolicyHash(req.PolicyHash), NodeID: safeMCPAuditID(req.NodeID), StartedAt: started, Status: "denied"} + key, owner, callErr := s.Authenticate(c, token) + if key.ID == "" { + return nil, callErr + } + if key.ID != "" { + record.CredentialID = key.ID + record.CredentialName = key.Name + record.OwnerType = key.OwnerType + record.OwnerID = key.OwnerID + record.OwnerName = key.OwnerName + record.Revision = key.Revision + } + record.Status = "running" + if err := s.DB.Create(&record).Error; err != nil { + return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable") + } + record.Status = "denied" + finish := func(err error, result any) (*contract.InvocationResult, error) { + now := s.Now() + record.FinishedAt = &now + record.DurationMs = now.Sub(started).Milliseconds() + if err == nil { + record.Status = "success" + } else { + var e *contract.Error + if !errors.As(err, &e) { + e = contract.Fail("MCP_EXECUTION_FAILED", 502, "Tool execution failed; inspect the panel invocation record before retrying") + } + record.Code = e.Code + e.InvocationID = record.ID + err = e + } + if auditErr := s.finishInvocationAudit(record); auditErr != nil { + return nil, &contract.Error{Code: "MCP_EXECUTION_FAILED", Status: 503, Message: "Invocation outcome could not be recorded; do not retry a write automatically", InvocationID: record.ID} + } + if err != nil { + return nil, err + } + return &contract.InvocationResult{InvocationID: record.ID, NodeID: req.NodeID, Result: result}, nil + } + if callErr != nil { + return finish(callErr, nil) + } + if nodeErr != nil { + return finish(contract.Invalid(), nil) + } + tool, err := s.authorizeInvocation(key, owner, req) + if err != nil { + return finish(err, nil) + } + operation, apiPath, arguments, err := contract.NormalizeOperation(tool, req) + if err != nil { + return finish(err, nil) + } + latest, latestOwner, err := s.Authenticate(c, token) + if err != nil { + return finish(err, nil) + } + if latest.Revision != key.Revision { + return finish(contract.Forbidden(), nil) + } + currentTool, err := s.authorizeInvocation(latest, latestOwner, req) + if err != nil { + return finish(err, nil) + } + latest, latestOwner, err = s.Authenticate(c, token) + if err != nil { + return finish(err, nil) + } + if latest.Revision != key.Revision || s.authorizeInvocationNode(latest, latestOwner, req.NodeID, currentTool) != nil { + return finish(contract.Forbidden(), nil) + } + discovery := mcpIsNodeDiscovery(currentTool) + if !discovery && s.authorizeAPI(latestOwner, req.NodeID, operation.Method, apiPath) != nil { + return finish(contract.Forbidden(), nil) + } + if !discovery && s.Execute == nil { + return finish(contract.Fail("MCP_NODE_UNAVAILABLE", 503, "Selected node is unavailable"), nil) + } + requestContext := c.Request.Context() + if requestContext.Err() != nil { + return finish(contract.Fail("MCP_EXECUTION_FAILED", 499, "Request ended before execution"), nil) + } + if operation.Method != http.MethodGet { + requestContext = context.WithoutCancel(requestContext) + } + ctx, cancel := context.WithTimeout(requestContext, 330*time.Second) + defer cancel() + enabled, enabledErr := s.Enabled() + if enabledErr != nil || !enabled { + return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil) + } + var admitted model.MCPCredential + admissionErr := s.DB.Select("expires_at"). + Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable"). + Where("EXISTS (SELECT 1 FROM mcp_settings WHERE id = ? AND enabled = ?)", 1, true).Take(&admitted).Error + if errors.Is(admissionErr, gorm.ErrRecordNotFound) { + return finish(contract.Forbidden(), nil) + } + if admissionErr != nil { + return finish(contract.Fail("MCP_EXECUTION_FAILED", 503, "Credential admission is unavailable"), nil) + } + if admitted.ExpiresAt != nil && !s.Now().Before(*admitted.ExpiresAt) { + return finish(contract.Forbidden(), nil) + } + record.Status = "failed" + if discovery { + nodes, err := s.credentialNodes(latest, latestOwner) + if err != nil { + return finish(err, nil) + } + return finish(nil, map[string]any{"items": nodes}) + } + result, err := s.Execute(ctx, contract.APIRequest{InvocationID: record.ID, NodeID: req.NodeID, Owner: latestOwner, Method: operation.Method, Path: apiPath, Arguments: arguments, ResponseFields: operation.ResponseFields, ReadOnly: operation.Method == http.MethodGet}) + return finish(err, result) +} +func safeMCPPolicyHash(value string) string { + data, err := hex.DecodeString(value) + if err != nil || len(data) != sha256.Size { + return "" + } + return strings.ToLower(value) +} +func safeMCPAuditID(value string) string { + if len(value) > 128 { + return "invalid" + } + for _, r := range value { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("_.:-", r)) { + return "invalid" + } + } + return value +} +func (s *MCPService) Invocations(owner auth.APIKeyOwner, req dto.MCPInvocationSearch) (map[string]any, error) { + if !validMCPPage(req.Page, req.PageSize) { + return nil, contract.Invalid() + } + q := mcpOwnerQuery(s.DB.Model(&model.MCPInvocation{}), owner) + if req.CredentialID != "" { + q = q.Where("credential_id = ?", req.CredentialID) + } + if req.ToolID != "" { + q = q.Where("tool_id = ?", req.ToolID) + } + if req.Status != "" { + if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" { + return nil, contract.Invalid() + } + q = q.Where("status = ?", req.Status) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, err + } + items := []model.MCPInvocation{} + if err := q.Order(mcpInvocationSecond + " DESC, " + mcpInvocationNanosecond + " DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&items).Error; err != nil { + return nil, err + } + return map[string]any{"items": items, "total": total}, nil +} + +func MCPBearer(c *gin.Context) (string, error) { + if auth.HasAPICredentials(c) || c.GetHeader("CurrentNode") != "" || c.GetHeader("X-Panel-User") != "" || c.Request.URL.RawQuery != "" { + return "", contract.Invalid() + } + values := c.Request.Header.Values("Authorization") + if len(values) != 1 { + return "", contract.Fail("MCP_UNAUTHENTICATED", http.StatusUnauthorized, "MCP Bearer credential required") + } + parts := strings.Fields(values[0]) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", contract.Fail("MCP_UNAUTHENTICATED", 401, "MCP Bearer credential required") + } + return parts[1], nil +} diff --git a/core/app/service/mcp_access.go b/core/app/service/mcp_access.go new file mode 100644 index 000000000..fce5490df --- /dev/null +++ b/core/app/service/mcp_access.go @@ -0,0 +1,37 @@ +package service + +import ( + "encoding/json" + "errors" + + "github.com/1Panel-dev/1Panel/core/app/dto" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" +) + +func encodeMCPIPs(entries []string) string { + data, _ := json.Marshal(entries) + if entries == nil { + return "[]" + } + return string(data) +} +func normalizeMCPAccessSettings(req *dto.MCPRuntimeSettings) error { + entries, err := contract.NormalizeAllowedIPs(req.AllowedIPs) + if err != nil { + return err + } + switch req.AccessPolicy { + case "inherit", "all": + if len(entries) != 0 { + return errors.New("independent MCP IP entries require restricted policy") + } + case "restricted": + if len(entries) == 0 { + return errors.New("MCP restricted policy requires at least one IP or CIDR") + } + default: + return errors.New("invalid MCP access policy") + } + req.AllowedIPs = entries + return nil +} diff --git a/core/app/service/mcp_call_admission.go b/core/app/service/mcp_call_admission.go new file mode 100644 index 000000000..b7beb3859 --- /dev/null +++ b/core/app/service/mcp_call_admission.go @@ -0,0 +1,274 @@ +package service + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "sync" + "time" + + "github.com/1Panel-dev/1Panel/core/app/model" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +const mcpServiceCallsPerMinute = 240 +const mcpAdmissionLifetime = 350 * time.Second +const mcpAdmissionMaxOperations = 64 + +type mcpAdmittedCall struct { + credentialID, tokenHash, toolID, nodeID, policyHash, requestID string + schemaVersion int + expires time.Time + operations int + active, closed bool + timer *time.Timer + // Used only when the adapter fails after successful API sub-operations. + outcome model.MCPInvocation +} + +var mcpAdmissions = struct { + sync.Mutex + calls map[string]*mcpAdmittedCall +}{calls: map[string]*mcpAdmittedCall{}} +var mcpGlobalCalls = struct { + sync.Mutex + start int64 + calls int +}{} + +func mcpRateError(message string, retry int) *contract.Error { + err := contract.Fail("MCP_RATE_LIMITED", 429, message) + if retry < 1 { + retry = 1 + } + err.RetryAfterSeconds = retry + return err +} + +func (s *MCPService) Admit(c *gin.Context, token string, req contract.Admission) (*contract.Admitted, error) { + now := s.Now() + nodeID, err := contract.NormalizeNodeID(req.NodeID) + if err != nil || len(req.RequestID) > 128 || len(req.Arguments) > 64<<10 { + return nil, contract.Invalid() + } + key, owner, err := s.Authenticate(c, token) + if err != nil { + return nil, err + } + if !admitMCPCall("admit:"+key.ID, now) { + return nil, mcpRateError("Too many MCP admission attempts", 60) + } + _, err = contract.VerifyAccessProof(c.GetHeader(contract.AccessProofHeader), token, time.Now()) + if err != nil { + return nil, err + } + catalog, err := s.catalog() + if err != nil { + return nil, err + } + tool, found := catalogIndex(catalog)[req.ToolID] + if !found { + return nil, contract.Fail("MCP_UNKNOWN_TOOL", 400, "Unknown managed tool") + } + var args map[string]any + if json.Unmarshal(req.Arguments, &args) != nil || args == nil || !contract.ValidateInput(tool.InputSchema, args) { + return nil, contract.Invalid() + } + requestedNode := "local" + if value, exists := args["nodeId"]; exists { + text, ok := value.(string) + if !ok { + return nil, contract.Invalid() + } + requestedNode, err = contract.NormalizeNodeID(text) + if err != nil { + return nil, err + } + } + if requestedNode != nodeID { + return nil, contract.Forbidden() + } + check := contract.Invoke{ToolID: req.ToolID, NodeID: nodeID, SchemaVersion: req.SchemaVersion, PolicyHash: req.PolicyHash, OperationID: tool.Operations[0].ID} + if _, err := s.authorizeInvocation(key, owner, check); err != nil { + record := model.MCPInvocation{ID: newMCPAdmissionID(), CredentialID: key.ID, CredentialName: key.Name, OwnerType: key.OwnerType, OwnerID: key.OwnerID, OwnerName: key.OwnerName, ToolID: safeMCPAuditID(req.ToolID), NodeID: nodeID, RequestID: safeMCPAuditID(req.RequestID), PolicyHash: safeMCPPolicyHash(req.PolicyHash), Revision: key.Revision, Status: "denied", Code: "MCP_FORBIDDEN", StartedAt: now, FinishedAt: &now} + if known, ok := err.(*contract.Error); ok { + record.Code = known.Code + } + if auditErr := s.DB.Create(&record).Error; auditErr != nil { + return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission audit is unavailable") + } + if known, ok := err.(*contract.Error); ok { + copy := *known + copy.InvocationID = record.ID + return nil, © + } + return nil, err + } + if !mcpIsNodeDiscovery(tool) { + if err := s.authorizeToolOnNode(owner, nodeID, tool); err != nil { + return nil, err + } + } + select { + case mcpExecutionSlots <- struct{}{}: + default: + return nil, mcpRateError("Too many concurrent MCP calls", 1) + } + release := true + defer func() { + if release { + <-mcpExecutionSlots + } + }() + mcpMutationMu.Lock() + defer mcpMutationMu.Unlock() + mcpGlobalCalls.Lock() + defer mcpGlobalCalls.Unlock() + now = s.Now() + if now.Unix() >= mcpGlobalCalls.start+60 { + mcpGlobalCalls.start = now.Unix() + mcpGlobalCalls.calls = 0 + } + if mcpGlobalCalls.calls >= mcpServiceCallsPerMinute { + return nil, mcpRateError("MCP global call limit exceeded", int(mcpGlobalCalls.start+60-now.Unix())) + } + id, err := randomMCPAdmissionID() + if err != nil { + return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Admission could not be created") + } + if err := s.validateMCPAdmission(key, now); err != nil { + return nil, err + } + entry := &mcpAdmittedCall{credentialID: key.ID, tokenHash: tokenDigest(token), toolID: req.ToolID, nodeID: nodeID, policyHash: req.PolicyHash, requestID: req.RequestID, schemaVersion: req.SchemaVersion, expires: time.Now().Add(mcpAdmissionLifetime)} + entry.outcome = model.MCPInvocation{ID: newMCPAdmissionID(), CredentialID: key.ID, CredentialName: key.Name, OwnerType: key.OwnerType, OwnerID: key.OwnerID, OwnerName: key.OwnerName, ToolID: safeMCPAuditID(req.ToolID), NodeID: nodeID, RequestID: safeMCPAuditID(req.RequestID), OperationID: "tool.result", PolicyHash: safeMCPPolicyHash(req.PolicyHash), Revision: key.Revision, StartedAt: now} + mcpAdmissions.Lock() + mcpAdmissions.calls[id] = entry + entry.timer = time.AfterFunc(mcpAdmissionLifetime, func() { closeMCPAdmission(id) }) + mcpAdmissions.Unlock() + mcpGlobalCalls.calls++ + release = false + return &contract.Admitted{AdmissionID: id, ExpiresAt: entry.expires}, nil +} + +func randomMCPAdmissionID() (string, error) { + data := make([]byte, 32) + if _, err := rand.Read(data); err != nil { + return "", err + } + return hex.EncodeToString(data), nil +} +func newMCPAdmissionID() string { return uuid.NewString() } + +func (s *MCPService) validateMCPAdmission(key model.MCPCredential, now time.Time) error { + return s.DB.Transaction(func(tx *gorm.DB) error { + var latest model.MCPCredential + if err := tx.Where("id = ? AND revision = ? AND status = ? AND revoked_at IS NULL", key.ID, key.Revision, "Enable").Take(&latest).Error; err != nil { + return contract.Forbidden() + } + if latest.ExpiresAt != nil && !now.Before(*latest.ExpiresAt) { + return contract.Forbidden() + } + var setting model.MCPSetting + if err := tx.Select("enabled").Take(&setting, 1).Error; err != nil || !setting.Enabled { + return contract.Fail("MCP_DISABLED", 503, "MCP access is disabled") + } + + return nil + }) +} + +func closeMCPAdmission(id string) { + mcpAdmissions.Lock() + defer mcpAdmissions.Unlock() + entry := mcpAdmissions.calls[id] + if entry == nil { + return + } + closeMCPAdmissionLocked(id, entry) +} + +func closeMCPAdmissionLocked(id string, entry *mcpAdmittedCall) { + entry.closed = true + if entry.active { + return + } + if entry.timer != nil { + entry.timer.Stop() + } + delete(mcpAdmissions.calls, id) + <-mcpExecutionSlots +} + +func (s *MCPService) Complete(token string, req contract.Completion) error { + _, err := s.CompleteWithOutcome(token, req) + return err +} + +func (s *MCPService) CompleteWithOutcome(token string, req contract.Completion) (*contract.Completed, error) { + if len(req.AdmissionID) != 64 || len(req.ErrorCode) > 64 { + return nil, contract.Invalid() + } + mcpAdmissions.Lock() + entry := mcpAdmissions.calls[req.AdmissionID] + if entry != nil && entry.tokenHash != tokenDigest(token) { + mcpAdmissions.Unlock() + return nil, contract.Forbidden() + } + var outcome *model.MCPInvocation + if entry != nil && req.ErrorCode != "" { + if entry.active { + closeMCPAdmissionLocked(req.AdmissionID, entry) + mcpAdmissions.Unlock() + return nil, contract.Fail("MCP_CONFLICT", 409, "An API operation is still active") + } + record := entry.outcome + outcome = &record + } + if entry != nil { + closeMCPAdmissionLocked(req.AdmissionID, entry) + } + mcpAdmissions.Unlock() + result := &contract.Completed{} + if outcome != nil { + now := s.Now() + outcome.Status, outcome.Code = "failed", mcpCompletionErrorCode(req.ErrorCode) + outcome.FinishedAt, outcome.DurationMs = &now, now.Sub(outcome.StartedAt).Milliseconds() + if err := s.DB.Create(outcome).Error; err != nil { + return nil, contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Invocation outcome could not be recorded; do not retry a write automatically") + } + result.InvocationID = outcome.ID + } + return result, nil +} + +func mcpCompletionErrorCode(code string) string { + switch code { + case "MCP_CONFLICT", "MCP_INVALID_ARGUMENTS", "MCP_SCHEMA_MISMATCH", "MCP_FORBIDDEN", "MCP_EXECUTION_FAILED", "MCP_NODE_UNAVAILABLE", "MCP_DISABLED", "MCP_POLICY_CHANGED", "MCP_UNKNOWN_TOOL", "MCP_RATE_LIMITED", "MCP_CATALOG_UNAVAILABLE", "MCP_UNAUTHENTICATED": + return code + default: + return "MCP_EXECUTION_FAILED" + } +} + +func useMCPAdmission(token string, req contract.Invoke) (func(), error) { + mcpAdmissions.Lock() + entry := mcpAdmissions.calls[req.AdmissionID] + if entry == nil || entry.closed || !time.Now().Before(entry.expires) || entry.active || entry.operations >= mcpAdmissionMaxOperations || entry.tokenHash != tokenDigest(token) || entry.toolID != req.ToolID || entry.nodeID != req.NodeID || entry.policyHash != req.PolicyHash || entry.schemaVersion != req.SchemaVersion || entry.requestID != req.RequestID { + mcpAdmissions.Unlock() + return nil, contract.Forbidden() + } + entry.active = true + entry.operations++ + mcpAdmissions.Unlock() + return func() { + mcpAdmissions.Lock() + entry.active = false + if entry.closed { + closeMCPAdmissionLocked(req.AdmissionID, entry) + } + mcpAdmissions.Unlock() + }, nil +} diff --git a/core/app/service/mcp_enrollment.go b/core/app/service/mcp_enrollment.go new file mode 100644 index 000000000..6def17bf8 --- /dev/null +++ b/core/app/service/mcp_enrollment.go @@ -0,0 +1,266 @@ +package service + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/model" + paneli18n "github.com/1Panel-dev/1Panel/core/i18n" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/google/uuid" + goi18n "github.com/nicksnyder/go-i18n/v2/i18n" + "gorm.io/gorm" +) + +const mcpSecretVersion = 1 + +func (s *MCPService) secretAEAD() (cipher.AEAD, error) { + unavailable := contract.Fail("MCP_SECRET_KEY_UNAVAILABLE", 503, "MCP credential encryption is unavailable") + if s.SecretKey == nil { + return nil, unavailable + } + master, err := s.SecretKey() + if err != nil || master == "" { + return nil, unavailable + } + derived := sha256.Sum256([]byte("1panel:mcp-token:v1:" + master)) + block, err := aes.NewCipher(derived[:]) + if err != nil { + return nil, unavailable + } + return cipher.NewGCM(block) +} + +func (s *MCPService) sealCredentialToken(key *model.MCPCredential, token string) error { + aead, err := s.secretAEAD() + if err != nil { + return err + } + nonce := make([]byte, aead.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return contract.Fail("MCP_SECRET_KEY_UNAVAILABLE", 503, "MCP credential encryption is unavailable") + } + key.SecretCiphertext = base64.RawStdEncoding.EncodeToString(aead.Seal(nonce, nonce, []byte(token), []byte("mcp-token:v1:"+key.ID))) + key.SecretVersion = mcpSecretVersion + return nil +} + +func (s *MCPService) openCredentialToken(key model.MCPCredential) (string, error) { + invalid := contract.Fail("MCP_SECRET_INVALID", 409, "The stored MCP token cannot be decrypted; rotate the credential") + if key.SecretVersion != mcpSecretVersion { + return "", invalid + } + aead, err := s.secretAEAD() + if err != nil { + return "", err + } + data, err := base64.RawStdEncoding.DecodeString(key.SecretCiphertext) + if err != nil || len(data) < aead.NonceSize()+aead.Overhead() || len(data) > 256 { + return "", invalid + } + plain, err := aead.Open(nil, data[:aead.NonceSize()], data[aead.NonceSize():], []byte("mcp-token:v1:"+key.ID)) + if err != nil { + return "", invalid + } + token := string(plain) + if len(token) != 68 || !strings.HasPrefix(token, "mcp_") || subtle.ConstantTimeCompare([]byte(tokenDigest(token)), []byte(key.TokenHash)) != 1 { + return "", invalid + } + if _, err := hex.DecodeString(token[4:]); err != nil { + return "", invalid + } + return token, nil +} + +func (s *MCPService) enrollmentKey(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) (model.MCPCredential, error) { + var key model.MCPCredential + if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 { + return key, contract.Invalid() + } + if err := s.authorize(owner, "mcp_access_manage"); err != nil { + return key, err + } + if err := mcpOwnerQuery(s.DB, owner).Where("id = ?", req.ID).First(&key).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return key, contract.Forbidden() + } + return key, err + } + if key.Revision != req.Revision { + return key, contract.Fail("MCP_CONFLICT", 409, "Credential changed; refresh and retry") + } + if err := s.enrollmentActive(key, false); err != nil { + return key, err + } + return key, nil +} + +func (s *MCPService) enrollmentActive(key model.MCPCredential, allowDisabled bool) error { + active := key.Status == "Enable" || allowDisabled && key.Status == "Disable" + if !active || key.RevokedAt != nil || key.ExpiresAt != nil && !s.Now().Before(*key.ExpiresAt) { + return contract.Fail("MCP_CREDENTIAL_INACTIVE", 409, "The credential is inactive or expired") + } + return nil +} + +func (s *MCPService) Reveal(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) (result *dto.MCPCredentialSecret, resultErr error) { + defer func() { + if err := s.auditEnrollment(s.DB, owner, "reveal", req, resultErr); err != nil { + result, resultErr = nil, err + } + }() + mcpMutationMu.Lock() + defer mcpMutationMu.Unlock() + key, err := s.enrollmentKey(owner, req) + if err != nil { + return nil, err + } + token, err := s.openCredentialToken(key) + if err != nil { + return nil, err + } + return &dto.MCPCredentialSecret{ID: key.ID, Token: token}, nil +} + +func (s *MCPService) Rotate(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) (result *dto.MCPCredentialCreated, resultErr error) { + defer func() { + if resultErr != nil { + if err := s.auditEnrollment(s.DB, owner, "rotate", req, resultErr); err != nil { + result, resultErr = nil, err + } + } + }() + if err := s.authorize(owner, "mcp_access_manage"); err != nil { + return nil, err + } + var token string + var item dto.MCPCredentialItem + catalog, _ := s.catalog() + err := s.mutate(owner, req, func(tx *gorm.DB, key *model.MCPCredential) error { + if err := s.authorize(owner, "mcp_access_manage"); err != nil { + return err + } + if err := s.enrollmentActive(*key, true); err != nil { + return err + } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return contract.Fail("MCP_SECRET_KEY_UNAVAILABLE", 503, "MCP credential encryption is unavailable") + } + token = "mcp_" + hex.EncodeToString(secret) + key.TokenHash = tokenDigest(token) + key.KeyHint = token[:8] + "…" + token[len(token)-4:] + if err := s.sealCredentialToken(key, token); err != nil { + return err + } + view := *s + view.DB = tx + var err error + item, err = view.credentialItem(*key, catalogIndex(catalog)) + item.Revision = key.Revision + 1 + if err != nil { + return err + } + return s.auditEnrollment(tx, owner, "rotate", dto.MCPCredentialMutation{ID: key.ID, Revision: item.Revision}, nil) + }) + if err != nil { + return nil, err + } + return &dto.MCPCredentialCreated{Item: item, Token: token}, nil +} + +func mcpDocumentEndpoint(endpoint string) bool { + u, err := url.Parse(endpoint) + return err == nil && len(endpoint) <= 2048 && !strings.ContainsAny(endpoint, "\r\n\x00") && + (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil && u.Path == "/mcp" && + u.RawQuery == "" && u.Fragment == "" && u.Opaque == "" +} + +func mcpDocumentJSON(value any) string { + data, _ := json.MarshalIndent(value, "", " ") + return strings.ReplaceAll(string(data), "`", "\\u0060") +} + +func mcpEnrollmentConfig(client, id, endpoint, token string) (string, string) { + name := "1panel_" + strings.ReplaceAll(id, "-", "") + if client == "codex" { + urlJSON := mcpDocumentJSON(endpoint) + authJSON := mcpDocumentJSON("Bearer " + token) + return "toml", fmt.Sprintf("[mcp_servers.%s]\nurl = %s\nhttp_headers = { Authorization = %s }", name, urlJSON, authJSON) + } + return "json", mcpDocumentJSON(map[string]any{"mcpServers": map[string]any{name: map[string]any{ + "type": "http", "url": endpoint, "headers": map[string]string{"Authorization": "Bearer " + token}, + }}}) +} + +func mcpEnrollmentDocument(locale, client, syntax, config, endpoint string) (string, error) { + localizer, err := paneli18n.NewMCPDocumentLocalizer(locale) + if err != nil { + return "", contract.Fail("MCP_DOCUMENT_UNAVAILABLE", 500, "MCP connection document is unavailable") + } + clientKey := map[string]string{ + "codex": "MCPDocumentClientCodex", "claude": "MCPDocumentClientClaude", "generic": "MCPDocumentClientGeneric", + }[client] + keys := []string{ + "MCPDocumentTitle", "MCPDocumentIntro", "MCPDocumentTaskTitle", "MCPDocumentTask", clientKey, + "MCPDocumentSteps", "MCPDocumentConfigTitle", "MCPDocumentNotes", + } + if strings.HasPrefix(endpoint, "http://") { + keys = append(keys, "MCPDocumentHTTPWarning") + } + texts := make([]string, len(keys)) + for index, key := range keys { + texts[index], err = localizer.Localize(&goi18n.LocalizeConfig{MessageID: key}) + if err != nil || texts[index] == "" { + return "", contract.Fail("MCP_DOCUMENT_UNAVAILABLE", 500, "MCP connection document is unavailable") + } + } + content := fmt.Sprintf("# %s\n\n%s\n\n## %s\n\n%s\n\n%s\n\n%s\n\n## %s\n\n```%s\n%s\n```\n\n%s\n", + texts[0], texts[1], texts[2], texts[3], texts[4], texts[5], texts[6], syntax, config, texts[7]) + if len(texts) > 8 { + content += "\n" + texts[8] + "\n" + } + return content, nil +} + +func (s *MCPService) ExportMarkdown(owner auth.APIKeyOwner, req dto.MCPEnrollmentExport, endpoint, locale string) (result *dto.MCPEnrollmentDocument, resultErr error) { + defer func() { + if err := s.auditEnrollment(s.DB, owner, "export", req.MCPCredentialMutation, resultErr); err != nil { + result, resultErr = nil, err + } + }() + if req.Client != "generic" && req.Client != "codex" && req.Client != "claude" { + return nil, contract.Invalid() + } + if !mcpDocumentEndpoint(endpoint) { + return nil, contract.Fail("MCP_ENDPOINT_UNAVAILABLE", 409, "Configure an MCP external endpoint before exporting") + } + mcpMutationMu.Lock() + defer mcpMutationMu.Unlock() + key, err := s.enrollmentKey(owner, req.MCPCredentialMutation) + if err != nil { + return nil, err + } + token, err := s.openCredentialToken(key) + if err != nil { + return nil, err + } + language, config := mcpEnrollmentConfig(req.Client, key.ID, endpoint, token) + content, err := mcpEnrollmentDocument(locale, req.Client, language, config, endpoint) + if err != nil { + return nil, err + } + return &dto.MCPEnrollmentDocument{Filename: "1panel-mcp-" + key.ID + "-" + req.Client + ".md", Content: content}, nil +} diff --git a/core/app/service/mcp_enrollment_audit.go b/core/app/service/mcp_enrollment_audit.go new file mode 100644 index 000000000..b6a057e08 --- /dev/null +++ b/core/app/service/mcp_enrollment_audit.go @@ -0,0 +1,67 @@ +package service + +import ( + "encoding/json" + "errors" + + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/constant" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/google/uuid" + "gorm.io/gorm" +) + +func mcpEnrollmentAuditCode(err error) string { + if err == nil { + return "MCP_OK" + } + var known *contract.Error + if errors.As(err, &known) { + switch known.Code { + case "MCP_FORBIDDEN", "MCP_CONFLICT", "MCP_INVALID_ARGUMENTS", "MCP_SECRET_INVALID", "MCP_SECRET_KEY_UNAVAILABLE", "MCP_CREDENTIAL_INACTIVE", "MCP_ENDPOINT_UNAVAILABLE", "MCP_POLICY_CHANGED", "MCP_CATALOG_UNAVAILABLE", "MCP_AUDIT_UNAVAILABLE": + return known.Code + } + } + return "MCP_ENROLLMENT_FAILED" +} + +func (s *MCPService) auditEnrollment(db *gorm.DB, owner auth.APIKeyOwner, action string, req dto.MCPCredentialMutation, outcome error) error { + failed := contract.Fail("MCP_AUDIT_UNAVAILABLE", 503, "Credential access could not be audited; no secret was returned") + if action != "create" && action != "reveal" && action != "rotate" && action != "export" { + return failed + } + id := "invalid" + if parsed, err := uuid.Parse(req.ID); err == nil { + id = parsed.String() + } + status := constant.StatusSuccess + if outcome != nil { + status = constant.StatusFailed + } + metadata := struct { + OwnerType string `json:"ownerType"` + OwnerID string `json:"ownerId"` + Action string `json:"action"` + CredentialID string `json:"credentialId"` + Revision uint64 `json:"revision"` + Outcome string `json:"outcome"` + Code string `json:"code"` + }{owner.Type, owner.ID, action, id, req.Revision, status, mcpEnrollmentAuditCode(outcome)} + detail, _ := json.Marshal(metadata) + record := &model.OperationLog{Source: "mcp", User: owner.Name, AuthMethod: "session", Node: "local", Path: "/core/mcp/credentials/" + action, Method: "post", Status: status, Message: metadata.Code, DetailEN: string(detail), DetailZH: string(detail)} + if db == nil { + return failed + } + var err error + if s.EnrollmentAudit != nil { + err = s.EnrollmentAudit(db, record) + } else { + err = db.Create(record).Error + } + if err != nil { + return failed + } + return nil +} diff --git a/core/app/service/mcp_execute.go b/core/app/service/mcp_execute.go new file mode 100644 index 000000000..1ba42659b --- /dev/null +++ b/core/app/service/mcp_execute.go @@ -0,0 +1,54 @@ +package service + +import ( + "context" + "errors" + "net/http" + "path" + "strings" + + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/1Panel-dev/1Panel/core/utils/mcpapi" + "github.com/1Panel-dev/1Panel/core/utils/xpack" + "github.com/1Panel-dev/1Panel/core/utils/xpack/providers" +) + +func executeMCPAPI(ctx context.Context, req contract.APIRequest) (any, error) { + result, err := mcpapi.Dispatch(ctx, mcpapi.Request{InvocationID: req.InvocationID, NodeID: req.NodeID, Owner: req.Owner, Method: req.Method, Path: req.Path, Arguments: req.Arguments}) + if err != nil { + if message := mcpapi.SafeReadErrorMessage(err); message != "" { + if mcpapi.IsSystemReadTransportFailure(err) && ctx.Err() == nil { + if provider, ok := xpack.AuthProvider.(providers.MCPNodeProvider); ok { + node, nodeErr := provider.ResolveMCPNode(req.Owner, req.NodeID) + if nodeErr == nil && node.ID == req.NodeID && node.Name != "" && node.IsLocal == (req.NodeID == "local") && node.IsLocal == (node.Name == "local") { + switch strings.ToLower(node.Status) { + case "unhealthy", "lost", "failed": + message = mcpapi.NodeAbnormalMessage + } + } + } + } + return nil, contract.Fail("MCP_EXECUTION_FAILED", 502, message) + } + if message := mcpapi.SafeBusinessErrorMessage(err); message != "" { + return nil, contract.Fail("MCP_EXECUTION_FAILED", 502, message) + } + return nil, contract.Fail("MCP_EXECUTION_FAILED", 502, "Panel API execution failed; inspect the invocation before retrying") + } + projected, err := projectMCPAPIResult(req, result) + if err != nil { + return nil, contract.Fail("MCP_EXECUTION_FAILED", 502, "Panel API returned an incompatible result") + } + return projected, nil +} + +func projectMCPAPIResult(req contract.APIRequest, result any) (any, error) { + if req.Method == http.MethodGet && req.Path == "/api/v2/settings/website/dir" { + directory, ok := result.(string) + if !ok || !path.IsAbs(directory) || len(directory) > 4096 || strings.ContainsAny(directory, "\x00\r\n") { + return nil, errors.New("unexpected website directory result") + } + result = map[string]any{"websiteDir": directory} + } + return contract.ProjectResult(result, req.ResponseFields) +} diff --git a/core/app/service/mcp_invocation_audit.go b/core/app/service/mcp_invocation_audit.go new file mode 100644 index 000000000..2912c3bf9 --- /dev/null +++ b/core/app/service/mcp_invocation_audit.go @@ -0,0 +1,39 @@ +package service + +import ( + "time" + + "github.com/1Panel-dev/1Panel/core/app/model" +) + +// SQLite stores time.Time with the server's offset. Compare absolute seconds +// and integer nanoseconds so offset changes and repeated local hours remain +// chronological without changing the stored timestamps or losing precision. +const mcpInvocationSecond = "CAST(strftime('%s', substr(started_at, 1, 19) || substr(started_at, -6)) AS INTEGER)" +const mcpInvocationNanosecond = "CASE WHEN substr(started_at, 20, 1) = '.' THEN CAST(substr(substr(started_at, 21, length(started_at) - 26) || '000000000', 1, 9) AS INTEGER) ELSE 0 END" + +func (s *MCPService) finishInvocationAudit(record model.MCPInvocation) error { + var err error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 50 * time.Millisecond) + } + // Only compensate the audit write. The business operation must never be replayed. + err = s.DB.Model(&model.MCPInvocation{}).Where("id = ? AND status = ?", record.ID, "running"). + Updates(map[string]any{"status": record.Status, "code": record.Code, "finished_at": record.FinishedAt, "duration_ms": record.DurationMs}).Error + if err == nil { + return nil + } + } + return err +} + +// RecoverInterruptedInvocations runs before any MCP ingress starts. The fixed +// process-start boundary prevents a later invocation from being marked interrupted. +func (s *MCPService) RecoverInterruptedInvocations(processStartedAt time.Time) error { + now := s.Now() + return s.DB.Model(&model.MCPInvocation{}). + Where("status = ? AND finished_at IS NULL", "running"). + Where("("+mcpInvocationSecond+" < ? OR ("+mcpInvocationSecond+" = ? AND "+mcpInvocationNanosecond+" < ?))", processStartedAt.Unix(), processStartedAt.Unix(), processStartedAt.Nanosecond()). + Updates(map[string]any{"status": "failed", "code": "MCP_OUTCOME_UNKNOWN", "finished_at": now}).Error +} diff --git a/core/app/service/mcp_nodes.go b/core/app/service/mcp_nodes.go new file mode 100644 index 000000000..3884b45a1 --- /dev/null +++ b/core/app/service/mcp_nodes.go @@ -0,0 +1,136 @@ +package service + +import ( + "encoding/json" + "net/http" + "slices" + + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/model" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" +) + +func (s *MCPService) Nodes(owner auth.APIKeyOwner) ([]dto.MCPNodeItem, error) { + if s.NodeProvider == nil { + return nil, contract.Forbidden() + } + nodes, err := s.NodeProvider.ListMCPNodes(owner) + if err != nil { + return nil, contract.Forbidden() + } + items := make([]dto.MCPNodeItem, 0, len(nodes)) + seen := make(map[string]bool, len(nodes)) + for _, node := range nodes { + if !contract.ValidateNodeID(node.ID) || node.IsLocal != (node.ID == "local") || seen[node.ID] { + return nil, contract.Forbidden() + } + seen[node.ID] = true + items = append(items, dto.MCPNodeItem{ID: node.ID, Name: node.Name, Status: node.Status, IsLocal: node.IsLocal}) + } + return items, nil +} + +func mcpNodeItemIDs(nodes []dto.MCPNodeItem) []string { + ids := make([]string, 0, len(nodes)) + for _, node := range nodes { + ids = append(ids, node.ID) + } + return ids +} + +func mcpEncodeNodeIDs(ids []string) string { + data, _ := json.Marshal(ids) + return string(data) +} + +func mcpCredentialNodeIDs(key model.MCPCredential) ([]string, error) { + var ids []string + if json.Unmarshal([]byte(key.NodeIDs), &ids) != nil || len(ids) == 0 || len(ids) > 256 { + return nil, contract.Forbidden() + } + for _, id := range ids { + if !contract.ValidateNodeID(id) { + return nil, contract.Forbidden() + } + } + return ids, nil +} + +func (s *MCPService) validateNodeIDs(owner auth.APIKeyOwner, ids *[]string) error { + if len(*ids) == 0 || len(*ids) > 256 { + return contract.Invalid() + } + for _, id := range *ids { + if !contract.ValidateNodeID(id) { + return contract.Invalid() + } + } + slices.Sort(*ids) + *ids = slices.Compact(*ids) + nodes, err := s.Nodes(owner) + if err != nil { + return err + } + available := mcpNodeItemIDs(nodes) + for _, id := range *ids { + if !slices.Contains(available, id) { + return contract.Forbidden() + } + } + return nil +} + +func (s *MCPService) credentialNodes(key model.MCPCredential, owner auth.APIKeyOwner) ([]dto.MCPNodeItem, error) { + ids, err := mcpCredentialNodeIDs(key) + if err != nil { + return nil, err + } + nodes, err := s.Nodes(owner) + if err != nil { + return nil, err + } + allowed := make([]dto.MCPNodeItem, 0, len(nodes)) + for _, node := range nodes { + if slices.Contains(ids, node.ID) { + allowed = append(allowed, node) + } + } + return allowed, nil +} + +func mcpIsNodeDiscovery(tool contract.Tool) bool { + return tool.ToolID == "list_nodes" && tool.ReadOnly && len(tool.Operations) == 1 && + tool.Operations[0].ID == "nodes" && tool.Operations[0].Method == http.MethodGet && + tool.Operations[0].Path == contract.NodesPath +} + +func (s *MCPService) authorizeToolOnNodes(owner auth.APIKeyOwner, nodeIDs []string, tool contract.Tool) error { + if mcpIsNodeDiscovery(tool) { + return nil + } + for _, nodeID := range nodeIDs { + if s.authorizeToolOnNode(owner, nodeID, tool) == nil { + return nil + } + } + return contract.Forbidden() +} + +func (s *MCPService) authorizeInvocationNode(key model.MCPCredential, owner auth.APIKeyOwner, nodeID string, tool contract.Tool) error { + if mcpIsNodeDiscovery(tool) { + if nodeID != "local" { + return contract.Forbidden() + } + _, err := s.credentialNodes(key, owner) + return err + } + nodes, err := s.credentialNodes(key, owner) + if err != nil { + return err + } + if !slices.Contains(mcpNodeItemIDs(nodes), nodeID) { + return contract.Forbidden() + } + return s.authorizeToolOnNode(owner, nodeID, tool) +} diff --git a/core/app/service/mcp_runtime.go b/core/app/service/mcp_runtime.go new file mode 100644 index 000000000..caa9d1884 --- /dev/null +++ b/core/app/service/mcp_runtime.go @@ -0,0 +1,327 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/url" + "reflect" + "strings" + "sync" + "time" + + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/global" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/1Panel-dev/1Panel/core/utils/mcpruntime" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var mcpRuntimeMutation sync.Mutex +var ErrMCPSettingsRevision = errors.New("MCP settings changed; reload the page before saving") + +type mcpRuntimeManager interface { + Prepare(context.Context) error + Catalog(context.Context) ([]contract.Tool, error) + Status(context.Context, bool) mcpruntime.State + Apply(context.Context, bool, bool) error + InstallImage(context.Context, bool) error + Restore(context.Context, string, bool) error + RestoreAbsent(context.Context) error + Logs(context.Context) (string, error) +} + +type mcpRuntimeSnapshot struct { + settings dto.MCPRuntimeSettings + imageID string + installed bool + unavailable bool +} + +func (s *MCPRuntimeService) snapshot(ctx context.Context, settings dto.MCPRuntimeSettings) (mcpRuntimeSnapshot, error) { + snapshot := mcpRuntimeSnapshot{settings: settings} + state := s.manager(settings).Status(ctx, settings.Enabled) + switch state.Status { + case "not_installed", "stopped", "running", "unhealthy": + default: + snapshot.unavailable = true + return snapshot, errors.New("cannot determine the existing MCP Docker instance; retry before changing it") + } + snapshot.installed = state.Installed + if state.Installed { + if state.AdapterVersion == "" || mcpruntime.ValidateDockerImage(mcpruntime.DockerImageRepository+"@"+state.ImageID) != nil { + snapshot.unavailable = true + return snapshot, errors.New("the existing MCP Docker instance has invalid metadata or image identity") + } + snapshot.imageID = state.ImageID + } else if state.Status != "not_installed" { + snapshot.unavailable = true + return snapshot, errors.New("cannot determine the existing MCP Docker installation") + } + return snapshot, nil +} + +type MCPRuntimeService struct { + newRuntimeManager func(dto.MCPRuntimeSettings) mcpRuntimeManager +} + +func (s *MCPRuntimeService) manager(settings dto.MCPRuntimeSettings) mcpRuntimeManager { + if s.newRuntimeManager != nil { + return s.newRuntimeManager(settings) + } + return mcpruntime.NewDocker(settings.ListenPort, settings.DockerImage) +} + +func NewMCPRuntimeService() *MCPRuntimeService { return &MCPRuntimeService{} } + +func (s *MCPRuntimeService) PrepareOnStartup(ctx context.Context) error { + mcpRuntimeMutation.Lock() + defer mcpRuntimeMutation.Unlock() + settings, err := s.Settings() + if err != nil { + return err + } + return s.manager(settings).Prepare(ctx) +} + +func (s *MCPRuntimeService) Settings() (dto.MCPRuntimeSettings, error) { + return s.settings(context.Background()) +} + +func (s *MCPRuntimeService) settings(ctx context.Context) (dto.MCPRuntimeSettings, error) { + settings := dto.MCPRuntimeSettings{ + RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, + ListenPort: mcpruntime.DefaultPort, Revision: 1, + AccessPolicy: "inherit", AllowedIPs: []string{}, + } + var saved model.MCPSetting + err := global.DB.WithContext(ctx).First(&saved, 1).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return settings, nil + } + if err != nil { + return settings, err + } + entries, err := contract.DecodeAllowedIPs(saved.AllowedIPs) + if err != nil { + return settings, err + } + settings = dto.MCPRuntimeSettings{RuntimeType: saved.RuntimeType, DockerImage: saved.DockerImage, Enabled: saved.Enabled, ListenPort: saved.ListenPort, ExternalURL: saved.ExternalURL, Revision: saved.Revision, AccessPolicy: saved.AccessPolicy, AllowedIPs: entries} + return settings, validateMCPRuntimeSettings(settings) +} + +func (s *MCPRuntimeService) Catalog(ctx context.Context) ([]contract.Tool, error) { + settings, err := s.settings(ctx) + if err != nil { + return nil, err + } + return s.manager(settings).Catalog(ctx) +} + +func (s *MCPRuntimeService) Update(req dto.MCPRuntimeSettingsUpdate) (dto.MCPRuntimeSettings, error) { + mcpRuntimeMutation.Lock() + defer mcpRuntimeMutation.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) + defer cancel() + return s.update(ctx, req, false) +} + +func (s *MCPRuntimeService) update(ctx context.Context, req dto.MCPRuntimeSettingsUpdate, restart bool) (dto.MCPRuntimeSettings, error) { + old, err := s.Settings() + if err != nil { + return old, err + } + if req.Revision != old.Revision { + return old, ErrMCPSettingsRevision + } + if err := validateMCPRuntimeSettings(req); err != nil { + return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error()) + } + if err := normalizeMCPAccessSettings(&req); err != nil { + return old, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error()) + } + snapshot, snapshotErr := s.snapshot(ctx, old) + if snapshotErr != nil && req.Enabled { + return old, snapshotErr + } + manager := s.manager(req) + recoverFailure := func(operationErr error) (dto.MCPRuntimeSettings, error) { + current, loadErr := s.Settings() + if loadErr != nil { + return old, errors.Join(operationErr, loadErr, errors.New("MCP settings are unavailable; runtime recovery requires retrying after the database is available")) + } + return current, errors.Join(operationErr, s.restore(current, snapshot)) + } + if err := manager.Apply(ctx, req.Enabled, restart || (!old.Enabled && req.Enabled) || (old.Enabled && old.ListenPort != req.ListenPort)); err != nil { + return recoverFailure(err) + } + if err := s.persist(req, old.Revision); err != nil { + return recoverFailure(err) + } + req.Revision++ + return req, nil +} + +func (s *MCPRuntimeService) persist(req dto.MCPRuntimeSettings, revision uint64) error { + return global.DB.Transaction(func(tx *gorm.DB) error { + seed := model.MCPSetting{ID: 1, RuntimeType: "docker", DockerImage: mcpruntime.DefaultDockerImage, ListenPort: mcpruntime.DefaultPort, Revision: 1} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil { + return err + } + result := tx.Model(&model.MCPSetting{}).Where("id = ? AND revision = ?", 1, revision).Updates(map[string]interface{}{ + "runtime_type": "docker", "docker_image": req.DockerImage, + "enabled": req.Enabled, "listen_port": req.ListenPort, "external_url": req.ExternalURL, "revision": revision + 1, + "access_policy": req.AccessPolicy, "allowed_ips": encodeMCPIPs(req.AllowedIPs), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrMCPSettingsRevision + } + return nil + }) +} + +func (s *MCPRuntimeService) restore(settings dto.MCPRuntimeSettings, snapshot mcpRuntimeSnapshot) error { + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + manager := s.manager(settings) + old := snapshot.settings + if sameMCPRuntimeConfiguration(settings, old) { + if snapshot.unavailable { + return errors.New("the previous MCP Docker identity was unavailable; the runtime could not be restored automatically") + } + if snapshot.imageID != "" { + return manager.Restore(ctx, snapshot.imageID, settings.Enabled) + } + } + return manager.Apply(ctx, settings.Enabled, true) +} + +func sameMCPRuntimeConfiguration(a, b dto.MCPRuntimeSettings) bool { + return a.DockerImage == b.DockerImage && a.ListenPort == b.ListenPort && a.Enabled == b.Enabled +} + +func (s *MCPRuntimeService) Status(ctx context.Context) (dto.MCPRuntimeStatus, error) { + settings, err := s.Settings() + if err != nil { + return dto.MCPRuntimeStatus{}, err + } + state := s.manager(settings).Status(ctx, settings.Enabled) + + return dto.MCPRuntimeStatus{ + RuntimeType: "docker", DockerImage: settings.DockerImage, ContainerName: mcpruntime.DockerContainerName, ImageID: state.ImageID, + Enabled: settings.Enabled, Installed: state.Installed, Status: state.Status, Ready: state.Ready, Reason: state.Reason, + AdapterVersion: state.AdapterVersion, ContractVersion: mcpruntime.ContractVersion, Protocol: mcpruntime.Protocol, Endpoint: settings.ExternalURL, + InternalEndpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", settings.ListenPort), + }, nil +} + +func (s *MCPRuntimeService) Operate(ctx context.Context, operation string) (dto.MCPRuntimeStatus, error) { + mcpRuntimeMutation.Lock() + defer mcpRuntimeMutation.Unlock() + settings, err := s.Settings() + if err != nil { + return dto.MCPRuntimeStatus{}, err + } + switch operation { + case "start", "restart": + settings.Enabled = true + case "stop": + settings.Enabled = false + default: + return dto.MCPRuntimeStatus{}, contract.Invalid() + } + if _, err := s.update(ctx, settings, operation == "restart"); err != nil { + return dto.MCPRuntimeStatus{}, err + } + return s.Status(ctx) +} + +func (s *MCPRuntimeService) Install(ctx context.Context, req dto.MCPRuntimeInstall) (dto.MCPRuntimeStatus, error) { + mcpRuntimeMutation.Lock() + defer mcpRuntimeMutation.Unlock() + old, err := s.settings(ctx) + if err != nil { + return dto.MCPRuntimeStatus{}, err + } + if req.Revision != old.Revision { + return dto.MCPRuntimeStatus{}, ErrMCPSettingsRevision + } + next := old + next.RuntimeType, next.DockerImage, next.ListenPort, next.ExternalURL = "docker", req.DockerImage, req.ListenPort, req.ExternalURL + if err := validateMCPRuntimeSettings(next); err != nil { + return dto.MCPRuntimeStatus{}, contract.Fail("MCP_INVALID_ARGUMENTS", 400, err.Error()) + } + snapshot, err := s.snapshot(ctx, old) + if err != nil { + return dto.MCPRuntimeStatus{}, err + } + if !snapshot.installed { + next.Enabled = true + } + manager := s.manager(next) + rollback := func(authoritative dto.MCPRuntimeSettings) error { + if !snapshot.installed && reflect.DeepEqual(authoritative, old) { + restoreCtx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + return manager.RestoreAbsent(restoreCtx) + } + return s.restore(authoritative, snapshot) + } + recoverFailure := func(operationErr error) error { + current, loadErr := s.Settings() + if loadErr != nil { + return errors.Join(operationErr, loadErr, errors.New("MCP settings are unavailable; runtime recovery requires retrying after the database is available")) + } + return errors.Join(operationErr, rollback(current)) + } + if err := manager.InstallImage(ctx, next.Enabled); err != nil { + return dto.MCPRuntimeStatus{}, recoverFailure(err) + } + if err := s.persist(next, old.Revision); err != nil { + return dto.MCPRuntimeStatus{}, recoverFailure(err) + } + return s.Status(ctx) +} + +func (s *MCPRuntimeService) Logs(ctx context.Context) (dto.MCPRuntimeLogs, error) { + settings, err := s.Settings() + if err != nil { + return dto.MCPRuntimeLogs{}, err + } + content, err := s.manager(settings).Logs(ctx) + return dto.MCPRuntimeLogs{Content: content}, err +} + +func validateMCPRuntimeSettings(req dto.MCPRuntimeSettings) error { + if req.AccessPolicy == "" { + return errors.New("MCP source policy is required") + } + if err := normalizeMCPAccessSettings(&req); err != nil { + return err + } + if req.RuntimeType != "docker" { + return errors.New("1Panel MCP supports only the Docker runtime") + } + if err := mcpruntime.ValidateDockerImage(req.DockerImage); err != nil { + return err + } + if req.ListenPort < 1024 || req.ListenPort > 65535 { + return errors.New("MCP listen port must be between 1024 and 65535") + } + if req.ExternalURL == "" { + return nil + } + if len(req.ExternalURL) > 2048 || strings.ContainsAny(req.ExternalURL, "\r\n") { + return errors.New("invalid MCP external URL") + } + u, err := url.Parse(req.ExternalURL) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/mcp" { + return errors.New("MCP external URL must be an HTTP or HTTPS URL ending in /mcp without credentials or query parameters") + } + return nil +} diff --git a/core/i18n/i18n.go b/core/i18n/i18n.go index 39df1c972..3a6b61816 100644 --- a/core/i18n/i18n.go +++ b/core/i18n/i18n.go @@ -4,6 +4,7 @@ import ( "embed" "fmt" "strings" + "sync" "sync/atomic" "github.com/1Panel-dev/1Panel/core/app/repo" @@ -253,3 +254,30 @@ func initBundle() { panic("[i18n] failed to init language files, See log above for details") } } + +var ( + mcpDocumentBundleOnce sync.Once + mcpDocumentBundle *i18n.Bundle + mcpDocumentBundleErr error +) + +func NewMCPDocumentLocalizer(lang string) (*i18n.Localizer, error) { + mcpDocumentBundleOnce.Do(func() { + documentBundle := i18n.NewBundle(language.English) + documentBundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal) + for _, file := range langFiles { + if _, err := documentBundle.LoadMessageFileFS(fs, file); err != nil { + mcpDocumentBundleErr = fmt.Errorf("load MCP document language %s: %w", file, err) + return + } + } + mcpDocumentBundle = documentBundle + }) + if mcpDocumentBundleErr != nil { + return nil, mcpDocumentBundleErr + } + if strings.TrimSpace(lang) == "" { + lang = GetLanguage() + } + return i18n.NewLocalizer(mcpDocumentBundle, lang, language.English.String()), nil +} diff --git a/core/i18n/lang/en.yaml b/core/i18n/lang/en.yaml index ec0b738e9..4da17546e 100644 --- a/core/i18n/lang/en.yaml +++ b/core/i18n/lang/en.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "Unable to verify API key terminal authorizati LocalAppSyncDispatch: "Dispatch local app sync" LocalAppSyncDispatchNode: "Dispatch local app sync to {{.node}} (node task: {{.taskID}})" ErrLocalAppSyncDisabled: "Local app sync across nodes is disabled" + +# MCP connection document +MCPDocumentTitle: "1Panel MCP connection setup prompt" +MCPDocumentIntro: "Give this file to an AI Agent and ask it to complete the setup described here. This file contains the full Token; keep it private." +MCPDocumentTaskTitle: "Configuration task for the AI Agent" +MCPDocumentTask: "Configure 1Panel MCP for the client specified below in the current environment, then verify the connection and tool discovery. Use the connection parameters in the configuration block below." +MCPDocumentClientCodex: "Target client: Codex. First confirm the configuration scope and the actual CODEX_HOME. Use the active config.toml (default: ~/.codex/config.toml) and merge the TOML below into the corresponding mcp_servers entry." +MCPDocumentClientClaude: "Target client: Claude. First confirm the actual client version. The JSON below is intended for clients that support HTTP MCP configuration. Use a configuration location or management method supported by that version; do not put remote HTTP configuration into an entry that only supports stdio." +MCPDocumentClientGeneric: "Target client: a generic MCP client. First identify the client in use and the configuration scope. Ask the user if uncertain; do not guess the configuration file location. Merge the connection parameters below using the client’s syntax." +MCPDocumentSteps: |- + 1. Confirm that the target client supports Streamable HTTP and Bearer request headers. Explain the limitation if its version does not support them. + 2. Read and back up the existing configuration. Merge the single server entry below, preserving all other MCP and client settings. If the name already exists, update only that entry. + 3. Write the URL and Authorization values exactly as provided. Do not regenerate the Token or change panel permissions or service state. Do not put the Token in command history, logs, replies or Git. + 4. Keep certificate verification enabled for HTTPS. For a private CA, ask the user to provide a trusted CA; do not automatically trust the server certificate or disable TLS verification. Use HTTP only on trusted networks; configure HTTPS before crossing an untrusted network. + 5. Reload through the client, then verify MCP initialization and tools/list. Do not execute tools/call or write operations. If reloading is unavailable, report that a new session or restart is required. Saving a file does not prove a successful connection. + 6. Report the client, configuration location, server name, connection and tool discovery results, and any remaining issues. Hide the Token. +MCPDocumentConfigTitle: "Connection configuration" +MCPDocumentNotes: "The panel server checks tool, node and current account permissions on every request. Update the client configuration after regenerating the Token. Access is rejected when the credential is disabled, revoked or expired." +MCPDocumentHTTPWarning: "This address uses unencrypted HTTP. Configure HTTPS before using it on an untrusted network." + +# MCP management +ErrMCPConflict: "MCP data changed or conflicts with existing data. Refresh and review it." +ErrMCPPolicyChanged: "The MCP tool catalog or authorization changed. Refresh, review, and save the authorization." +ErrMCPCatalogUnavailable: "The MCP tool catalog is unavailable. Check the service status." +ErrMCPDisabled: "The MCP service is not enabled." +ErrMCPSecretKeyUnavailable: "MCP credential encryption is unavailable. Try again later." +ErrMCPSecretInvalid: "The stored MCP Token cannot be decrypted. Regenerate the Token." +ErrMCPCredentialInactive: "The MCP credential is disabled, expired, or revoked." +ErrMCPAuditUnavailable: "MCP operation auditing is unavailable. Verify the operation result before continuing." +ErrMCPEndpointUnavailable: "Configure a valid external MCP endpoint first." diff --git a/core/i18n/lang/es-ES.yaml b/core/i18n/lang/es-ES.yaml index 21bba0cc4..701732afd 100644 --- a/core/i18n/lang/es-ES.yaml +++ b/core/i18n/lang/es-ES.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "No se pudo verificar la autorización de term LocalAppSyncDispatch: "Enviar sincronización de apps locales" LocalAppSyncDispatchNode: "Enviar sincronización de apps locales a {{.node}} (tarea del nodo: {{.taskID}})" ErrLocalAppSyncDisabled: "La sincronización de apps locales entre nodos está desactivada" + +# MCP connection document +MCPDocumentTitle: "Instrucciones de configuración de la conexión MCP de 1Panel" +MCPDocumentIntro: "Entregue este archivo a un agente de IA y pídale que realice la configuración descrita. El archivo contiene el Token completo; manténgalo privado." +MCPDocumentTaskTitle: "Tarea de configuración para el agente de IA" +MCPDocumentTask: "Configure el MCP de 1Panel en el entorno actual para el cliente indicado a continuación y verifique la conexión y el descubrimiento de herramientas. Use los parámetros del bloque de configuración siguiente." +MCPDocumentClientCodex: "Cliente de destino: Codex. Confirme primero el ámbito de configuración y el CODEX_HOME real. Use el config.toml activo (por defecto: ~/.codex/config.toml) e integre el TOML siguiente en la entrada mcp_servers correspondiente." +MCPDocumentClientClaude: "Cliente de destino: Claude. Confirme primero la versión real del cliente. El JSON siguiente está destinado a clientes que admitan la configuración MCP por HTTP. Use una ubicación o un método de gestión admitidos por esa versión; no escriba la configuración HTTP remota en una entrada que solo admita stdio." +MCPDocumentClientGeneric: "Cliente de destino: cliente MCP genérico. Identifique primero el cliente utilizado y el ámbito de configuración. Si no puede determinarlo, pregunte al usuario; no adivine la ubicación del archivo. Integre los parámetros siguientes con la sintaxis del cliente." +MCPDocumentSteps: |- + 1. Confirme que el cliente admite Streamable HTTP y cabeceras de solicitud Bearer. Si la versión no los admite, explique el motivo. + 2. Lea la configuración existente y cree una copia de seguridad. Integre la única entrada de servidor siguiente y conserve el resto de los ajustes MCP y del cliente. Si el nombre ya existe, actualice solo esa entrada. + 3. Escriba los valores de URL y Authorization exactamente como se proporcionan. No regenere el Token ni cambie los permisos del panel o el estado del servicio. No incluya el Token en el historial de comandos, registros, respuestas ni Git. + 4. Mantenga la validación de certificados HTTPS. Para una CA privada, pida al usuario una CA de confianza; no confíe automáticamente en el certificado del servidor ni desactive la validación TLS. Use HTTP solo en redes de confianza; configure HTTPS antes de atravesar una red no fiable. + 5. Recargue mediante el cliente y verifique la inicialización MCP y tools/list. No ejecute tools/call ni operaciones de escritura. Si no se puede recargar, indique que hace falta una nueva sesión o un reinicio. Guardar el archivo no demuestra que la conexión funcione. + 6. Informe del cliente, la ubicación de configuración, el nombre del servidor, los resultados de conexión y descubrimiento de herramientas y los problemas pendientes. Oculte el Token. +MCPDocumentConfigTitle: "Configuración de la conexión" +MCPDocumentNotes: "El servidor del panel comprueba los permisos de herramientas, nodos y de la cuenta actual en cada solicitud. Tras regenerar el Token, actualice la configuración del cliente. Se deniega el acceso si la credencial se desactiva, revoca o caduca." +MCPDocumentHTTPWarning: "Esta dirección utiliza HTTP sin cifrar. Configure HTTPS antes de usarla en una red no fiable." + +# MCP management +ErrMCPConflict: "Los datos MCP han cambiado o entran en conflicto. Actualice la página y revíselos." +ErrMCPPolicyChanged: "El catálogo de herramientas MCP o la autorización han cambiado. Actualice, revise y guarde la autorización." +ErrMCPCatalogUnavailable: "El catálogo de herramientas MCP no está disponible. Compruebe el estado del servicio." +ErrMCPDisabled: "El servicio MCP no está habilitado." +ErrMCPSecretKeyUnavailable: "El cifrado de credenciales MCP no está disponible. Inténtelo más tarde." +ErrMCPSecretInvalid: "No se puede descifrar el Token MCP almacenado. Genere un Token nuevo." +ErrMCPCredentialInactive: "La credencial MCP está deshabilitada, ha caducado o se ha revocado." +ErrMCPAuditUnavailable: "La auditoría de operaciones MCP no está disponible. Verifique el resultado antes de continuar." +ErrMCPEndpointUnavailable: "Configure primero una dirección externa MCP válida." diff --git a/core/i18n/lang/fa.yaml b/core/i18n/lang/fa.yaml index 6764cc443..f6ea2e1a3 100644 --- a/core/i18n/lang/fa.yaml +++ b/core/i18n/lang/fa.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "امکان بررسی پشتیبانی از LocalAppSyncDispatch: "ارسال درخواست همگام‌سازی برنامه‌های محلی" LocalAppSyncDispatchNode: "ارسال درخواست همگام‌سازی به {{.node}} (وظیفه گره: {{.taskID}})" ErrLocalAppSyncDisabled: "همگام‌سازی برنامه‌های محلی بین گره‌ها غیرفعال است" + +# MCP connection document +MCPDocumentTitle: "دستور راه‌اندازی اتصال MCP در 1Panel" +MCPDocumentIntro: "این فایل را به یک عامل هوش مصنوعی بدهید و از آن بخواهید تنظیمات را طبق سند انجام دهد. فایل شامل Token کامل است؛ آن را محرمانه نگه دارید." +MCPDocumentTaskTitle: "وظیفه پیکربندی برای عامل هوش مصنوعی" +MCPDocumentTask: "در محیط فعلی، MCP مربوط به 1Panel را برای کلاینت مشخص‌شده در پایین پیکربندی کنید؛ سپس اتصال و کشف ابزارها را بررسی کنید. پارامترهای اتصال را از بخش پیکربندی زیر بردارید." +MCPDocumentClientCodex: "کلاینت هدف: Codex. ابتدا محدوده پیکربندی و CODEX_HOME واقعی را مشخص کنید. از config.toml فعال (پیش‌فرض: ~/.codex/config.toml) استفاده کنید و TOML زیر را با ورودی مربوط در mcp_servers ادغام کنید." +MCPDocumentClientClaude: "کلاینت هدف: Claude. ابتدا نسخه واقعی کلاینت را مشخص کنید. JSON زیر برای کلاینت‌هایی است که از پیکربندی MCP روی HTTP پشتیبانی می‌کنند. از محل پیکربندی یا روش مدیریتی پشتیبانی‌شده در همان نسخه استفاده کنید؛ تنظیمات HTTP راه دور را در ورودی مختص stdio ننویسید." +MCPDocumentClientGeneric: "کلاینت هدف: یک کلاینت عمومی MCP. ابتدا کلاینت مورد استفاده و محدوده پیکربندی را مشخص کنید. اگر مطمئن نیستید، از کاربر بپرسید و محل فایل پیکربندی را حدس نزنید. پارامترهای اتصال زیر را طبق نحو کلاینت ادغام کنید." +MCPDocumentSteps: |- + 1. مطمئن شوید کلاینت هدف از Streamable HTTP و سرآیندهای درخواست Bearer پشتیبانی می‌کند. اگر نسخه آن پشتیبانی نمی‌کند، دلیل را توضیح دهید. + 2. پیکربندی موجود را بخوانید و از آن نسخه پشتیبان بگیرید. تنها ورودی سرور زیر را ادغام کنید و سایر تنظیمات MCP و کلاینت را حفظ کنید. اگر نام از قبل وجود دارد، فقط همان ورودی را به‌روز کنید. + 3. مقادیر URL و Authorization را دقیقاً همان‌طور که ارائه شده‌اند بنویسید. Token را دوباره تولید نکنید و مجوزهای پنل یا وضعیت سرویس را تغییر ندهید. Token را در تاریخچه فرمان‌ها، گزارش‌ها، پاسخ‌ها یا Git قرار ندهید. + 4. اعتبارسنجی گواهی HTTPS را فعال نگه دارید. برای CA خصوصی، از کاربر بخواهید CA مورد اعتماد ارائه کند؛ گواهی سرور را خودکار معتبر ندانید و اعتبارسنجی TLS را غیرفعال نکنید. HTTP فقط در شبکه‌های مورد اعتماد استفاده شود؛ پیش از عبور از شبکه نامطمئن، HTTPS را پیکربندی کنید. + 5. تنظیمات را از طریق کلاینت دوباره بارگذاری کنید و راه‌اندازی MCP و tools/list را بررسی کنید. tools/call یا عملیات نوشتن را اجرا نکنید. اگر بارگذاری دوباره ممکن نیست، نیاز به نشست جدید یا راه‌اندازی مجدد را گزارش کنید. ذخیره فایل به‌تنهایی به معنی موفقیت اتصال نیست. + 6. کلاینت، محل پیکربندی، نام سرور، نتیجه اتصال و کشف ابزارها و مشکلات باقی‌مانده را گزارش کنید. Token را پنهان نگه دارید. +MCPDocumentConfigTitle: "پیکربندی اتصال" +MCPDocumentNotes: "سرور پنل در هر درخواست مجوزهای ابزار، گره و حساب مالک را بررسی می‌کند. پس از تولید دوباره Token، پیکربندی کلاینت را به‌روز کنید. اگر اعتبارنامه غیرفعال، لغو یا منقضی شود، دسترسی رد می‌شود." +MCPDocumentHTTPWarning: "این نشانی از HTTP بدون رمزنگاری استفاده می‌کند. پیش از استفاده در شبکه نامطمئن، HTTPS را پیکربندی کنید." + +# MCP management +ErrMCPConflict: "داده‌های MCP تغییر کرده‌اند یا با داده‌های موجود تداخل دارند. صفحه را تازه‌سازی و داده‌ها را بررسی کنید." +ErrMCPPolicyChanged: "فهرست ابزارهای MCP یا مجوزها تغییر کرده است. صفحه را تازه‌سازی کنید، مجوزها را بررسی و ذخیره کنید." +ErrMCPCatalogUnavailable: "فهرست ابزارهای MCP در دسترس نیست. وضعیت سرویس را بررسی کنید." +ErrMCPDisabled: "سرویس MCP فعال نیست." +ErrMCPSecretKeyUnavailable: "رمزنگاری اطلاعات اعتبار MCP در دسترس نیست. بعداً دوباره تلاش کنید." +ErrMCPSecretInvalid: "رمزگشایی Token ذخیره‌شده MCP ممکن نیست. Token را دوباره ایجاد کنید." +ErrMCPCredentialInactive: "اعتبار MCP غیرفعال، منقضی یا لغو شده است." +ErrMCPAuditUnavailable: "ثبت حسابرسی عملیات MCP در دسترس نیست. پیش از ادامه، نتیجه عملیات را بررسی کنید." +ErrMCPEndpointUnavailable: "ابتدا یک نشانی خارجی معتبر برای MCP تنظیم کنید." diff --git a/core/i18n/lang/ja.yaml b/core/i18n/lang/ja.yaml index 99d5e545d..22549052e 100644 --- a/core/i18n/lang/ja.yaml +++ b/core/i18n/lang/ja.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "API キーによるターミナル認証へ LocalAppSyncDispatch: "ローカルアプリ同期要求の送信" LocalAppSyncDispatchNode: "{{.node}} にローカルアプリ同期要求を送信(ノードタスク: {{.taskID}})" ErrLocalAppSyncDisabled: "ノード間のローカルアプリ同期は無効です" + +# MCP connection document +MCPDocumentTitle: "1Panel MCP 接続設定用プロンプト" +MCPDocumentIntro: "このファイルを AI エージェントに渡し、記載された手順で設定するよう依頼してください。完全な Token が含まれるため、非公開で保管してください。" +MCPDocumentTaskTitle: "AI エージェントへの設定タスク" +MCPDocumentTask: "現在の環境で、以下に指定されたクライアントに 1Panel MCP を設定し、接続とツールの検出を検証してください。接続パラメーターには以下の設定ブロックを使用してください。" +MCPDocumentClientCodex: "対象クライアント:Codex。まず設定の適用範囲と実際の CODEX_HOME を確認してください。有効な config.toml(既定:~/.codex/config.toml)を使用し、以下の TOML を対応する mcp_servers エントリーに統合してください。" +MCPDocumentClientClaude: "対象クライアント:Claude。まず実際のクライアントのバージョンを確認してください。以下の JSON は HTTP MCP 設定に対応したクライアント向けです。そのバージョンが対応する設定場所または管理方法を使用し、stdio 専用の設定先にリモート HTTP 設定を書き込まないでください。" +MCPDocumentClientGeneric: "対象クライアント:汎用 MCP クライアント。まず使用中のクライアントと設定の適用範囲を特定してください。不明な場合はユーザーに確認し、設定ファイルの場所を推測しないでください。クライアントの構文に従って、以下の接続パラメーターを統合してください。" +MCPDocumentSteps: |- + 1. 対象クライアントが Streamable HTTP と Bearer リクエストヘッダーに対応していることを確認してください。対応していないバージョンの場合は理由を説明してください。 + 2. 既存の設定を読み取り、バックアップしてください。以下の単一サーバーエントリーを統合し、ほかの MCP 設定とクライアント設定を保持してください。同名のエントリーがある場合は、その項目だけを更新してください。 + 3. URL と Authorization は提示された値をそのまま書き込んでください。Token の再生成、パネル権限やサービスの起動・停止状態の変更は行わないでください。Token をコマンド履歴、ログ、返信、Git に含めないでください。 + 4. HTTPS の証明書検証は有効のままにしてください。プライベート CA の場合はユーザーに信頼できる CA の提供を求め、サーバー証明書を自動的に信頼したり、TLS 検証を無効にしたりしないでください。HTTP は信頼できるネットワーク内に限定し、信頼できないネットワークを経由する前に HTTPS を設定してください。 + 5. クライアントで設定を再読み込みし、MCP 初期化と tools/list を検証してください。tools/call や書き込み操作は実行しないでください。再読み込みできない場合は、新しいセッションまたは再起動が必要であることを報告してください。ファイルの保存だけで接続成功と判断しないでください。 + 6. クライアント、設定場所、サーバー名、接続とツール検出の結果、未解決の問題を報告してください。Token は表示しないでください。 +MCPDocumentConfigTitle: "接続設定" +MCPDocumentNotes: "パネルのサーバーは、ツール、ノード、所属アカウントの現在の権限をリクエストごとに確認します。Token を再生成した後はクライアント設定を更新してください。認証情報が無効化、取り消し、期限切れになった場合、接続は拒否されます。" +MCPDocumentHTTPWarning: "現在のアドレスは暗号化されていない HTTP を使用しています。信頼できないネットワークで使用する前に HTTPS を設定してください。" + +# MCP management +ErrMCPConflict: "MCP データが変更されたか、競合しています。再読み込みして確認してください。" +ErrMCPPolicyChanged: "MCP ツールカタログまたは認可設定が変更されました。再読み込みして確認し、認可設定を保存してください。" +ErrMCPCatalogUnavailable: "MCP ツールカタログを利用できません。サービスの状態を確認してください。" +ErrMCPDisabled: "MCP サービスが有効になっていません。" +ErrMCPSecretKeyUnavailable: "MCP 認証情報を暗号化できません。しばらくしてから再試行してください。" +ErrMCPSecretInvalid: "保存済みの MCP Token を復号できません。Token を再生成してください。" +ErrMCPCredentialInactive: "MCP 認証情報が無効、期限切れ、または取り消し済みです。" +ErrMCPAuditUnavailable: "MCP 操作の監査記録を利用できません。操作結果を確認してから続行してください。" +ErrMCPEndpointUnavailable: "先に有効な MCP 外部接続先を設定してください。" diff --git a/core/i18n/lang/ko.yaml b/core/i18n/lang/ko.yaml index 7c572f981..2337c2961 100644 --- a/core/i18n/lang/ko.yaml +++ b/core/i18n/lang/ko.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "API Key 터미널 인증 지원 여부를 확 LocalAppSyncDispatch: "로컬 앱 동기화 요청 전송" LocalAppSyncDispatchNode: "{{.node}}에 로컬 앱 동기화 요청 전송 (노드 작업: {{.taskID}})" ErrLocalAppSyncDisabled: "노드 간 로컬 앱 동기화가 비활성화되어 있습니다" + +# MCP connection document +MCPDocumentTitle: "1Panel MCP 연결 설정 프롬프트" +MCPDocumentIntro: "이 파일을 AI 에이전트에 전달하고 문서에 따라 설정하도록 요청하세요. 전체 Token이 포함되어 있으므로 비공개로 보관하세요." +MCPDocumentTaskTitle: "AI 에이전트의 설정 작업" +MCPDocumentTask: "현재 환경에서 아래에 지정된 클라이언트에 1Panel MCP를 설정하고 연결 및 도구 검색을 검증하세요. 연결 매개변수는 아래 설정 블록을 기준으로 사용하세요." +MCPDocumentClientCodex: "대상 클라이언트: Codex. 먼저 설정 범위와 실제 CODEX_HOME을 확인하세요. 적용 중인 config.toml(기본값: ~/.codex/config.toml)을 사용하고 아래 TOML을 해당 mcp_servers 항목에 병합하세요." +MCPDocumentClientClaude: "대상 클라이언트: Claude. 먼저 실제 클라이언트 버전을 확인하세요. 아래 JSON은 HTTP MCP 설정을 지원하는 클라이언트용입니다. 해당 버전에서 지원하는 설정 위치나 관리 방법을 사용하고, stdio만 지원하는 항목에 원격 HTTP 설정을 넣지 마세요." +MCPDocumentClientGeneric: "대상 클라이언트: 범용 MCP 클라이언트. 먼저 사용 중인 클라이언트와 설정 범위를 확인하세요. 확실하지 않으면 사용자에게 질문하고 설정 파일 위치를 추측하지 마세요. 클라이언트 구문에 따라 아래 연결 매개변수를 병합하세요." +MCPDocumentSteps: |- + 1. 대상 클라이언트가 Streamable HTTP와 Bearer 요청 헤더를 지원하는지 확인하세요. 해당 버전이 지원하지 않으면 이유를 설명하세요. + 2. 기존 설정을 읽고 백업하세요. 아래의 단일 서버 항목을 병합하면서 다른 MCP 및 클라이언트 설정은 유지하세요. 같은 이름이 있으면 해당 항목만 업데이트하세요. + 3. URL과 Authorization 값을 제공된 그대로 기록하세요. Token을 다시 생성하거나 패널 권한 또는 서비스 실행 상태를 변경하지 마세요. Token을 명령 기록, 로그, 답변 또는 Git에 포함하지 마세요. + 4. HTTPS 인증서 검증을 유지하세요. 사설 CA를 사용하는 경우 사용자에게 신뢰할 수 있는 CA를 요청하고, 서버 인증서를 자동으로 신뢰하거나 TLS 검증을 끄지 마세요. HTTP는 신뢰할 수 있는 네트워크에서만 사용하고, 신뢰할 수 없는 네트워크를 통과하기 전에 HTTPS를 설정하세요. + 5. 클라이언트에서 다시 로드한 후 MCP 초기화와 tools/list를 검증하세요. tools/call이나 쓰기 작업은 실행하지 마세요. 다시 로드할 수 없으면 새 세션 또는 재시작이 필요하다고 보고하세요. 파일 저장만으로 연결 성공을 판단하지 마세요. + 6. 클라이언트, 설정 위치, 서버 이름, 연결 및 도구 검색 결과와 남은 문제를 보고하세요. Token은 숨기세요. +MCPDocumentConfigTitle: "연결 설정" +MCPDocumentNotes: "패널 서버는 요청마다 도구, 노드 및 소유 계정의 현재 권한을 검사합니다. Token을 다시 생성하면 클라이언트 설정을 업데이트해야 합니다. 자격 증명이 비활성화, 취소 또는 만료되면 접근이 거부됩니다." +MCPDocumentHTTPWarning: "현재 주소는 암호화되지 않은 HTTP를 사용합니다. 신뢰할 수 없는 네트워크에서 사용하기 전에 HTTPS를 설정하세요." + +# MCP management +ErrMCPConflict: "MCP 데이터가 변경되었거나 충돌합니다. 새로 고친 후 확인하세요." +ErrMCPPolicyChanged: "MCP 도구 카탈로그 또는 권한이 변경되었습니다. 새로 고친 후 권한을 확인하고 저장하세요." +ErrMCPCatalogUnavailable: "MCP 도구 카탈로그를 사용할 수 없습니다. 서비스 상태를 확인하세요." +ErrMCPDisabled: "MCP 서비스가 활성화되지 않았습니다." +ErrMCPSecretKeyUnavailable: "MCP 자격 증명을 암호화할 수 없습니다. 나중에 다시 시도하세요." +ErrMCPSecretInvalid: "저장된 MCP Token을 복호화할 수 없습니다. Token을 다시 생성하세요." +ErrMCPCredentialInactive: "MCP 자격 증명이 비활성화되었거나 만료 또는 취소되었습니다." +ErrMCPAuditUnavailable: "MCP 작업 감사 기록을 사용할 수 없습니다. 작업 결과를 확인한 후 계속하세요." +ErrMCPEndpointUnavailable: "먼저 유효한 MCP 외부 접속 주소를 설정하세요." diff --git a/core/i18n/lang/lo.yaml b/core/i18n/lang/lo.yaml index 4d311d043..95fd80fb2 100644 --- a/core/i18n/lang/lo.yaml +++ b/core/i18n/lang/lo.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "ບໍ່ສາມາດຢືນຢັນກ LocalAppSyncDispatch: "ສົ່ງຄຳຂໍຊິງຄ໌ແອັບໃນເຄື່ອງ" LocalAppSyncDispatchNode: "ສົ່ງຄຳຂໍຊິງຄ໌ໄປຫາ {{.node}} (ວຽກຂອງໂນດ: {{.taskID}})" ErrLocalAppSyncDisabled: "ການຊິງຄ໌ແອັບລະຫວ່າງໂນດຍັງບໍ່ໄດ້ເປີດ" + +# MCP connection document +MCPDocumentTitle: "ຄຳສັ່ງຕັ້ງຄ່າການເຊື່ອມຕໍ່ 1Panel MCP" +MCPDocumentIntro: "ສົ່ງໄຟລ໌ນີ້ໃຫ້ຕົວແທນ AI ແລະ ຂໍໃຫ້ຕັ້ງຄ່າຕາມເອກະສານ. ໄຟລ໌ນີ້ມີ Token ຄົບຖ້ວນ; ກະລຸນາເກັບເປັນຄວາມລັບ." +MCPDocumentTaskTitle: "ໜ້າວຽກຕັ້ງຄ່າສຳລັບຕົວແທນ AI" +MCPDocumentTask: "ກະລຸນາຕັ້ງຄ່າ 1Panel MCP ໃນສະພາບແວດລ້ອມປັດຈຸບັນສຳລັບໄຄລເອນທີ່ລະບຸຂ້າງລຸ່ມ ແລ້ວກວດສອບການເຊື່ອມຕໍ່ ແລະ ການຄົ້ນພົບເຄື່ອງມື. ໃຊ້ພາລາມິເຕີການເຊື່ອມຕໍ່ຈາກສ່ວນຕັ້ງຄ່າຂ້າງລຸ່ມ." +MCPDocumentClientCodex: "ໄຄລເອນເປົ້າໝາຍ: Codex. ກ່ອນອື່ນໃຫ້ຢືນຢັນຂອບເຂດການຕັ້ງຄ່າ ແລະ CODEX_HOME ທີ່ໃຊ້ຈິງ. ໃຊ້ config.toml ທີ່ມີຜົນ (ຄ່າເລີ່ມຕົ້ນ: ~/.codex/config.toml) ແລະ ລວມ TOML ຂ້າງລຸ່ມເຂົ້າໃນລາຍການ mcp_servers ທີ່ກົງກັນ." +MCPDocumentClientClaude: "ໄຄລເອນເປົ້າໝາຍ: Claude. ກ່ອນອື່ນໃຫ້ຢືນຢັນເວີຊັນໄຄລເອນທີ່ໃຊ້ຈິງ. JSON ຂ້າງລຸ່ມເໝາະສຳລັບໄຄລເອນທີ່ຮອງຮັບການຕັ້ງຄ່າ HTTP MCP. ໃຊ້ຕຳແໜ່ງຕັ້ງຄ່າ ຫຼື ວິທີຈັດການທີ່ເວີຊັນນັ້ນຮອງຮັບ; ຢ່າຂຽນການຕັ້ງຄ່າ HTTP ທາງໄກໃສ່ຊ່ອງທີ່ຮອງຮັບພຽງ stdio." +MCPDocumentClientGeneric: "ໄຄລເອນເປົ້າໝາຍ: ໄຄລເອນ MCP ທົ່ວໄປ. ກ່ອນອື່ນໃຫ້ລະບຸໄຄລເອນທີ່ໃຊ້ ແລະ ຂອບເຂດການຕັ້ງຄ່າ. ຖ້າບໍ່ແນ່ໃຈໃຫ້ຖາມຜູ້ໃຊ້, ຢ່າເດົາຕຳແໜ່ງໄຟລ໌. ລວມພາລາມິເຕີຂ້າງລຸ່ມຕາມຮູບແບບຂອງໄຄລເອນ." +MCPDocumentSteps: |- + 1. ຢືນຢັນວ່າໄຄລເອນເປົ້າໝາຍຮອງຮັບ Streamable HTTP ແລະ ສ່ວນຫົວຄຳຮ້ອງຂໍ Bearer. ຖ້າເວີຊັນບໍ່ຮອງຮັບ ໃຫ້ອະທິບາຍເຫດຜົນ. + 2. ອ່ານ ແລະ ສຳຮອງການຕັ້ງຄ່າທີ່ມີຢູ່. ລວມລາຍການເຊີບເວີດຽວຂ້າງລຸ່ມ ໂດຍຮັກສາການຕັ້ງຄ່າ MCP ແລະ ໄຄລເອນອື່ນໄວ້. ຖ້າຊື່ຊ້ຳກັນ ໃຫ້ປັບປຸງສະເພາະລາຍການນັ້ນ. + 3. ຂຽນ URL ແລະ Authorization ຕາມຄ່າເດີມທີ່ໃຫ້. ຢ່າສ້າງ Token ໃໝ່ ຫຼື ປ່ຽນສິດແຜງຄວບຄຸມ ຫຼື ສະຖານະເປີດປິດບໍລິການ. ຢ່າຂຽນ Token ໃສ່ປະຫວັດຄຳສັ່ງ, ບັນທຶກເຫດການ, ຄຳຕອບ ຫຼື Git. + 4. ຮັກສາການກວດສອບໃບຢັ້ງຢືນ HTTPS ໄວ້. ສຳລັບ CA ສ່ວນຕົວ ໃຫ້ຂໍ CA ທີ່ເຊື່ອຖືໄດ້ຈາກຜູ້ໃຊ້; ຢ່າເຊື່ອຖືໃບຢັ້ງຢືນເຊີບເວີອັດຕະໂນມັດ ຫຼື ປິດການກວດສອບ TLS. ໃຊ້ HTTP ສະເພາະເຄືອຂ່າຍທີ່ເຊື່ອຖືໄດ້; ຕັ້ງຄ່າ HTTPS ກ່ອນຜ່ານເຄືອຂ່າຍທີ່ບໍ່ເຊື່ອຖື. + 5. ໂຫຼດຄືນຜ່ານໄຄລເອນ ແລ້ວກວດສອບການເລີ່ມຕົ້ນ MCP ແລະ tools/list. ຢ່າເອີ້ນ tools/call ຫຼື ດຳເນີນການຂຽນ. ຖ້າໂຫຼດຄືນບໍ່ໄດ້ ໃຫ້ລາຍງານວ່າຕ້ອງເປີດເຊດຊັນໃໝ່ ຫຼື ເລີ່ມໃໝ່. ຢ່າຖືວ່າການບັນທຶກໄຟລ໌ໝາຍເຖິງເຊື່ອມຕໍ່ສຳເລັດ. + 6. ລາຍງານໄຄລເອນ, ຕຳແໜ່ງຕັ້ງຄ່າ, ຊື່ເຊີບເວີ, ຜົນການເຊື່ອມຕໍ່ ແລະ ການຄົ້ນພົບເຄື່ອງມື ພ້ອມບັນຫາທີ່ຍັງເຫຼືອ. ເຊື່ອງ Token ໄວ້. +MCPDocumentConfigTitle: "ການຕັ້ງຄ່າເຊື່ອມຕໍ່" +MCPDocumentNotes: "ເຊີບເວີແຜງຄວບຄຸມກວດສອບສິດເຄື່ອງມື, ໂນດ ແລະ ສິດປັດຈຸບັນຂອງບັນຊີເຈົ້າຂອງທຸກຄຳຮ້ອງຂໍ. ຫຼັງສ້າງ Token ໃໝ່ ຕ້ອງປັບປຸງການຕັ້ງຄ່າໄຄລເອນ. ການເຂົ້າເຖິງຈະຖືກປະຕິເສດເມື່ອຂໍ້ມູນຢືນຢັນຖືກປິດ, ຖອນ ຫຼື ໝົດອາຍຸ." +MCPDocumentHTTPWarning: "ທີ່ຢູ່ນີ້ໃຊ້ HTTP ທີ່ບໍ່ເຂົ້າລະຫັດ. ກະລຸນາຕັ້ງຄ່າ HTTPS ກ່ອນໃຊ້ໃນເຄືອຂ່າຍທີ່ບໍ່ເຊື່ອຖື." + +# MCP management +ErrMCPConflict: "ຂໍ້ມູນ MCP ຖືກປ່ຽນແປງ ຫຼື ຂັດແຍ່ງກັບຂໍ້ມູນເດີມ. ກະລຸນາໂຫຼດໜ້າໃໝ່ແລະກວດສອບ." +ErrMCPPolicyChanged: "ລາຍການເຄື່ອງມື MCP ຫຼື ການອະນຸຍາດຖືກປ່ຽນແປງ. ກະລຸນາໂຫຼດໜ້າໃໝ່, ກວດສອບ ແລະ ບັນທຶກການອະນຸຍາດ." +ErrMCPCatalogUnavailable: "ລາຍການເຄື່ອງມື MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບສະຖານະບໍລິການ." +ErrMCPDisabled: "ບໍລິການ MCP ຍັງບໍ່ຖືກເປີດໃຊ້ງານ." +ErrMCPSecretKeyUnavailable: "ການເຂົ້າລະຫັດຂໍ້ມູນຢັ້ງຢືນ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາລອງໃໝ່ພາຍຫຼັງ." +ErrMCPSecretInvalid: "ບໍ່ສາມາດຖອດລະຫັດ MCP Token ທີ່ບັນທຶກໄວ້. ກະລຸນາສ້າງ Token ໃໝ່." +ErrMCPCredentialInactive: "ຂໍ້ມູນຢັ້ງຢືນ MCP ຖືກປິດໃຊ້ງານ, ໝົດອາຍຸ ຫຼື ຖືກຖອນ." +ErrMCPAuditUnavailable: "ການບັນທຶກກວດສອບການດຳເນີນງານ MCP ບໍ່ພ້ອມໃຊ້ງານ. ກະລຸນາກວດສອບຜົນການດຳເນີນງານກ່ອນດຳເນີນການຕໍ່." +ErrMCPEndpointUnavailable: "ກະລຸນາຕັ້ງຄ່າທີ່ຢູ່ MCP ພາຍນອກທີ່ຖືກຕ້ອງກ່ອນ." diff --git a/core/i18n/lang/ms.yaml b/core/i18n/lang/ms.yaml index 27dc9f3a8..69bf70623 100644 --- a/core/i18n/lang/ms.yaml +++ b/core/i18n/lang/ms.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "Sokongan pengesahan terminal melalui kunci AP LocalAppSyncDispatch: "Hantar penyegerakan aplikasi tempatan" LocalAppSyncDispatchNode: "Hantar penyegerakan aplikasi tempatan ke {{.node}} (tugas nod: {{.taskID}})" ErrLocalAppSyncDisabled: "Penyegerakan aplikasi tempatan antara nod dinyahdayakan" + +# MCP connection document +MCPDocumentTitle: "Arahan persediaan sambungan MCP 1Panel" +MCPDocumentIntro: "Berikan fail ini kepada ejen AI dan minta ia melengkapkan persediaan mengikut dokumen. Fail ini mengandungi Token penuh; simpan secara peribadi." +MCPDocumentTaskTitle: "Tugas konfigurasi untuk ejen AI" +MCPDocumentTask: "Konfigurasikan MCP 1Panel dalam persekitaran semasa untuk klien yang dinyatakan di bawah, kemudian sahkan sambungan dan penemuan alat. Gunakan parameter sambungan dalam blok konfigurasi di bawah." +MCPDocumentClientCodex: "Klien sasaran: Codex. Sahkan dahulu skop konfigurasi dan CODEX_HOME sebenar. Gunakan config.toml yang berkuat kuasa (lalai: ~/.codex/config.toml) dan gabungkan TOML di bawah ke dalam entri mcp_servers yang sepadan." +MCPDocumentClientClaude: "Klien sasaran: Claude. Sahkan dahulu versi klien sebenar. JSON di bawah adalah untuk klien yang menyokong konfigurasi MCP melalui HTTP. Gunakan lokasi konfigurasi atau kaedah pengurusan yang disokong versi tersebut; jangan masukkan konfigurasi HTTP jauh ke dalam entri yang hanya menyokong stdio." +MCPDocumentClientGeneric: "Klien sasaran: klien MCP umum. Kenal pasti dahulu klien yang digunakan dan skop konfigurasi. Tanya pengguna jika tidak pasti; jangan meneka lokasi fail konfigurasi. Gabungkan parameter sambungan di bawah mengikut sintaks klien." +MCPDocumentSteps: |- + 1. Sahkan bahawa klien sasaran menyokong Streamable HTTP dan pengepala permintaan Bearer. Jika versinya tidak menyokongnya, jelaskan sebabnya. + 2. Baca dan sandarkan konfigurasi sedia ada. Gabungkan satu entri pelayan di bawah sambil mengekalkan tetapan MCP dan klien yang lain. Jika nama itu sudah wujud, kemas kini entri itu sahaja. + 3. Tulis nilai URL dan Authorization tepat seperti yang diberikan. Jangan jana semula Token atau ubah kebenaran panel atau keadaan perkhidmatan. Jangan masukkan Token dalam sejarah perintah, log, balasan atau Git. + 4. Kekalkan pengesahan sijil untuk HTTPS. Bagi CA peribadi, minta pengguna memberikan CA yang dipercayai; jangan mempercayai sijil pelayan secara automatik atau mematikan pengesahan TLS. Gunakan HTTP hanya pada rangkaian dipercayai; konfigurasikan HTTPS sebelum melalui rangkaian yang tidak dipercayai. + 5. Muat semula melalui klien, kemudian sahkan permulaan MCP dan tools/list. Jangan jalankan tools/call atau operasi tulis. Jika muat semula tidak tersedia, laporkan bahawa sesi baharu atau mula semula diperlukan. Menyimpan fail bukan bukti sambungan berjaya. + 6. Laporkan klien, lokasi konfigurasi, nama pelayan, hasil sambungan dan penemuan alat, serta masalah yang masih belum selesai. Sembunyikan Token. +MCPDocumentConfigTitle: "Konfigurasi sambungan" +MCPDocumentNotes: "Pelayan panel menyemak kebenaran alat, nod dan akaun pemilik semasa bagi setiap permintaan. Kemas kini konfigurasi klien selepas menjana semula Token. Akses ditolak apabila kelayakan dinyahdayakan, dibatalkan atau tamat tempoh." +MCPDocumentHTTPWarning: "Alamat ini menggunakan HTTP tanpa penyulitan. Konfigurasikan HTTPS sebelum menggunakannya pada rangkaian yang tidak dipercayai." + +# MCP management +ErrMCPConflict: "Data MCP telah berubah atau bercanggah dengan data sedia ada. Muat semula dan semak data tersebut." +ErrMCPPolicyChanged: "Katalog alat MCP atau kebenaran telah berubah. Muat semula, semak dan simpan kebenaran." +ErrMCPCatalogUnavailable: "Katalog alat MCP tidak tersedia. Semak status perkhidmatan." +ErrMCPDisabled: "Perkhidmatan MCP tidak diaktifkan." +ErrMCPSecretKeyUnavailable: "Penyulitan kelayakan MCP tidak tersedia. Cuba lagi kemudian." +ErrMCPSecretInvalid: "Token MCP yang disimpan tidak dapat dinyahsulit. Jana semula Token." +ErrMCPCredentialInactive: "Kelayakan MCP telah dinyahaktifkan, tamat tempoh atau dibatalkan." +ErrMCPAuditUnavailable: "Audit operasi MCP tidak tersedia. Sahkan hasil operasi sebelum meneruskan." +ErrMCPEndpointUnavailable: "Konfigurasikan alamat luaran MCP yang sah terlebih dahulu." diff --git a/core/i18n/lang/pt-BR.yaml b/core/i18n/lang/pt-BR.yaml index 0973efc92..1966b7bda 100644 --- a/core/i18n/lang/pt-BR.yaml +++ b/core/i18n/lang/pt-BR.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "Não foi possível verificar o suporte à aut LocalAppSyncDispatch: "Enviar sincronização de apps locais" LocalAppSyncDispatchNode: "Enviar sincronização de apps locais para {{.node}} (tarefa do nó: {{.taskID}})" ErrLocalAppSyncDisabled: "A sincronização de apps locais entre nós está desativada" + +# MCP connection document +MCPDocumentTitle: "Instruções de configuração da conexão MCP do 1Panel" +MCPDocumentIntro: "Entregue este arquivo a um agente de IA e peça que realize a configuração descrita. Este arquivo contém o Token completo; mantenha-o privado." +MCPDocumentTaskTitle: "Tarefa de configuração para o agente de IA" +MCPDocumentTask: "Configure o MCP do 1Panel no ambiente atual para o cliente indicado abaixo e verifique a conexão e a descoberta de ferramentas. Use os parâmetros do bloco de configuração abaixo." +MCPDocumentClientCodex: "Cliente de destino: Codex. Primeiro confirme o escopo da configuração e o CODEX_HOME efetivo. Use o config.toml em uso (padrão: ~/.codex/config.toml) e mescle o TOML abaixo na entrada mcp_servers correspondente." +MCPDocumentClientClaude: "Cliente de destino: Claude. Primeiro confirme a versão real do cliente. O JSON abaixo se destina a clientes compatíveis com a configuração MCP por HTTP. Use um local de configuração ou método de gerenciamento compatível com essa versão; não insira a configuração HTTP remota em uma entrada que aceite apenas stdio." +MCPDocumentClientGeneric: "Cliente de destino: cliente MCP genérico. Primeiro identifique o cliente em uso e o escopo da configuração. Se houver dúvida, pergunte ao usuário; não adivinhe o local do arquivo. Mescle os parâmetros de conexão abaixo conforme a sintaxe do cliente." +MCPDocumentSteps: |- + 1. Confirme que o cliente de destino aceita Streamable HTTP e cabeçalhos de requisição Bearer. Se a versão não oferecer suporte, explique o motivo. + 2. Leia a configuração existente e faça uma cópia de segurança. Mescle a única entrada de servidor abaixo, preservando as demais configurações MCP e do cliente. Se o nome já existir, atualize somente essa entrada. + 3. Grave os valores de URL e Authorization exatamente como fornecidos. Não gere outro Token nem altere as permissões do painel ou o estado do serviço. Não inclua o Token no histórico de comandos, em logs, respostas ou no Git. + 4. Mantenha a verificação de certificados habilitada para HTTPS. Para uma CA privada, solicite ao usuário uma CA confiável; não confie automaticamente no certificado do servidor nem desative a verificação TLS. Use HTTP apenas em redes confiáveis; configure HTTPS antes de passar por uma rede não confiável. + 5. Recarregue pelo cliente e verifique a inicialização MCP e tools/list. Não execute tools/call nem operações de escrita. Se não for possível recarregar, informe que é necessária uma nova sessão ou reinicialização. Salvar o arquivo não comprova que a conexão funcionou. + 6. Informe o cliente, o local da configuração, o nome do servidor, os resultados da conexão e da descoberta de ferramentas e as pendências. Oculte o Token. +MCPDocumentConfigTitle: "Configuração da conexão" +MCPDocumentNotes: "O servidor do painel verifica as permissões de ferramentas, nós e da conta atual em cada requisição. Após gerar um novo Token, atualize a configuração do cliente. O acesso será negado se a credencial for desativada, revogada ou expirar." +MCPDocumentHTTPWarning: "Este endereço usa HTTP sem criptografia. Configure HTTPS antes de utilizá-lo em uma rede não confiável." + +# MCP management +ErrMCPConflict: "Os dados MCP foram alterados ou estão em conflito. Atualize a página e revise-os." +ErrMCPPolicyChanged: "O catálogo de ferramentas MCP ou a autorização foi alterado. Atualize, revise e salve a autorização." +ErrMCPCatalogUnavailable: "O catálogo de ferramentas MCP está indisponível. Verifique o estado do serviço." +ErrMCPDisabled: "O serviço MCP não está habilitado." +ErrMCPSecretKeyUnavailable: "A criptografia de credenciais MCP está indisponível. Tente novamente mais tarde." +ErrMCPSecretInvalid: "Não foi possível descriptografar o Token MCP armazenado. Gere um novo Token." +ErrMCPCredentialInactive: "A credencial MCP está desabilitada, expirada ou revogada." +ErrMCPAuditUnavailable: "A auditoria de operações MCP está indisponível. Verifique o resultado antes de continuar." +ErrMCPEndpointUnavailable: "Configure primeiro um endereço externo MCP válido." diff --git a/core/i18n/lang/ru.yaml b/core/i18n/lang/ru.yaml index 19fcf32f2..73e31a91e 100644 --- a/core/i18n/lang/ru.yaml +++ b/core/i18n/lang/ru.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "Не удалось проверить под LocalAppSyncDispatch: "Отправка запросов синхронизации локальных приложений" LocalAppSyncDispatchNode: "Отправка запроса синхронизации узлу {{.node}} (задача узла: {{.taskID}})" ErrLocalAppSyncDisabled: "Синхронизация локальных приложений между узлами отключена" + +# MCP connection document +MCPDocumentTitle: "Инструкция для настройки подключения 1Panel MCP" +MCPDocumentIntro: "Передайте этот файл ИИ-агенту и попросите выполнить настройку по инструкции. Файл содержит полный Token; храните его конфиденциально." +MCPDocumentTaskTitle: "Задача настройки для ИИ-агента" +MCPDocumentTask: "Настройте 1Panel MCP в текущей среде для указанного ниже клиента, затем проверьте подключение и обнаружение инструментов. Используйте параметры подключения из приведённого ниже блока конфигурации." +MCPDocumentClientCodex: "Целевой клиент: Codex. Сначала уточните область действия конфигурации и фактический CODEX_HOME. Используйте действующий config.toml (по умолчанию ~/.codex/config.toml) и объедините приведённый ниже TOML с соответствующей записью mcp_servers." +MCPDocumentClientClaude: "Целевой клиент: Claude. Сначала уточните фактическую версию клиента. Приведённый ниже JSON предназначен для клиентов с поддержкой настройки MCP по HTTP. Используйте место хранения или способ управления конфигурацией, поддерживаемые этой версией; не добавляйте удалённую конфигурацию HTTP в раздел, предназначенный только для stdio." +MCPDocumentClientGeneric: "Целевой клиент: универсальный MCP-клиент. Сначала определите используемый клиент и область действия конфигурации. Если это неясно, спросите пользователя; не угадывайте расположение файла. Объедините параметры подключения ниже согласно синтаксису клиента." +MCPDocumentSteps: |- + 1. Убедитесь, что целевой клиент поддерживает Streamable HTTP и заголовки запросов Bearer. Если версия их не поддерживает, объясните причину. + 2. Прочитайте существующую конфигурацию и создайте резервную копию. Добавьте единственную запись сервера ниже, сохранив остальные настройки MCP и клиента. Если такое имя уже существует, обновите только эту запись. + 3. Запишите URL и Authorization без изменения значений. Не создавайте новый Token и не меняйте права панели или состояние службы. Не помещайте Token в историю команд, журналы, ответы или Git. + 4. Не отключайте проверку сертификатов HTTPS. Для частного CA запросите у пользователя доверенный CA; не доверяйте сертификату сервера автоматически и не отключайте проверку TLS. Используйте HTTP только в доверенных сетях; перед передачей через недоверенную сеть настройте HTTPS. + 5. Перезагрузите конфигурацию средствами клиента и проверьте инициализацию MCP и tools/list. Не выполняйте tools/call или операции записи. Если перезагрузка недоступна, сообщите о необходимости нового сеанса или перезапуска. Сохранение файла само по себе не означает успешного подключения. + 6. Укажите клиент, расположение конфигурации, имя сервера, результаты подключения и обнаружения инструментов, а также нерешённые вопросы. Скройте Token. +MCPDocumentConfigTitle: "Конфигурация подключения" +MCPDocumentNotes: "Сервер панели проверяет текущие права на инструменты, узлы и права владельца учётных данных при каждом запросе. После создания нового Token обновите конфигурацию клиента. Доступ отклоняется, если учётные данные отключены, отозваны или просрочены." +MCPDocumentHTTPWarning: "Этот адрес использует незашифрованный HTTP. Перед использованием в недоверенной сети настройте HTTPS." + +# MCP management +ErrMCPConflict: "Данные MCP изменились или конфликтуют с существующими. Обновите страницу и проверьте их." +ErrMCPPolicyChanged: "Каталог инструментов MCP или разрешения изменились. Обновите страницу, проверьте и сохраните разрешения." +ErrMCPCatalogUnavailable: "Каталог инструментов MCP недоступен. Проверьте состояние службы." +ErrMCPDisabled: "Служба MCP не включена." +ErrMCPSecretKeyUnavailable: "Шифрование учётных данных MCP недоступно. Повторите попытку позже." +ErrMCPSecretInvalid: "Не удалось расшифровать сохранённый MCP Token. Создайте новый Token." +ErrMCPCredentialInactive: "Учётные данные MCP отключены, просрочены или отозваны." +ErrMCPAuditUnavailable: "Аудит операций MCP недоступен. Проверьте результат операции, прежде чем продолжить." +ErrMCPEndpointUnavailable: "Сначала настройте корректный внешний адрес MCP." diff --git a/core/i18n/lang/tr.yaml b/core/i18n/lang/tr.yaml index 80791583a..40475da3b 100644 --- a/core/i18n/lang/tr.yaml +++ b/core/i18n/lang/tr.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "API anahtarıyla terminal yetkilendirme deste LocalAppSyncDispatch: "Yerel uygulama eşitleme isteği gönder" LocalAppSyncDispatchNode: "{{.node}} düğümüne yerel uygulama eşitleme isteği gönder (düğüm görevi: {{.taskID}})" ErrLocalAppSyncDisabled: "Düğümler arası yerel uygulama eşitleme devre dışı" + +# MCP connection document +MCPDocumentTitle: "1Panel MCP bağlantı kurulum yönergesi" +MCPDocumentIntro: "Bu dosyayı bir yapay zekâ ajanına verin ve belgede açıklanan kurulumu tamamlamasını isteyin. Dosya Token değerinin tamamını içerir; gizli tutun." +MCPDocumentTaskTitle: "Yapay zekâ ajanı için yapılandırma görevi" +MCPDocumentTask: "Mevcut ortamda aşağıda belirtilen istemci için 1Panel MCP bağlantısını yapılandırın; ardından bağlantıyı ve araç keşfini doğrulayın. Aşağıdaki yapılandırma bloğundaki bağlantı parametrelerini kullanın." +MCPDocumentClientCodex: "Hedef istemci: Codex. Önce yapılandırmanın kapsamını ve gerçek CODEX_HOME konumunu doğrulayın. Etkin config.toml dosyasını (varsayılan: ~/.codex/config.toml) kullanın ve aşağıdaki TOML içeriğini ilgili mcp_servers girdisine birleştirin." +MCPDocumentClientClaude: "Hedef istemci: Claude. Önce gerçek istemci sürümünü doğrulayın. Aşağıdaki JSON, HTTP MCP yapılandırmasını destekleyen istemciler içindir. Bu sürümün desteklediği yapılandırma konumunu veya yönetim yöntemini kullanın; uzak HTTP yapılandırmasını yalnızca stdio destekleyen bir girdiye yazmayın." +MCPDocumentClientGeneric: "Hedef istemci: genel bir MCP istemcisi. Önce kullanılan istemciyi ve yapılandırmanın kapsamını belirleyin. Emin değilseniz kullanıcıya sorun; yapılandırma dosyasının konumunu tahmin etmeyin. Aşağıdaki bağlantı parametrelerini istemcinin sözdizimine göre birleştirin." +MCPDocumentSteps: |- + 1. Hedef istemcinin Streamable HTTP ve Bearer istek başlıklarını desteklediğini doğrulayın. Sürüm bunları desteklemiyorsa nedenini açıklayın. + 2. Mevcut yapılandırmayı okuyun ve yedekleyin. Aşağıdaki tek sunucu girdisini birleştirirken diğer MCP ve istemci ayarlarını koruyun. Aynı ad varsa yalnızca o girdiyi güncelleyin. + 3. URL ve Authorization değerlerini verildiği gibi yazın. Token değerini yeniden oluşturmayın; panel izinlerini veya hizmet durumunu değiştirmeyin. Token değerini komut geçmişine, günlüklere, yanıtlara veya Git içine yazmayın. + 4. HTTPS için sertifika doğrulamasını açık tutun. Özel CA kullanılıyorsa kullanıcıdan güvenilir bir CA isteyin; sunucu sertifikasına otomatik olarak güvenmeyin veya TLS doğrulamasını kapatmayın. HTTP yalnızca güvenilir ağlarda kullanılmalıdır; güvenilmeyen bir ağdan geçmeden önce HTTPS yapılandırın. + 5. İstemci üzerinden yeniden yükleyin; ardından MCP başlatma ve tools/list işlemlerini doğrulayın. tools/call veya yazma işlemleri çalıştırmayın. Yeniden yükleme mümkün değilse yeni oturum ya da yeniden başlatma gerektiğini bildirin. Dosyanın kaydedilmesi, bağlantının başarılı olduğunu kanıtlamaz. + 6. İstemciyi, yapılandırma konumunu, sunucu adını, bağlantı ve araç keşfi sonuçlarını ve kalan sorunları bildirin. Token değerini gizleyin. +MCPDocumentConfigTitle: "Bağlantı yapılandırması" +MCPDocumentNotes: "Panel sunucusu her istekte araç, düğüm ve ilgili hesabın güncel izinlerini denetler. Token yeniden oluşturulduğunda istemci yapılandırmasını güncelleyin. Kimlik bilgisi devre dışı bırakılırsa, iptal edilirse veya süresi dolarsa erişim reddedilir." +MCPDocumentHTTPWarning: "Bu adres şifrelenmemiş HTTP kullanıyor. Güvenilmeyen bir ağda kullanmadan önce HTTPS yapılandırın." + +# MCP management +ErrMCPConflict: "MCP verileri değişti veya mevcut verilerle çakışıyor. Sayfayı yenileyip kontrol edin." +ErrMCPPolicyChanged: "MCP araç kataloğu veya yetkilendirme değişti. Yenileyin, kontrol edin ve yetkilendirmeyi kaydedin." +ErrMCPCatalogUnavailable: "MCP araç kataloğu kullanılamıyor. Hizmet durumunu kontrol edin." +ErrMCPDisabled: "MCP hizmeti etkin değil." +ErrMCPSecretKeyUnavailable: "MCP kimlik bilgilerinin şifrelenmesi kullanılamıyor. Daha sonra tekrar deneyin." +ErrMCPSecretInvalid: "Kaydedilen MCP Token çözülemiyor. Token'ı yeniden oluşturun." +ErrMCPCredentialInactive: "MCP kimlik bilgileri devre dışı, süresi dolmuş veya iptal edilmiş." +ErrMCPAuditUnavailable: "MCP işlem denetimi kullanılamıyor. Devam etmeden önce işlem sonucunu doğrulayın." +ErrMCPEndpointUnavailable: "Önce geçerli bir harici MCP adresi yapılandırın." diff --git a/core/i18n/lang/zh-Hant.yaml b/core/i18n/lang/zh-Hant.yaml index 16fd20fcd..579273bd8 100644 --- a/core/i18n/lang/zh-Hant.yaml +++ b/core/i18n/lang/zh-Hant.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "無法確認節點支援 API Key 終端授權 LocalAppSyncDispatch: "下發本機應用同步" LocalAppSyncDispatchNode: "向節點 {{.node}} 下發本機應用同步(節點任務:{{.taskID}})" ErrLocalAppSyncDisabled: "尚未啟用本機應用跨節點同步" + +# MCP connection document +MCPDocumentTitle: "1Panel MCP 連線設定提示詞" +MCPDocumentIntro: "將本檔案交給 AI Agent,要求其依照文件完成設定。本檔案包含完整 Token,請妥善保密。" +MCPDocumentTaskTitle: "給 AI Agent 的設定任務" +MCPDocumentTask: "請在目前環境中為下方指定的用戶端設定 1Panel MCP,並驗證連線及工具探索。連線參數以下方設定片段為準。" +MCPDocumentClientCodex: "目標用戶端:Codex。先確認設定範圍及實際 CODEX_HOME;使用生效的 config.toml(預設 ~/.codex/config.toml),將下方 TOML 合併至對應的 mcp_servers 項目。" +MCPDocumentClientClaude: "目標用戶端:Claude。先確認實際用戶端版本;下方 JSON 適用於支援 HTTP MCP 設定的用戶端。使用該版本支援的設定位置或管理方式,不將遠端 HTTP 設定寫入僅支援 stdio 的入口。" +MCPDocumentClientGeneric: "目標用戶端:通用 MCP 用戶端。先識別目前使用的用戶端及設定範圍;無法確定時詢問使用者,不猜測設定檔位置。依照用戶端語法合併下方連線參數。" +MCPDocumentSteps: |- + 1. 確認目標用戶端支援 Streamable HTTP 與 Bearer 請求標頭;版本不支援時說明原因。 + 2. 讀取並備份現有設定,合併下方唯一的伺服器項目,保留其他 MCP 和用戶端設定;同名時只更新該項目。 + 3. 按原值寫入 URL 與 Authorization,不重設 Token、不變更面板權限或服務啟停狀態,不將 Token 寫入命令列歷程、日誌、回覆或 Git。 + 4. HTTPS 保持憑證驗證;私有 CA 須由使用者提供可信任的 CA,不自動信任伺服器憑證或停用 TLS 驗證。HTTP 僅限可信任網路,跨不可信任網路前先設定 HTTPS。 + 5. 使用用戶端重新載入,執行 MCP 初始化及 tools/list 驗證;不執行 tools/call 或寫入操作。無法重新載入時回報需要新工作階段或重新啟動,勿將儲存檔案視為連線成功。 + 6. 回報用戶端、設定位置、伺服器名稱、連線與工具探索結果及尚待處理的問題,隱藏 Token。 +MCPDocumentConfigTitle: "連線設定" +MCPDocumentNotes: "工具、節點及所屬帳號目前的權限由面板伺服器每次驗證。重新產生 Token 後須更新用戶端設定,停用、撤銷或過期後將拒絕連線。" +MCPDocumentHTTPWarning: "目前位址使用未加密的 HTTP;在不可信任的網路使用前,請先設定 HTTPS。" + +# MCP management +ErrMCPConflict: "MCP 資料已變更或存在衝突,請重新整理後再次核對。" +ErrMCPPolicyChanged: "MCP 工具目錄或授權已變更,請重新整理後核對並儲存授權。" +ErrMCPCatalogUnavailable: "MCP 工具目錄暫時無法使用,請檢查服務狀態。" +ErrMCPDisabled: "MCP 服務尚未啟用。" +ErrMCPSecretKeyUnavailable: "MCP 存取憑證加密暫時無法使用,請稍後重試。" +ErrMCPSecretInvalid: "儲存的 MCP Token 無法解密,請重新產生 Token。" +ErrMCPCredentialInactive: "MCP 存取憑證已停用、過期或撤銷。" +ErrMCPAuditUnavailable: "MCP 操作稽核暫時無法使用,請核對操作結果後再繼續。" +ErrMCPEndpointUnavailable: "請先設定有效的 MCP 外部連線位址。" diff --git a/core/i18n/lang/zh.yaml b/core/i18n/lang/zh.yaml index 1f867c82d..e8e0181c4 100644 --- a/core/i18n/lang/zh.yaml +++ b/core/i18n/lang/zh.yaml @@ -502,3 +502,33 @@ ErrAPIKeyTerminalUpgradeRequired: "无法确认节点支持 API Key 终端授权 LocalAppSyncDispatch: "下发本地应用同步" LocalAppSyncDispatchNode: "向节点 {{.node}} 下发本地应用同步(节点任务:{{.taskID}})" ErrLocalAppSyncDisabled: "尚未开启本地应用跨节点同步" + +# MCP connection document +MCPDocumentTitle: "1Panel MCP 接入配置提示词" +MCPDocumentIntro: "将本文件交给 AI Agent,要求其按文档完成配置。本文件包含完整 Token,请私密保存。" +MCPDocumentTaskTitle: "给 AI Agent 的配置任务" +MCPDocumentTask: "请在当前环境中为下方指定的客户端配置 1Panel MCP,并完成连接和工具发现验证。连接参数以本文配置片段为准。" +MCPDocumentClientCodex: "目标客户端:Codex。先确认配置作用域和实际 CODEX_HOME;使用生效的 config.toml(默认 ~/.codex/config.toml),将下方 TOML 合并到对应 mcp_servers 条目。" +MCPDocumentClientClaude: "目标客户端:Claude。先确认实际客户端版本;下方 JSON 适用于支持 HTTP MCP 配置的客户端。使用该版本支持的配置位置或管理方式,不把远程 HTTP 配置写进仅支持 stdio 的入口。" +MCPDocumentClientGeneric: "目标客户端:通用 MCP 客户端。先识别当前使用的客户端和配置作用域;无法确定时询问用户,不猜测配置文件位置。按照客户端语法合并下方连接参数。" +MCPDocumentSteps: |- + 1. 确认目标客户端支持 Streamable HTTP 与 Bearer 请求头;版本不支持时说明原因。 + 2. 读取并备份现有配置,将下方唯一服务器条目合并,保留其它 MCP 和客户端设置;同名时只更新这一项。 + 3. 按原值写入 URL 与 Authorization,不重置 Token、不更改面板权限或服务启停状态,不将 Token 写入命令行历史、日志、回复或 Git。 + 4. HTTPS 保持证书校验;私有 CA 需用户提供可信 CA,不自动信任服务端证书或关闭 TLS 校验。HTTP 仅限可信网络,跨不可信网络前先配置 HTTPS。 + 5. 使用客户端重新加载,执行 MCP 初始化及 tools/list 验证;不执行 tools/call 或写入操作。无法重载时报告需要新会话或重启,勿把保存文件当作连接成功。 + 6. 报告客户端、配置位置、服务器名、连接与工具发现结果及仍待处理的问题,隐藏 Token。 +MCPDocumentConfigTitle: "连接配置" +MCPDocumentNotes: "工具、节点及所属账号当前权限由面板服务端每次校验。Token 重新生成后需更新客户端配置,停用、撤销或过期后接入会被拒绝。" +MCPDocumentHTTPWarning: "当前地址使用未加密 HTTP;在不可信网络使用前,请先配置 HTTPS。" + +# MCP management +ErrMCPConflict: "MCP 数据已变更或存在冲突,请刷新后重新核对。" +ErrMCPPolicyChanged: "MCP 工具目录或授权已变更,请刷新后重新核对并保存授权。" +ErrMCPCatalogUnavailable: "MCP 工具目录暂不可用,请检查服务状态。" +ErrMCPDisabled: "MCP 服务未启用。" +ErrMCPSecretKeyUnavailable: "MCP 凭据加密暂不可用,请稍后重试。" +ErrMCPSecretInvalid: "存储的 MCP Token 无法解密,请重新生成 Token。" +ErrMCPCredentialInactive: "MCP 凭据已停用、过期或撤销。" +ErrMCPAuditUnavailable: "MCP 操作审计暂不可用,请核对操作结果后再继续。" +ErrMCPEndpointUnavailable: "请先配置有效的 MCP 外部接入地址。" diff --git a/core/init/migration/migrate.go b/core/init/migration/migrate.go index cab382c9f..be2117e05 100644 --- a/core/init/migration/migrate.go +++ b/core/init/migration/migrate.go @@ -71,5 +71,6 @@ func coreMigrations() []*gormigrate.Migration { migrations.AddOperationLogAPIKey, migrations.UpdateAiMenuOrder, migrations.UpdateAiMenuOrderV2, + migrations.AddPanelMCP, } } diff --git a/core/init/migration/migrations/mcp.go b/core/init/migration/migrations/mcp.go new file mode 100644 index 000000000..860c439ac --- /dev/null +++ b/core/init/migration/migrations/mcp.go @@ -0,0 +1,14 @@ +package migrations + +import ( + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var AddPanelMCP = &gormigrate.Migration{ + ID: "20261008-panel-mcp", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.MCPCredential{}, &model.MCPGrant{}, &model.MCPInvocation{}, &model.MCPSetting{}) + }, +} diff --git a/core/init/router/mcp.go b/core/init/router/mcp.go new file mode 100644 index 000000000..cadb07f2b --- /dev/null +++ b/core/init/router/mcp.go @@ -0,0 +1,160 @@ +package router + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httputil" + "os" + "path/filepath" + "strconv" + "time" + + v2 "github.com/1Panel-dev/1Panel/core/app/api/v2" + "github.com/1Panel-dev/1Panel/core/app/service" + "github.com/1Panel-dev/1Panel/core/global" + "github.com/1Panel-dev/1Panel/core/utils/common" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/gin-gonic/gin" +) + +func registerMCPRuntime(r *gin.Engine) { + r.GET(contract.RuntimePrefix+"/tools", v2.MCPRuntimeTools) + r.POST(contract.RuntimePrefix+"/invoke", v2.MCPRuntimeInvoke) + r.POST(contract.RuntimePrefix+"/admit", v2.MCPRuntimeAdmit) + r.POST(contract.RuntimePrefix+"/complete", v2.MCPRuntimeComplete) +} +func registerMCPTransport(r *gin.Engine) { + for _, method := range []string{http.MethodPost, http.MethodGet, http.MethodDelete} { + r.Handle(method, "/mcp", proxyMCPTransport) + } +} +func proxyMCPTransport(c *gin.Context) { + if err := contract.AdmitIngress(time.Now()); err != nil { + v2.MCPRuntimeError(c, err) + return + } + if c.GetHeader("Origin") != "" { + v2.MCPRuntimeError(c, contract.Forbidden()) + return + } + token, err := service.MCPBearer(c) + if err != nil { + v2.MCPRuntimeError(c, err) + return + } + if _, _, err = service.NewMCPService().Authenticate(c, token); err != nil { + v2.MCPRuntimeError(c, err) + return + } + source := c.GetString("MCP_CLIENT_IP") + if source == "" { + source = common.GetRealClientIP(c) + } + proof, err := contract.IssueAccessProof(token, source, time.Now()) + if err != nil { + v2.MCPRuntimeError(c, contract.Forbidden()) + return + } + settings, err := service.NewMCPRuntimeService().Settings() + if err != nil { + v2.MCPRuntimeError(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + transport := &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, ResponseHeaderTimeout: 355 * time.Second} + defer transport.CloseIdleConnections() + proxy := &httputil.ReverseProxy{Transport: transport, Rewrite: func(p *httputil.ProxyRequest) { + p.Out.URL.Scheme = "http" + p.Out.URL.Host = net.JoinHostPort("127.0.0.1", strconv.Itoa(settings.ListenPort)) + p.Out.URL.Path = "/mcp" + p.Out.URL.RawPath = "" + p.Out.URL.RawQuery = "" + p.Out.Host = p.Out.URL.Host + p.Out.Header = make(http.Header) + for _, key := range []string{"Content-Type", "Accept", "MCP-Protocol-Version", "Accept-Language"} { + if value := p.In.Header.Get(key); value != "" { + p.Out.Header.Set(key, value) + } + } + p.Out.Header.Set("Authorization", "Bearer "+token) + p.Out.Header.Set(contract.AccessProofHeader, proof) + }, ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(503) + _, _ = w.Write([]byte(`{"code":"MCP_NODE_UNAVAILABLE","message":"MCP adapter is unavailable"}`)) + }} + c.Header("Cache-Control", "no-store") + proxy.ServeHTTP(c.Writer, c.Request) + c.Abort() +} + +func StartMCPBridge() error { + if err := prepareMCPDockerSocketDir(contract.DockerSocketDir); err != nil { + return err + } + return startMCPBridge(contract.DockerCoreSocket) +} + +func prepareMCPDockerSocketDir(dir string) error { + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + info, err := os.Lstat(dir) + if err != nil || !info.IsDir() { + return errors.New("MCP container bridge directory must be a real directory") + } + if err := os.Chmod(dir, 0700); err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() != "core.sock" || entry.Type()&os.ModeSocket == 0 { + return errors.New("MCP container bridge directory must contain only its socket") + } + } + return nil +} +func startMCPBridge(socket string) error { + if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil { + return err + } + if info, err := os.Lstat(socket); err == nil { + if info.Mode()&os.ModeSocket == 0 { + return errors.New("MCP bridge path is not a socket") + } + conn, dialErr := net.DialTimeout("unix", socket, time.Second) + if dialErr == nil { + conn.Close() + return errors.New("MCP bridge is already running") + } + if err = os.Remove(socket); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + listener, err := net.Listen("unix", socket) + if err != nil { + return err + } + if err = os.Chmod(socket, 0600); err != nil { + listener.Close() + return err + } + r := gin.New() + r.Use(gin.Recovery()) + registerMCPRuntime(r) + r.GET("/healthz", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok", "contractVersion": contract.ContractVersion}) }) + server := &http.Server{Handler: r, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 340 * time.Second, WriteTimeout: 340 * time.Second, IdleTimeout: 30 * time.Second, BaseContext: func(net.Listener) context.Context { return context.Background() }} + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && global.LOG != nil { + global.LOG.Error("MCP runtime bridge stopped") + } + }() + return nil +} diff --git a/core/init/router/proxy.go b/core/init/router/proxy.go index ca7e9893c..653603e37 100644 --- a/core/init/router/proxy.go +++ b/core/init/router/proxy.go @@ -18,9 +18,11 @@ import ( "github.com/1Panel-dev/1Panel/core/init/proxy" psessionUtils "github.com/1Panel-dev/1Panel/core/init/session/psession" "github.com/1Panel-dev/1Panel/core/middleware" + "github.com/1Panel-dev/1Panel/core/utils/mcpapi" "github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local" terminalsession "github.com/1Panel-dev/1Panel/core/utils/terminal_session" "github.com/1Panel-dev/1Panel/core/utils/xpack" + "github.com/1Panel-dev/1Panel/core/utils/xpack/providers" "github.com/gin-gonic/gin" ) @@ -33,6 +35,33 @@ var loadLocalTerminalCapabilities = func(ctx context.Context) (interface{}, erro func Proxy() gin.HandlerFunc { return func(c *gin.Context) { terminalsession.ClearForwardedHeaders(c) + mcpapi.ClearForwardedHeaders(c.Request.Header) + if mcpapi.HasIdentity(c.Request) && !mcpapi.IsRequest(c.Request) { + c.AbortWithStatus(http.StatusForbidden) + return + } + if _, owner, ok := mcpapi.Identity(c.Request); ok { + nodeID, _ := mcpapi.NodeID(c.Request) + provider, supported := xpack.AuthProvider.(providers.MCPAPIAuthorizer) + if !c.GetBool("MCP_API_AUTH") || !supported || provider.AuthorizeMCPAPI(owner, nodeID, c.Request.Method, c.Request.URL.Path) != nil { + c.AbortWithStatus(http.StatusForbidden) + return + } + nodeName := "local" + if nodeProvider, supported := xpack.AuthProvider.(providers.MCPNodeProvider); supported { + node, err := nodeProvider.ResolveMCPNode(owner, nodeID) + if err != nil || node.ID != nodeID || node.IsLocal != (nodeID == "local") || node.IsLocal != (node.Name == "local") || node.Name == "" { + c.AbortWithStatus(http.StatusForbidden) + return + } + nodeName = node.Name + } else if nodeID != "local" { + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Request.Header.Set("CurrentNode", url.QueryEscape(nodeName)) + mcpapi.SetForwardedHeaders(c.Request.Header, c.Request) + } reqPath := c.Request.URL.Path if !middleware.ShouldProxyToAgent(reqPath) { c.Next() @@ -50,6 +79,10 @@ func Proxy() gin.HandlerFunc { helper.ErrorWithDetail(c, http.StatusBadRequest, "ErrProxy", err) return } + useLocalAgent := currentNode == "local" || len(currentNode) == 0 + if nodeID, ok := mcpapi.NodeID(c.Request); ok { + useLocalAgent = nodeID == "local" + } apiReq := c.GetBool("API_AUTH") terminalRevalidate := c.Query("terminalRevalidate") == "1" && isTerminalRevalidationEndpoint(reqPath) @@ -73,12 +106,12 @@ func Proxy() gin.HandlerFunc { return } - if reqPath == "/api/v2/hosts/terminal/local" && (currentNode == "local" || len(currentNode) == 0) { + if reqPath == "/api/v2/hosts/terminal/local" && useLocalAgent { proxyLocalAgent(c) return } - if !strings.HasPrefix(reqPath, "/api/v2/core") && (currentNode == "local" || len(currentNode) == 0) { + if !strings.HasPrefix(reqPath, "/api/v2/core") && useLocalAgent { proxyLocalAgent(c) return } @@ -99,6 +132,7 @@ func isTerminalRevalidationEndpoint(reqPath string) bool { func isInternalOnlyAgentEndpoint(reqPath string) bool { normalizedPath := path.Clean(reqPath) return normalizedPath == "/api/v2/xpack/alert/offline/email" || + strings.HasPrefix(normalizedPath, "/api/v2/internal/mcp/") || normalizedPath == "/api/v2/xpack/alert/offline/webhook" || normalizedPath == "/api/v2/hosts/firewall/port" || normalizedPath == "/api/v2/internal/terminal/sessions/revoke" || diff --git a/core/init/router/router.go b/core/init/router/router.go index b83a644be..7c289e965 100644 --- a/core/init/router/router.go +++ b/core/init/router/router.go @@ -18,6 +18,7 @@ import ( "github.com/1Panel-dev/1Panel/core/init/swagger" "github.com/1Panel-dev/1Panel/core/middleware" rou "github.com/1Panel-dev/1Panel/core/router" + "github.com/1Panel-dev/1Panel/core/utils/mcpapi" "github.com/1Panel-dev/1Panel/core/utils/security" "github.com/1Panel-dev/1Panel/core/utils/xpack" "github.com/gin-contrib/gzip" @@ -69,6 +70,8 @@ func setWebStatic(rootRouter *gin.RouterGroup) { func Routers() *gin.Engine { Router = gin.New() + Router.Use(middleware.MCPTokenBoundary()) + Router.Use(middleware.MCPAPIIdentity()) Router.Use(i18n.UseI18n()) Router.Use(middleware.WhiteAllow()) Router.Use(middleware.BindDomain()) @@ -85,6 +88,7 @@ func Routers() *gin.Engine { if global.CONF.Base.IsDemo { Router.Use(middleware.DemoHandle()) } + registerMCPTransport(Router) Router.Use(middleware.FrontendFallback()) Router.Use(middleware.WebSocketOriginGuard()) @@ -109,6 +113,7 @@ func Routers() *gin.Engine { security.HandleNotSecurity(c, "") }) + mcpapi.SetHandler(Router) return Router } diff --git a/core/middleware/bind_domain.go b/core/middleware/bind_domain.go index 0ee9af2a6..09a29af92 100644 --- a/core/middleware/bind_domain.go +++ b/core/middleware/bind_domain.go @@ -5,12 +5,17 @@ import ( "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/utils/mcpapi" "github.com/1Panel-dev/1Panel/core/utils/security" "github.com/gin-gonic/gin" ) func BindDomain() gin.HandlerFunc { return func(c *gin.Context) { + if mcpapi.IsRequest(c.Request) && c.GetBool("MCP_API_AUTH") { + c.Next() + return + } localRequest := c.GetBool("LOCAL_REQUEST") if localRequest { c.Next() diff --git a/core/middleware/ip_limit.go b/core/middleware/ip_limit.go index 468cb5c61..3382d895b 100644 --- a/core/middleware/ip_limit.go +++ b/core/middleware/ip_limit.go @@ -1,18 +1,28 @@ package middleware import ( + "errors" "net" "strings" "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" + "github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/global" "github.com/1Panel-dev/1Panel/core/utils/common" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/1Panel-dev/1Panel/core/utils/mcpapi" "github.com/1Panel-dev/1Panel/core/utils/security" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) func WhiteAllow() gin.HandlerFunc { return func(c *gin.Context) { + if mcpapi.IsRequest(c.Request) && c.GetBool("MCP_API_AUTH") { + c.Next() + return + } tokenString := c.GetHeader("X-Panel-Local-Token") remoteIP := common.GetRealClientIP(c) if isLocalSyncRequest(c.Request.URL.Path, remoteIP, tokenString) { @@ -28,6 +38,35 @@ func WhiteAllow() gin.HandlerFunc { return } clientIP := common.ResolveClientIP(c, trustedProxies) + if c.Request.URL.Path == "/mcp" { + c.Set("MCP_CLIENT_IP", clientIP) + var policy model.MCPSetting + err := global.DB.Select("access_policy", "allowed_ips").First(&policy, 1).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatusJSON(503, gin.H{"code": "MCP_POLICY_UNAVAILABLE", "message": "MCP source policy is unavailable"}) + return + } + if errors.Is(err, gorm.ErrRecordNotFound) { + policy.AccessPolicy = "inherit" + } + switch policy.AccessPolicy { + case "inherit": + case "all": + c.Next() + return + case "restricted": + entries, err := contract.DecodeAllowedIPs(policy.AllowedIPs) + if err == nil && len(entries) > 0 && contract.IPAllowed(entries, clientIP) { + c.Next() + return + } + c.AbortWithStatusJSON(403, gin.H{"code": "MCP_SOURCE_DENIED", "message": "Source IP is not allowed by MCP policy"}) + return + default: + c.AbortWithStatusJSON(403, gin.H{"code": "MCP_SOURCE_DENIED", "message": "MCP source policy is invalid"}) + return + } + } allowIPs, err := settingRepo.GetValueByKey("AllowIPs") if err != nil { diff --git a/core/middleware/mcp.go b/core/middleware/mcp.go new file mode 100644 index 000000000..ce5689a7e --- /dev/null +++ b/core/middleware/mcp.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "net/http" + "net/url" + "strings" + + "github.com/1Panel-dev/1Panel/core/utils/mcpapi" + "github.com/1Panel-dev/1Panel/core/utils/xpack" + "github.com/1Panel-dev/1Panel/core/utils/xpack/providers" + "github.com/gin-gonic/gin" +) + +func MCPAPIIdentity() gin.HandlerFunc { + return func(c *gin.Context) { + mcpapi.ClearForwardedHeaders(c.Request.Header) + _, owner, ok := mcpapi.Identity(c.Request) + if !ok { + if mcpapi.HasIdentity(c.Request) { + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Next() + return + } + provider, supported := xpack.AuthProvider.(providers.MCPAPIAuthorizer) + nodeID, _ := mcpapi.NodeID(c.Request) + if !supported || provider.AuthorizeMCPAPI(owner, nodeID, c.Request.Method, c.Request.URL.Path) != nil || provider.BindMCPAPIIdentity(c, owner) != nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": http.StatusForbidden, "message": "MCP API permission denied"}) + return + } + nodeProvider, supported := xpack.AuthProvider.(providers.MCPNodeProvider) + if !supported { + c.AbortWithStatus(http.StatusForbidden) + return + } + node, err := nodeProvider.ResolveMCPNode(owner, nodeID) + if err != nil || node.ID != nodeID || node.IsLocal != (nodeID == "local") || node.IsLocal != (node.Name == "local") || node.Name == "" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": http.StatusForbidden, "message": "MCP node permission denied"}) + return + } + c.Request.Header.Set("CurrentNode", url.QueryEscape(node.Name)) + c.Next() + } +} + +func MCPTokenBoundary() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + if path == "/mcp" { + c.Next() + return + } + for _, value := range c.Request.Header.Values("Authorization") { + for _, part := range strings.Fields(value) { + if strings.HasPrefix(part, "mcp_") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": "MCP_UNAUTHENTICATED", "message": "MCP credentials cannot access this endpoint"}) + return + } + } + } + c.Next() + } +} diff --git a/core/middleware/operation.go b/core/middleware/operation.go index ca1ea76b2..f2e73e69c 100644 --- a/core/middleware/operation.go +++ b/core/middleware/operation.go @@ -23,6 +23,7 @@ import ( "github.com/1Panel-dev/1Panel/core/constant" "github.com/1Panel-dev/1Panel/core/global" psessionUtils "github.com/1Panel-dev/1Panel/core/init/session/psession" + "github.com/1Panel-dev/1Panel/core/utils/mcpapi" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -36,6 +37,15 @@ const ( func OperationLog() gin.HandlerFunc { return func(c *gin.Context) { c.Request.Header.Del(headerNeedOperationResolve) + if mcpapi.IsRequest(c.Request) && c.GetBool("MCP_API_AUTH") { + c.Next() + return + } + switch c.Request.URL.Path { + case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export": + c.Next() + return + } if strings.Contains(c.Request.URL.Path, "search") || c.Request.Method == http.MethodGet { c.Next() diff --git a/core/router/common.go b/core/router/common.go index 626ef2148..7d0847924 100644 --- a/core/router/common.go +++ b/core/router/common.go @@ -10,5 +10,6 @@ func commonGroups() []CommonRouter { &GroupRouter{}, &ScriptRouter{}, &RuntimeDiagnosticsRouter{}, + &PanelMCPRouter{}, } } diff --git a/core/router/ro_mcp.go b/core/router/ro_mcp.go new file mode 100644 index 000000000..16966e927 --- /dev/null +++ b/core/router/ro_mcp.go @@ -0,0 +1,31 @@ +package router + +import ( + v2 "github.com/1Panel-dev/1Panel/core/app/api/v2" + "github.com/1Panel-dev/1Panel/core/middleware" + "github.com/gin-gonic/gin" +) + +type PanelMCPRouter struct{} + +func (*PanelMCPRouter) InitRouter(group *gin.RouterGroup) { + b := v2.ApiGroupApp.BaseApi + r := group.Group("mcp").Use(middleware.SessionAuth()) + r.GET("status", b.MCPServiceStatus) + r.GET("settings", b.MCPSettings) + r.POST("settings", b.UpdateMCPSettings) + r.GET("catalog", b.MCPCatalog) + r.GET("nodes", b.MCPNodes) + r.POST("service/operate", b.OperateMCPService) + r.POST("service/install", b.InstallMCPService) + r.GET("service/logs", b.MCPServiceLogs) + r.POST("credentials/search", b.SearchMCPCredentials) + r.POST("credentials/create", b.CreateMCPCredential) + r.POST("credentials/update", b.UpdateMCPCredential) + r.POST("credentials/status", b.StatusMCPCredential) + r.POST("credentials/revoke", b.RevokeMCPCredential) + r.POST("credentials/reveal", b.RevealMCPCredential) + r.POST("credentials/rotate", b.RotateMCPCredential) + r.POST("credentials/export", b.ExportMCPCredential) + r.POST("invocations/search", b.SearchMCPInvocations) +} diff --git a/core/server/server.go b/core/server/server.go index ed908626a..d9315dfc9 100644 --- a/core/server/server.go +++ b/core/server/server.go @@ -2,6 +2,7 @@ package server import ( "bufio" + "context" "crypto/tls" "encoding/gob" "fmt" @@ -9,6 +10,7 @@ import ( "net/http" "os" "path" + "runtime" "strings" "time" @@ -36,6 +38,7 @@ import ( ) func Start() { + processStartedAt := time.Now() re.Init() viper.Init() log.Init() @@ -82,6 +85,20 @@ func Start() { if err != nil { panic(err) } + if err := service.NewMCPService().RecoverInterruptedInvocations(processStartedAt); err != nil { + global.LOG.Errorf("MCP invocation audit recovery failed; runtime bridge is unavailable: %v", err) + } else if err := router.StartMCPBridge(); err != nil { + global.LOG.Errorf("MCP runtime bridge is unavailable: %v", err) + } + if runtime.GOOS == "linux" { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := service.NewMCPRuntimeService().PrepareOnStartup(ctx); err != nil { + global.LOG.Warnf("MCP Docker runtime preparation failed: %v", err) + } + }() + } type tcpKeepAliveListener struct { *net.TCPListener } diff --git a/core/utils/mcp/access.go b/core/utils/mcp/access.go new file mode 100644 index 000000000..a8b70aea1 --- /dev/null +++ b/core/utils/mcp/access.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "encoding/json" + "errors" + "net/netip" + "slices" + "strings" +) + +const MaxAllowedIPs = 64 + +func NormalizeAllowedIPs(entries []string) ([]string, error) { + if len(entries) > MaxAllowedIPs { + return nil, errors.New("too many MCP IP entries") + } + result := make([]string, 0, len(entries)) + for _, raw := range entries { + entry := strings.TrimSpace(raw) + if len(entry) == 0 || len(entry) > 64 { + return nil, errors.New("invalid MCP IP entry") + } + if strings.Contains(entry, "/") { + prefix, err := netip.ParsePrefix(entry) + if err != nil || prefix.Addr().Zone() != "" { + return nil, errors.New("invalid MCP CIDR") + } + if prefix.Addr().Is4In6() { + if prefix.Bits() < 96 { + return nil, errors.New("invalid mapped IPv4 CIDR") + } + prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96) + } + result = append(result, prefix.Masked().String()) + } else { + address, err := netip.ParseAddr(entry) + if err != nil || address.Zone() != "" { + return nil, errors.New("invalid MCP address") + } + result = append(result, address.Unmap().String()) + } + } + slices.Sort(result) + return slices.Compact(result), nil +} + +func DecodeAllowedIPs(raw string) ([]string, error) { + var result []string + if json.Unmarshal([]byte(raw), &result) != nil || result == nil { + return nil, errors.New("invalid stored MCP IP policy") + } + return NormalizeAllowedIPs(result) +} + +func IPAllowed(entries []string, source string) bool { + address, err := netip.ParseAddr(source) + if err != nil || address.Zone() != "" { + return false + } + address = address.Unmap() + for _, entry := range entries { + if prefix, err := netip.ParsePrefix(entry); err == nil { + if prefix.Contains(address) { + return true + } + } else if expected, err := netip.ParseAddr(entry); err == nil && expected.Unmap() == address { + return true + } + } + return false +} diff --git a/core/utils/mcp/access_proof.go b/core/utils/mcp/access_proof.go new file mode 100644 index 000000000..3d1653fda --- /dev/null +++ b/core/utils/mcp/access_proof.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/netip" + "strings" + "sync" + "time" +) + +const AccessProofHeader = "X-Panel-MCP-Access" + +var accessProofKey struct { + sync.Once + value []byte + err error +} + +type accessProof struct { + IP string `json:"ip"` + Token string `json:"token"` + Expires int64 `json:"expires"` +} + +func proofKey() ([]byte, error) { + accessProofKey.Do(func() { + accessProofKey.value = make([]byte, 32) + _, accessProofKey.err = rand.Read(accessProofKey.value) + }) + return accessProofKey.value, accessProofKey.err +} + +func proofToken(token string) string { + value := sha256.Sum256([]byte(token)) + return hex.EncodeToString(value[:]) +} + +func IssueAccessProof(token, source string, now time.Time) (string, error) { + address, err := netip.ParseAddr(source) + if err != nil || address.Zone() != "" || token == "" { + return "", errors.New("invalid MCP source proof") + } + key, err := proofKey() + if err != nil { + return "", err + } + data, err := json.Marshal(accessProof{IP: address.Unmap().String(), Token: proofToken(token), Expires: now.Add(400 * time.Second).Unix()}) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, key) + mac.Write(data) + return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func VerifyAccessProof(value, token string, now time.Time) (string, error) { + if len(value) > 512 { + return "", Forbidden() + } + parts := strings.Split(value, ".") + if len(parts) != 2 { + return "", Forbidden() + } + data, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", Forbidden() + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", Forbidden() + } + key, err := proofKey() + if err != nil { + return "", Forbidden() + } + mac := hmac.New(sha256.New, key) + mac.Write(data) + if !hmac.Equal(signature, mac.Sum(nil)) { + return "", Forbidden() + } + var proof accessProof + if json.Unmarshal(data, &proof) != nil || !hmac.Equal([]byte(proof.Token), []byte(proofToken(token))) || now.Unix() >= proof.Expires || proof.Expires > now.Add(401*time.Second).Unix() { + return "", Forbidden() + } + address, err := netip.ParseAddr(proof.IP) + if err != nil || address.Zone() != "" { + return "", Forbidden() + } + return address.Unmap().String(), nil +} diff --git a/core/utils/mcp/authorization.go b/core/utils/mcp/authorization.go new file mode 100644 index 000000000..7e9d1b42b --- /dev/null +++ b/core/utils/mcp/authorization.go @@ -0,0 +1,249 @@ +package mcp + +import ( + "encoding/json" + "errors" + "reflect" + "slices" +) + +const toolAuthorizationSnapshotVersion = 1 +const toolAuthorizationSnapshotLimit = 2 << 20 + +type toolAuthorizationSnapshot struct { + Version int `json:"version"` + PolicyVersion int `json:"policyVersion"` + Tool *Tool `json:"tool"` +} + +// ToolAuthorizationSnapshot records the tool the owner explicitly approved. +// It is produced from the trusted catalog, never from credential request data. +func ToolAuthorizationSnapshot(tool Tool) (string, error) { + catalog, err := ValidateCatalog([]Tool{tool}) + if err != nil { + return "", err + } + data, err := json.Marshal(toolAuthorizationSnapshot{ + Version: toolAuthorizationSnapshotVersion, PolicyVersion: ToolPolicyVersion, Tool: &catalog[0], + }) + if err != nil { + return "", err + } + if len(data) > toolAuthorizationSnapshotLimit { + return "", errors.New("MCP authorization snapshot is too large") + } + return string(data), nil +} + +// ToolAuthorizationAllows accepts only equivalent or provably narrower tools. +// The original snapshot remains the baseline; callers must separately require +// the current schema version and complete policy hash on every invocation. +func ToolAuthorizationAllows(snapshot, grantedPolicyHash string, current Tool) bool { + if grantedPolicyHash == "" { + return false + } + var saved toolAuthorizationSnapshot + if DecodeObjectLimit([]byte(snapshot), &saved, toolAuthorizationSnapshotLimit) != nil || + saved.Version != toolAuthorizationSnapshotVersion || saved.PolicyVersion != ToolPolicyVersion || saved.Tool == nil || + saved.Tool.PolicyHash != grantedPolicyHash || ToolPolicyHash(*saved.Tool) != grantedPolicyHash { + return false + } + // A snapshot must carry its read/write classification explicitly. Unlike + // the API manifest, that classification is not part of ToolPolicyHash. + var fields struct { + Tool map[string]json.RawMessage `json:"tool"` + } + var classification *bool + if json.Unmarshal([]byte(snapshot), &fields) != nil || json.Unmarshal(fields.Tool["readOnly"], &classification) != nil || classification == nil { + return false + } + previous, err := ValidateCatalog([]Tool{*saved.Tool}) + if err != nil { + return false + } + latest, err := ValidateCatalog([]Tool{current}) + if err != nil { + return false + } + old, next := previous[0], latest[0] + if old.ToolID != next.ToolID || old.ReadOnly && !next.ReadOnly || !authorizationSchemaSubset(old.InputSchema, next.InputSchema, 0) { + return false + } + ops := make(map[string]Operation, len(old.Operations)) + for _, op := range old.Operations { + ops[op.ID] = op + } + for _, op := range next.Operations { + approved, exists := ops[op.ID] + if !exists || approved.Method != op.Method || approved.Path != op.Path || + !authorizationSchemaSubset(approved.BodySchema, op.BodySchema, 0) || + !authorizationResponseSubset(approved.ResponseFields, op.ResponseFields) { + return false + } + } + return true +} + +func authorizationResponseSubset(previous, current []string) bool { + for _, field := range current { + // Projections expose scalar leaves. An approved parent path never + // authorizes new descendants, which could contain sensitive data. + if !slices.Contains(previous, field) { + return false + } + } + return true +} + +func authorizationSchemaSubset(previous, current map[string]any, depth int) bool { + if authorizationSchemaEqual(previous, current, depth) { + return true + } + if depth > 16 || validateSchema(previous, depth) != nil || validateSchema(current, depth) != nil { + return false + } + oldType, nextType := previous["type"], current["type"] + if oldType != nextType && !(oldType == "number" && nextType == "integer") { + return false + } + oldDefault, oldHasDefault := previous["default"] + nextDefault, nextHasDefault := current["default"] + if oldHasDefault != nextHasDefault || !reflect.DeepEqual(oldDefault, nextDefault) { + return false + } + if values, ok := previous["enum"].([]any); ok { + selected, ok := current["enum"].([]any) + if !ok { + return false + } + for _, value := range selected { + if !slices.ContainsFunc(values, func(approved any) bool { return reflect.DeepEqual(approved, value) }) { + return false + } + } + } + for _, key := range []string{"minimum", "minLength", "minItems"} { + if bound, ok := previous[key].(float64); ok { + value, exists := current[key].(float64) + if !exists || value < bound { + return false + } + } + } + for _, key := range []string{"maximum", "maxLength", "maxItems"} { + if bound, ok := previous[key].(float64); ok { + value, exists := current[key].(float64) + if !exists || value > bound { + return false + } + } + } + if pattern, ok := previous["pattern"]; ok && !reflect.DeepEqual(pattern, current["pattern"]) { + return false + } + switch nextType { + case "object": + oldProperties := previous["properties"].(map[string]any) + nextProperties := current["properties"].(map[string]any) + for name, raw := range oldProperties { + if _, remains := nextProperties[name]; !remains && authorizationSchemaHasDefault(raw.(map[string]any)) { + // Removing a default can change the meaning of an omitted input. + return false + } + } + for name, raw := range nextProperties { + approved, exists := oldProperties[name].(map[string]any) + if !exists || !authorizationSchemaSubset(approved, raw.(map[string]any), depth+1) { + return false + } + } + nextRequired, _ := current["required"].([]any) + if required, ok := previous["required"].([]any); ok { + for _, field := range required { + if !slices.Contains(nextRequired, field) { + return false + } + } + } + case "array": + return authorizationSchemaSubset(previous["items"].(map[string]any), current["items"].(map[string]any), depth+1) + } + return true +} + +func authorizationSchemaHasDefault(schema map[string]any) bool { + if _, exists := schema["default"]; exists { + return true + } + if properties, ok := schema["properties"].(map[string]any); ok { + for _, raw := range properties { + if child, ok := raw.(map[string]any); ok && authorizationSchemaHasDefault(child) { + return true + } + } + } + if child, ok := schema["items"].(map[string]any); ok { + return authorizationSchemaHasDefault(child) + } + return false +} + +// Input schemas may contain extensions not interpreted by Core. Exact equality +// is safe, while a changed unknown constraint remains subject to confirmation. +// Only schema annotations are ignored, never defaults or enum object contents. +func authorizationSchemaEqual(previous, current map[string]any, depth int) bool { + if reflect.DeepEqual(previous, current) { + return true + } + if depth > 16 { + return false + } + count := func(schema map[string]any) int { + n := len(schema) + if _, exists := schema["description"]; exists { + n-- + } + if _, exists := schema["title"]; exists { + n-- + } + return n + } + if count(previous) != count(current) { + return false + } + for key, approved := range previous { + if key == "description" || key == "title" { + continue + } + value, exists := current[key] + if !exists { + return false + } + switch key { + case "properties": + oldProperties, oldOK := approved.(map[string]any) + nextProperties, nextOK := value.(map[string]any) + if !oldOK || !nextOK || len(oldProperties) != len(nextProperties) { + return false + } + for name, raw := range oldProperties { + oldSchema, oldOK := raw.(map[string]any) + nextSchema, nextOK := nextProperties[name].(map[string]any) + if !oldOK || !nextOK || !authorizationSchemaEqual(oldSchema, nextSchema, depth+1) { + return false + } + } + case "items": + oldSchema, oldOK := approved.(map[string]any) + nextSchema, nextOK := value.(map[string]any) + if !oldOK || !nextOK || !authorizationSchemaEqual(oldSchema, nextSchema, depth+1) { + return false + } + default: + if !reflect.DeepEqual(approved, value) { + return false + } + } + } + return true +} diff --git a/core/utils/mcp/contract.go b/core/utils/mcp/contract.go new file mode 100644 index 000000000..144b8d5fe --- /dev/null +++ b/core/utils/mcp/contract.go @@ -0,0 +1,373 @@ +package mcp + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/1Panel-dev/1Panel/core/app/auth" +) + +const ContractVersion = 5 + +const ToolPolicyVersion = 4 + +const DockerSocketDir = "/etc/1panel/mcp-runtime" +const DockerCoreSocket = DockerSocketDir + "/core.sock" +const RuntimePrefix = "/api/v2/core/mcp/runtime" +const NodesPath = RuntimePrefix + "/nodes" + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + InvocationID string `json:"invocationId,omitempty"` + Status int `json:"-"` + RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"` +} + +func (e *Error) Error() string { return e.Code } +func Fail(code string, status int, message string) *Error { + return &Error{Code: code, Status: status, Message: message} +} +func Invalid() *Error { return Fail("MCP_INVALID_ARGUMENTS", 400, "Invalid tool arguments") } +func Forbidden() *Error { + return Fail("MCP_FORBIDDEN", 403, "This credential or its owner cannot perform this operation") +} + +type Operation struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + BodySchema map[string]any `json:"bodySchema"` + ResponseFields []string `json:"responseFields"` +} +type Tool struct { + ToolID string `json:"toolId"` + CategoryID string `json:"categoryId"` + CategoryName string `json:"categoryName"` + CategoryOrder int `json:"categoryOrder"` + SchemaVersion int `json:"schemaVersion"` + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` + OutputSchema map[string]any `json:"outputSchema"` + ReadOnly bool `json:"readOnly"` + Risk string `json:"risk"` + Operations []Operation `json:"operations"` + PolicyHash string `json:"policyHash"` +} +type Invoke struct { + NodeID string `json:"nodeId"` + ToolID string `json:"toolId"` + SchemaVersion int `json:"schemaVersion"` + OperationID string `json:"operationId"` + PolicyHash string `json:"policyHash"` + Arguments json.RawMessage `json:"arguments"` + PathParams map[string]string `json:"pathParams,omitempty"` + RequestID string `json:"requestId,omitempty"` + AdmissionID string `json:"admissionId,omitempty"` +} +type Admission struct { + NodeID string `json:"nodeId"` + ToolID string `json:"toolId"` + SchemaVersion int `json:"schemaVersion"` + PolicyHash string `json:"policyHash"` + Arguments json.RawMessage `json:"arguments"` + RequestID string `json:"requestId,omitempty"` +} +type Admitted struct { + AdmissionID string `json:"admissionId"` + ExpiresAt time.Time `json:"expiresAt"` +} +type Completion struct { + AdmissionID string `json:"admissionId"` + ErrorCode string `json:"errorCode,omitempty"` +} +type Completed struct { + InvocationID string `json:"invocationId,omitempty"` +} +type APIRequest struct { + NodeID string + InvocationID string + Owner auth.APIKeyOwner + Method string + Path string + Arguments json.RawMessage + ResponseFields []string + ReadOnly bool +} +type InvocationResult struct { + InvocationID string `json:"invocationId"` + NodeID string `json:"nodeId"` + Result any `json:"result"` +} + +var toolIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`) +var categoryIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`) +var pathPart = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) +var fieldPart = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]{0,127}$`) + +func NormalizeNodeID(id string) (string, error) { + if id == "local" { + return "local", nil + } + if len(id) == 0 || len(id) > 20 || id[0] == '0' { + return "", Invalid() + } + for _, ch := range id { + if ch < '0' || ch > '9' { + return "", Invalid() + } + } + if _, err := strconv.ParseUint(id, 10, 64); err != nil { + return "", Invalid() + } + return id, nil +} + +func ValidateNodeID(id string) bool { + normalized, err := NormalizeNodeID(id) + return err == nil && normalized == id +} + +func ToolPolicyHash(tool Tool) string { + ops := append([]Operation(nil), tool.Operations...) + slices.SortFunc(ops, func(a, b Operation) int { return strings.Compare(a.ID, b.ID) }) + for i := range ops { + ops[i].ResponseFields = append([]string(nil), ops[i].ResponseFields...) + slices.Sort(ops[i].ResponseFields) + } + encoded, _ := json.Marshal(struct { + Version int `json:"version"` + SchemaVersion int `json:"schemaVersion"` + InputSchema map[string]any `json:"inputSchema"` + Operations []Operation `json:"operations"` + }{ToolPolicyVersion, tool.SchemaVersion, tool.InputSchema, ops}) + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]) +} + +func ValidateAPIPath(path string, template bool) bool { + if path == NodesPath { + return true + } + if !strings.HasPrefix(path, "/api/v2/") || len(path) > 1024 || strings.HasSuffix(path, "/") { + return false + } + seen := map[string]bool{} + for _, part := range strings.Split(strings.TrimPrefix(path, "/"), "/") { + if template && strings.HasPrefix(part, ":") { + name := strings.TrimPrefix(part, ":") + if !fieldPart.MatchString(name) || seen[name] { + return false + } + seen[name] = true + continue + } + if !pathPart.MatchString(part) || part == "." || part == ".." { + return false + } + } + for _, part := range strings.Split(path, "/") { + if part == "internal" || part == "core" || part == "terminal" || part == "ws" || part == "websocket" { + return false + } + } + return true +} +func ValidateCatalog(input []Tool) ([]Tool, error) { + if len(input) > 256 { + return nil, errors.New("too many MCP tools") + } + encoded, err := json.Marshal(input) + if err != nil { + return nil, err + } + var result []Tool + if err = json.Unmarshal(encoded, &result); err != nil { + return nil, err + } + seen := map[string]bool{} + type categoryMetadata struct { + name string + order int + } + categories := map[string]categoryMetadata{} + for i := range result { + t := &result[i] + if !toolIdentifier.MatchString(t.ToolID) || seen[t.ToolID] || t.SchemaVersion < 1 || t.SchemaVersion > 1000000 || len(t.Operations) < 1 || len(t.Operations) > 32 || len(t.Name) > 256 || len(t.Description) > 8192 || t.InputSchema["type"] != "object" { + return nil, errors.New("invalid MCP tool") + } + if !categoryIdentifier.MatchString(t.CategoryID) || t.CategoryOrder < 1 || t.CategoryOrder > 1000000 || len(t.CategoryName) == 0 || len(t.CategoryName) > 128 || !utf8.ValidString(t.CategoryName) || strings.TrimSpace(t.CategoryName) != t.CategoryName || strings.ContainsFunc(t.CategoryName, unicode.IsControl) { + return nil, errors.New("invalid MCP category") + } + metadata := categoryMetadata{t.CategoryName, t.CategoryOrder} + if previous, exists := categories[t.CategoryID]; exists && previous != metadata { + return nil, errors.New("inconsistent MCP category") + } + categories[t.CategoryID] = metadata + seen[t.ToolID] = true + ops := map[string]bool{} + for j := range t.Operations { + op := &t.Operations[j] + if op.Path == NodesPath && (!t.ReadOnly || op.Method != http.MethodGet) { + return nil, errors.New("invalid MCP node discovery operation") + } + if !toolIdentifier.MatchString(op.ID) || ops[op.ID] || !slices.Contains([]string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}, op.Method) || !ValidateAPIPath(op.Path, true) || op.BodySchema["type"] != "object" { + return nil, errors.New("invalid MCP API operation") + } + ops[op.ID] = true + if err = validateSchema(op.BodySchema, 0); err != nil { + return nil, err + } + if _, err = projectionTree(op.ResponseFields); err != nil { + return nil, err + } + } + t.Risk = "write" + if t.ReadOnly { + t.Risk = "read" + } + if t.Name == "" { + t.Name = t.ToolID + } + t.PolicyHash = ToolPolicyHash(*t) + } + return result, nil +} +func OperationPath(op Operation, params map[string]string) (string, error) { + parts := strings.Split(op.Path, "/") + used := 0 + for i, part := range parts { + if !strings.HasPrefix(part, ":") { + continue + } + value, ok := params[part[1:]] + if !ok || !pathPart.MatchString(value) || value == "." || value == ".." { + return "", Invalid() + } + parts[i] = value + used++ + } + if used != len(params) { + return "", Invalid() + } + path := strings.Join(parts, "/") + if !ValidateAPIPath(path, false) { + return "", Invalid() + } + return path, nil +} + +func PermissionPath(op Operation) string { + params := map[string]string{} + for _, part := range strings.Split(op.Path, "/") { + if strings.HasPrefix(part, ":") { + params[part[1:]] = "1" + } + } + p, _ := OperationPath(op, params) + return p +} +func NormalizeOperation(tool Tool, req Invoke) (Operation, string, json.RawMessage, error) { + if _, err := NormalizeNodeID(req.NodeID); err != nil { + return Operation{}, "", nil, err + } + if len(req.RequestID) > 128 || strings.ContainsAny(req.RequestID, "\r\n\x00") { + return Operation{}, "", nil, Invalid() + } + for _, op := range tool.Operations { + if op.ID == req.OperationID { + path, err := OperationPath(op, req.PathParams) + if err != nil { + return Operation{}, "", nil, err + } + var args map[string]any + if err = DecodeObject(req.Arguments, &args); err != nil { + return Operation{}, "", nil, err + } + if err = validateValue(op.BodySchema, args, 0); err != nil { + return Operation{}, "", nil, Invalid() + } + data, err := json.Marshal(args) + return op, path, data, err + } + } + return Operation{}, "", nil, Forbidden() +} + +func DecodeObject(data []byte, value any) error { + return DecodeObjectLimit(data, value, 64<<10) +} + +func DecodeObjectLimit(data []byte, value any, limit int) error { + if len(data) == 0 || len(data) > limit { + return Invalid() + } + d := json.NewDecoder(bytes.NewReader(data)) + if err := checkJSON(d); err != nil { + return Invalid() + } + if _, err := d.Token(); err != io.EOF { + return Invalid() + } + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || trimmed[0] != '{' { + return Invalid() + } + d = json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(value); err != nil { + return Invalid() + } + return nil +} +func checkJSON(d *json.Decoder) error { + t, err := d.Token() + if err != nil { + return err + } + delim, ok := t.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := map[string]bool{} + for d.More() { + key, err := d.Token() + if err != nil { + return err + } + s, ok := key.(string) + if !ok || seen[s] { + return errors.New("duplicate JSON key") + } + seen[s] = true + if err = checkJSON(d); err != nil { + return err + } + } + case '[': + for d.More() { + if err := checkJSON(d); err != nil { + return err + } + } + default: + return errors.New("unexpected delimiter") + } + _, err = d.Token() + return err +} diff --git a/core/utils/mcp/ingress.go b/core/utils/mcp/ingress.go new file mode 100644 index 000000000..d99c17fb5 --- /dev/null +++ b/core/utils/mcp/ingress.go @@ -0,0 +1,31 @@ +package mcp + +import ( + "sync" + "time" +) + +var ingress struct { + sync.Mutex + start int64 + requests int +} + +func AdmitIngress(now time.Time) error { + ingress.Lock() + defer ingress.Unlock() + if now.Unix() >= ingress.start+60 { + ingress.start = now.Unix() + ingress.requests = 0 + } + if ingress.requests >= 1200 { + err := Fail("MCP_RATE_LIMITED", 429, "MCP entrance request limit exceeded") + err.RetryAfterSeconds = int(ingress.start + 60 - now.Unix()) + if err.RetryAfterSeconds < 1 { + err.RetryAfterSeconds = 1 + } + return err + } + ingress.requests++ + return nil +} diff --git a/core/utils/mcp/projection.go b/core/utils/mcp/projection.go new file mode 100644 index 000000000..fa56bf070 --- /dev/null +++ b/core/utils/mcp/projection.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "encoding/json" + "errors" + "strings" +) + +type projectionNode struct { + leaf bool + children map[string]*projectionNode +} + +func projectionTree(fields []string) (*projectionNode, error) { + root := &projectionNode{children: map[string]*projectionNode{}} + if len(fields) > 256 { + return nil, errors.New("too many response fields") + } + for _, field := range fields { + parts := strings.Split(field, ".") + if len(parts) > 16 { + return nil, errors.New("response path too deep") + } + node := root + for _, part := range parts { + if !fieldPart.MatchString(part) || node.leaf { + return nil, errors.New("invalid response path") + } + if node.children[part] == nil { + node.children[part] = &projectionNode{children: map[string]*projectionNode{}} + } + node = node.children[part] + } + if node.leaf || len(node.children) > 0 { + return nil, errors.New("conflicting response fields") + } + node.leaf = true + } + return root, nil +} + +// ProjectResult never copies a complete business object. A manifest lists +// scalar leaves explicitly, and paths transparently traverse arrays. +func ProjectResult(value any, fields []string) (any, error) { + root, err := projectionTree(fields) + if err != nil { + return nil, err + } + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + var data any + if err = json.Unmarshal(raw, &data); err != nil { + return nil, err + } + return projectValue(data, root, 0) +} +func projectValue(value any, node *projectionNode, depth int) (any, error) { + if depth > 32 { + return nil, errors.New("response too deep") + } + if value == nil { + return nil, nil + } + if list, ok := value.([]any); ok { + out := make([]any, 0, len(list)) + for _, item := range list { + v, err := projectValue(item, node, depth+1) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil + } + if node.leaf { + switch value.(type) { + case string, float64, bool: + return value, nil + default: + return nil, errors.New("response field must be a scalar leaf") + } + } + if len(node.children) == 0 { + return map[string]any{}, nil + } + obj, ok := value.(map[string]any) + if !ok { + return nil, errors.New("response shape changed") + } + out := map[string]any{} + for key, child := range node.children { + v, exists := obj[key] + if !exists { + continue + } + selected, err := projectValue(v, child, depth+1) + if err != nil { + return nil, err + } + out[key] = selected + } + return out, nil +} diff --git a/core/utils/mcp/schema.go b/core/utils/mcp/schema.go new file mode 100644 index 000000000..e3d245a6d --- /dev/null +++ b/core/utils/mcp/schema.go @@ -0,0 +1,198 @@ +package mcp + +import ( + "errors" + "math" + "reflect" + "regexp" + "slices" + "unicode/utf8" +) + +func ValidateInput(schema map[string]any, value any) bool { + return validateSchema(schema, 0) == nil && validateValue(schema, value, 0) == nil +} + +func validateSchema(s map[string]any, depth int) error { + if depth > 16 { + return errors.New("MCP schema is too deep") + } + typ, ok := s["type"].(string) + if !ok || !slices.Contains([]string{"object", "string", "integer", "number", "boolean", "array", "null"}, typ) { + return errors.New("unsupported MCP schema type") + } + allowed := []string{"type", "description", "title", "default", "enum"} + switch typ { + case "object": + allowed = append(allowed, "properties", "required", "additionalProperties") + case "string": + allowed = append(allowed, "minLength", "maxLength", "pattern") + case "integer", "number": + allowed = append(allowed, "minimum", "maximum") + case "array": + allowed = append(allowed, "items", "minItems", "maxItems") + } + for key := range s { + if !slices.Contains(allowed, key) { + return errors.New("unsupported MCP schema keyword") + } + } + if e, ok := s["enum"]; ok { + values, ok := e.([]any) + if !ok || len(values) == 0 || len(values) > 256 { + return errors.New("invalid enum") + } + } + for _, k := range []string{"minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"} { + if v, ok := s[k]; ok { + n, ok := v.(float64) + if !ok || math.IsNaN(n) || math.IsInf(n, 0) { + return errors.New("invalid schema limit") + } + if k != "minimum" && k != "maximum" && (n < 0 || n != math.Trunc(n)) { + return errors.New("invalid schema size") + } + } + } + if p, ok := s["pattern"]; ok { + text, ok := p.(string) + if !ok || len(text) > 1024 { + return errors.New("invalid pattern") + } + if _, err := regexp.Compile(text); err != nil { + return err + } + } + if typ == "object" { + if extra, ok := s["additionalProperties"].(bool); !ok || extra { + return errors.New("MCP objects must reject undeclared fields") + } + props, ok := s["properties"].(map[string]any) + if !ok || len(props) > 128 { + return errors.New("invalid properties") + } + for name, raw := range props { + if !fieldPart.MatchString(name) { + return errors.New("invalid property name") + } + child, ok := raw.(map[string]any) + if !ok { + return errors.New("invalid property schema") + } + if err := validateSchema(child, depth+1); err != nil { + return err + } + } + if raw, ok := s["required"]; ok { + required, ok := raw.([]any) + if !ok { + return errors.New("invalid required fields") + } + seen := map[string]bool{} + for _, r := range required { + name, ok := r.(string) + if !ok || props[name] == nil || seen[name] { + return errors.New("invalid required field") + } + seen[name] = true + } + } + } + if typ == "array" { + child, ok := s["items"].(map[string]any) + if !ok { + return errors.New("invalid array schema") + } + if err := validateSchema(child, depth+1); err != nil { + return err + } + } + return nil +} +func validateValue(s map[string]any, v any, depth int) error { + if depth > 32 { + return Invalid() + } + if raw, ok := s["enum"]; ok { + matched := false + for _, e := range raw.([]any) { + if reflect.DeepEqual(e, v) { + matched = true + break + } + } + if !matched { + return Invalid() + } + } + bound := func(value float64, lower, upper string) bool { + if n, ok := s[lower].(float64); ok && value < n { + return false + } + if n, ok := s[upper].(float64); ok && value > n { + return false + } + return true + } + switch s["type"] { + case "object": + obj, ok := v.(map[string]any) + if !ok { + return Invalid() + } + props := s["properties"].(map[string]any) + if required, ok := s["required"].([]any); ok { + for _, r := range required { + if _, exists := obj[r.(string)]; !exists { + return Invalid() + } + } + } + for k, val := range obj { + raw, ok := props[k] + if !ok { + return Invalid() + } + if err := validateValue(raw.(map[string]any), val, depth+1); err != nil { + return err + } + } + case "array": + a, ok := v.([]any) + if !ok || !bound(float64(len(a)), "minItems", "maxItems") { + return Invalid() + } + for _, item := range a { + if err := validateValue(s["items"].(map[string]any), item, depth+1); err != nil { + return err + } + } + case "string": + text, ok := v.(string) + if !ok || !bound(float64(utf8.RuneCountInString(text)), "minLength", "maxLength") { + return Invalid() + } + if p, ok := s["pattern"].(string); ok && !regexp.MustCompile(p).MatchString(text) { + return Invalid() + } + case "number", "integer": + n, ok := v.(float64) + if !ok || math.IsNaN(n) || math.IsInf(n, 0) || !bound(n, "minimum", "maximum") { + return Invalid() + } + if s["type"] == "integer" && (math.Trunc(n) != n || math.Abs(n) > 9007199254740991) { + return Invalid() + } + case "boolean": + if _, ok := v.(bool); !ok { + return Invalid() + } + case "null": + if v != nil { + return Invalid() + } + default: + return Invalid() + } + return nil +} diff --git a/core/utils/mcpapi/dispatch.go b/core/utils/mcpapi/dispatch.go new file mode 100644 index 000000000..ed84a13ee --- /dev/null +++ b/core/utils/mcpapi/dispatch.go @@ -0,0 +1,197 @@ +// Package mcpapi sends an already-authorized MCP operation through the existing +// Core HTTP application without exposing an HTTP header-based identity bridge. +package mcpapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/utils/mcpapi/metadata" + "github.com/1Panel-dev/1Panel/core/utils/xpack/providers" +) + +const ( + CapabilityContractVersion = 4 + InvocationHeader = "X-Panel-MCP-Invocation" + NodeHeader = "X-Panel-MCP-Node" + MethodHeader = "X-Panel-MCP-Method" + PathHeader = "X-Panel-MCP-Path" +) +const maxResponseBytes = 8 << 20 + +type Request struct { + InvocationID string + Owner auth.APIKeyOwner + NodeID string + Method string + Path string + Arguments json.RawMessage +} +type identityKey struct{} +type identity struct { + InvocationID string + Owner auth.APIKeyOwner + NodeID string + Method, Path string +} + +var application struct { + sync.RWMutex + handler http.Handler +} + +func SetHandler(handler http.Handler) { + application.Lock() + application.handler = handler + application.Unlock() +} + +func Identity(req *http.Request) (string, auth.APIKeyOwner, bool) { + value, ok := req.Context().Value(identityKey{}).(identity) + if !ok || value.Method != req.Method || value.Path != req.URL.Path || req.URL.RawQuery != "" || value.InvocationID == "" || value.Owner.ID == "" || providers.ValidateMCPAPIBoundary(value.NodeID, value.Method, value.Path) != nil { + return "", auth.APIKeyOwner{}, false + } + return value.InvocationID, value.Owner, true +} + +func IsRequest(req *http.Request) bool { _, _, ok := Identity(req); return ok } + +func HasIdentity(req *http.Request) bool { + _, ok := req.Context().Value(identityKey{}).(identity) + return ok +} + +func NodeID(req *http.Request) (string, bool) { + if !IsRequest(req) { + return "", false + } + return req.Context().Value(identityKey{}).(identity).NodeID, true +} + +func ClearForwardedHeaders(header http.Header) { + for _, name := range []string{InvocationHeader, NodeHeader, MethodHeader, PathHeader} { + header.Del(name) + } +} + +func SetForwardedHeaders(header http.Header, req *http.Request) bool { + ClearForwardedHeaders(header) + invocation, _, ok := Identity(req) + if !ok { + return false + } + nodeID, _ := NodeID(req) + header.Set(InvocationHeader, invocation) + header.Set(NodeHeader, nodeID) + header.Set(MethodHeader, req.Method) + header.Set(PathHeader, req.URL.Path) + return true +} + +func Dispatch(ctx context.Context, input Request) (result any, err error) { + if err := providers.ValidateMCPAPIBoundary(input.NodeID, input.Method, input.Path); err != nil { + return nil, err + } + if input.InvocationID == "" || input.Owner.ID == "" || len(input.Arguments) > 64<<10 { + return nil, errors.New("invalid MCP API request") + } + application.RLock() + handler := application.handler + application.RUnlock() + if handler == nil { + return nil, errors.New("MCP API dispatcher unavailable") + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + request, err := http.NewRequestWithContext(context.WithValue(ctx, identityKey{}, identity{InvocationID: input.InvocationID, Owner: input.Owner, NodeID: input.NodeID, Method: input.Method, Path: input.Path}), input.Method, "http://mcp.internal"+input.Path, bytes.NewReader(input.Arguments)) + if err != nil { + return nil, errors.New("invalid MCP API request") + } + request = metadata.BindRequest(request, IsRequest) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + if input.NodeID == "local" { + request.Header.Set("CurrentNode", "local") + } + response := &boundedResponse{header: make(http.Header)} + defer func() { + if recover() != nil { + result, err = nil, errors.New("MCP API execution failed") + } + }() + handler.ServeHTTP(response, request) + if response.overflow { + return nil, errors.New("MCP API execution failed") + } + if !strings.Contains(strings.ToLower(response.header.Get("Content-Type")), "application/json") { + if json.Valid(response.body.Bytes()) { + return nil, errors.New("MCP API execution failed") + } + if failure := classifyReadResponseFailure(input, response.status, 0, "", false); failure != nil { + return nil, failure + } + return nil, errors.New("MCP API execution failed") + } + var envelope struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` + } + decoder := json.NewDecoder(bytes.NewReader(response.body.Bytes())) + if decoder.Decode(&envelope) != nil || decoder.Decode(new(any)) != io.EOF { + return nil, errors.New("MCP API execution failed") + } + if response.status < 200 || response.status >= 300 || envelope.Code != 200 { + businessCode := responseBusinessCode(response.header) + if failure := classifyReadResponseFailure(input, response.status, envelope.Code, businessCode, true); failure != nil { + return nil, failure + } + if (response.status == 200 || response.status == 400 || response.status == 409 || response.status >= 500 && response.status <= 599) && + (envelope.Code == 400 || envelope.Code == 409 || envelope.Code == 500) { + if hint := BusinessErrorHint(input.Method, input.Path, businessCode); hint != "" { + return nil, &businessHintError{hint: hint} + } + } + return nil, errors.New("MCP API execution failed") + } + if len(envelope.Data) == 0 || bytes.Equal(bytes.TrimSpace(envelope.Data), []byte("null")) { + return map[string]any{}, nil + } + decoder = json.NewDecoder(bytes.NewReader(envelope.Data)) + decoder.UseNumber() + if decoder.Decode(&result) != nil { + return nil, errors.New("MCP API execution failed") + } + return result, nil +} + +type boundedResponse struct { + header http.Header + body bytes.Buffer + status int + overflow bool +} + +func (r *boundedResponse) Header() http.Header { return r.header } +func (r *boundedResponse) WriteHeader(code int) { + if r.status == 0 { + r.status = code + } +} +func (r *boundedResponse) Write(data []byte) (int, error) { + if r.status == 0 { + r.WriteHeader(http.StatusOK) + } + if r.overflow || len(data) > maxResponseBytes-r.body.Len() { + r.overflow = true + return 0, io.ErrShortBuffer + } + return r.body.Write(data) +} diff --git a/core/utils/mcpapi/metadata/request.go b/core/utils/mcpapi/metadata/request.go new file mode 100644 index 000000000..729ca6746 --- /dev/null +++ b/core/utils/mcpapi/metadata/request.go @@ -0,0 +1,22 @@ +package metadata + +import ( + "context" + "net/http" +) + +const BusinessCodeHeader = "X-Panel-MCP-Business-Code" + +type verifierKey struct{} + +func BindRequest(request *http.Request, verify func(*http.Request) bool) *http.Request { + return request.WithContext(context.WithValue(request.Context(), verifierKey{}, verify)) +} + +func TrustedRequest(request *http.Request) bool { + if request == nil { + return false + } + verify, ok := request.Context().Value(verifierKey{}).(func(*http.Request) bool) + return ok && verify != nil && verify(request) +} diff --git a/core/utils/mcpapi/read_errors.go b/core/utils/mcpapi/read_errors.go new file mode 100644 index 000000000..1c7c3047a --- /dev/null +++ b/core/utils/mcpapi/read_errors.go @@ -0,0 +1,76 @@ +package mcpapi + +import "errors" + +const ( + NodeAbnormalMessage = "1Panel reports an abnormal status for the selected node. Check the node status, Agent service and panel logs." + MySQLStoppedMessage = "The selected MySQL instance is not running. Start the database service and try the query again." + MySQLConnectionFailedMessage = "Cannot connect to the selected MySQL instance. Check the database service, connection settings and panel logs." + MySQLStatusFailedMessage = "Cannot read the selected MySQL instance status. Check that the database service is running and review its connection settings and panel logs." + SystemInfoFailedMessage = "Cannot read system information from the selected 1Panel node. Check the Agent service and panel logs." +) + +func ReadErrorHint(method, fullAPIPath, businessCode string) string { + if method == "POST" && fullAPIPath == "/api/v2/databases/status" { + switch businessCode { + case CodeMySQLStopped: + return MySQLStoppedMessage + case CodeMySQLConnectionFailed: + return MySQLConnectionFailedMessage + } + } + return "" +} + +type readResponseError struct { + message string + systemTransport bool +} + +func (e *readResponseError) Error() string { return e.message } + +func SafeReadErrorMessage(err error) string { + var failure *readResponseError + if !errors.As(err, &failure) || failure == nil { + return "" + } + switch failure.message { + case MySQLStoppedMessage, MySQLConnectionFailedMessage, MySQLStatusFailedMessage, SystemInfoFailedMessage, NodeAbnormalMessage: + return failure.message + } + return "" +} + +func IsSystemReadTransportFailure(err error) bool { + var failure *readResponseError + return errors.As(err, &failure) && failure != nil && failure.message == SystemInfoFailedMessage && failure.systemTransport +} + +func classifyReadResponseFailure(input Request, status, code int, businessCode string, parsed bool) error { + switch { + case input.Method == "POST" && input.Path == "/api/v2/databases/status": + if !parsed || (code != 400 && code != 500) || (status != 200 && status != 400 && (status < 500 || status > 599)) { + return nil + } + hint := ReadErrorHint(input.Method, input.Path, businessCode) + if hint == "" { + hint = MySQLStatusFailedMessage + } + return &readResponseError{message: hint} + case input.Method == "GET" && input.Path == "/api/v2/dashboard/base/os": + if (status >= 400 && status < 500) || (parsed && code >= 400 && code < 500) { + return nil + } + serverStatus := status >= 500 && status <= 599 + businessFailure := parsed && status >= 200 && status < 300 && code >= 500 && code <= 599 + if !serverStatus && !businessFailure { + return nil + } + if parsed && code != 0 && code != 200 && (code < 500 || code > 599) { + return nil + } + transport := status == 502 || status == 503 || status == 504 || (parsed && code == 500 && businessCode == "ErrProxy") + return &readResponseError{message: SystemInfoFailedMessage, systemTransport: transport} + } + return nil +} diff --git a/core/utils/mcpapi/safe_errors.go b/core/utils/mcpapi/safe_errors.go new file mode 100644 index 000000000..3efe14e9f --- /dev/null +++ b/core/utils/mcpapi/safe_errors.go @@ -0,0 +1,75 @@ +package mcpapi + +import ( + "errors" + "net/http" + + "github.com/1Panel-dev/1Panel/core/utils/mcpapi/metadata" +) + +const ( + BusinessCodeHeader = metadata.BusinessCodeHeader + CodeMySQLStopped = "MCP_MYSQL_STOPPED" + CodeMySQLConnectionFailed = "MCP_MYSQL_CONNECTION_FAILED" + + WebsiteAliasHint = "1Panel reports that the website identifier already exists." + WebsiteDomainHint = "1Panel reports that the domain is already used by a website." + PortOccupiedHint = "1Panel reports that the requested port is already in use." + DatabaseExistsHint = "1Panel reports that the database or its panel record already exists." +) + +// responseBusinessCode accepts one known code from the internal Core/Agent +// response. Localized messages and arbitrary metadata are never used as hints. +func responseBusinessCode(header http.Header) string { + values := header.Values(BusinessCodeHeader) + if len(values) != 1 || len(values[0]) > 64 { + return "" + } + switch values[0] { + case "ErrAliasIsExist", "ErrDomainIsUsed", "ErrPortInUsed", "ErrPortInOtherApp", "ErrPortExist", "ErrRecordExist", "ErrDatabaseIsExist", "ErrProxy", CodeMySQLStopped, CodeMySQLConnectionFailed: + return values[0] + } + return "" +} + +func BusinessErrorHint(method, path, businessCode string) string { + if method != http.MethodPost { + return "" + } + switch path { + case "/api/v2/websites": + switch businessCode { + case "ErrAliasIsExist": + return WebsiteAliasHint + case "ErrDomainIsUsed": + return WebsiteDomainHint + } + case "/api/v2/apps/install": + switch businessCode { + case "ErrPortInUsed", "ErrPortInOtherApp", "ErrPortExist": + return PortOccupiedHint + } + case "/api/v2/databases": + switch businessCode { + case "ErrRecordExist", "ErrDatabaseIsExist": + return DatabaseExistsHint + } + } + return "" +} + +type businessHintError struct{ hint string } + +func (e *businessHintError) Error() string { return e.hint } + +func SafeBusinessErrorMessage(err error) string { + var failure *businessHintError + if !errors.As(err, &failure) || failure == nil { + return "" + } + switch failure.hint { + case WebsiteAliasHint, WebsiteDomainHint, PortOccupiedHint, DatabaseExistsHint: + return failure.hint + " Operation outcome is unknown; check the target resource in 1Panel before retrying" + } + return "" +} diff --git a/core/utils/mcpruntime/docker.go b/core/utils/mcpruntime/docker.go new file mode 100644 index 000000000..4402de0d7 --- /dev/null +++ b/core/utils/mcpruntime/docker.go @@ -0,0 +1,242 @@ +package mcpruntime + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "regexp" + "strings" + "sync" + "time" + + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" +) + +const ( + DockerImageRepository = "1panel/1panel-mcp-server" + DefaultDockerImage = DockerImageRepository + ":v0.0.1" + DockerContainerName = "1panel-mcp" + dockerAgentEndpoint = "http://agent/internal/mcp/docker" +) + +var dockerDigestPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) + +func ValidateDockerImage(value string) error { + if len(value) > 180 { + return errors.New("invalid MCP Docker image") + } + if strings.HasPrefix(value, DockerImageRepository+"@") && dockerDigestPattern.MatchString(strings.TrimPrefix(value, DockerImageRepository+"@")) { + return nil + } + if strings.HasPrefix(value, DockerImageRepository+":") && versionPattern.MatchString(strings.TrimPrefix(value, DockerImageRepository+":")) { + return nil + } + return errors.New("MCP Docker image must use the official repository with a fixed semantic version or SHA256 digest") +} + +type DockerManager struct { + port int + image string + client *http.Client + installMu sync.Mutex + installImageID string +} + +type dockerRequest struct { + Operation string `json:"operation"` + Image string `json:"image"` + Port int `json:"port"` + Enabled bool `json:"enabled"` + Restart bool `json:"restart"` + ImageID string `json:"imageID,omitempty"` +} + +type dockerResponse struct { + State *struct { + Installed bool `json:"installed"` + Status string `json:"status"` + Ready bool `json:"ready"` + Reason string `json:"reason"` + AdapterVersion string `json:"adapterVersion"` + Image string `json:"image"` + ImageID string `json:"imageID"` + } `json:"state"` + Catalog json.RawMessage `json:"catalog"` + Logs string `json:"logs"` + Error string `json:"error"` + ImageID string `json:"imageID"` +} + +func NewDocker(port int, image string) *DockerManager { + transport := &http.Transport{Proxy: nil, DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", "/etc/1panel/agent.sock") + }, MaxIdleConns: 1, IdleConnTimeout: 30 * time.Second} + return &DockerManager{port: port, image: image, client: &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} + +func (m *DockerManager) call(ctx context.Context, operation string, enabled, restart bool, imageID string) (dockerResponse, error) { + if err := ValidateDockerImage(m.image); err != nil { + return dockerResponse{}, err + } + if m.port < 1024 || m.port > 65535 { + return dockerResponse{}, errors.New("MCP listen port must be between 1024 and 65535") + } + timeout := 65 * time.Second + if operation == "catalog" || operation == "candidate_catalog" || operation == "logs" || operation == "prepare" { + timeout = 5 * time.Second + } + if operation == "status" { + timeout = 8 * time.Second + } + if operation == "pull" { + timeout = 4*time.Minute + 5*time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + request := dockerRequest{Operation: operation, Image: m.image, Port: m.port, Enabled: enabled, Restart: restart, ImageID: imageID} + data, err := json.Marshal(request) + if err != nil { + return dockerResponse{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, dockerAgentEndpoint, bytes.NewReader(data)) + if err != nil { + return dockerResponse{}, err + } + req.Header.Set("Content-Type", "application/json") + response, err := m.client.Do(req) + defer m.client.CloseIdleConnections() + if err != nil { + return dockerResponse{}, errors.New("MCP Docker runtime agent is unavailable or timed out") + } + defer response.Body.Close() + limit := maxCatalogBytes + 64<<10 + body, err := io.ReadAll(io.LimitReader(response.Body, int64(limit+1))) + if err != nil || len(body) > limit { + return dockerResponse{}, errors.New("MCP Docker agent response is incomplete or too large") + } + var result dockerResponse + if contract.DecodeObjectLimit(body, &result, limit) != nil { + return dockerResponse{}, errors.New("MCP Docker agent returned an invalid response") + } + if result.Error != "" { + if len(result.Error) > 1024 { + return dockerResponse{}, errors.New("MCP Docker operation failed") + } + return dockerResponse{}, errors.New(result.Error) + } + if response.StatusCode != http.StatusOK { + return dockerResponse{}, errors.New("MCP Docker operation was rejected by the local agent") + } + return result, nil +} + +func (m *DockerManager) Prepare(ctx context.Context) error { + _, err := m.call(ctx, "prepare", false, false, "") + return err +} + +func (m *DockerManager) Catalog(ctx context.Context) ([]contract.Tool, error) { + tools, _, err := m.readCatalog(ctx, "catalog") + return tools, err +} + +func (m *DockerManager) readCatalog(ctx context.Context, operation string) ([]contract.Tool, string, error) { + select { + case catalogSlots <- struct{}{}: + defer func() { <-catalogSlots }() + default: + return nil, "", errors.New("MCP tool catalog is busy; retry shortly") + } + response, err := m.call(ctx, operation, false, false, "") + if err != nil { + return nil, "", err + } + var manifest struct { + ContractVersion int `json:"contractVersion"` + Tools []contract.Tool `json:"tools"` + } + if !dockerDigestPattern.MatchString(response.ImageID) || contract.DecodeObjectLimit(response.Catalog, &manifest, maxCatalogBytes) != nil || manifest.ContractVersion != ContractVersion { + return nil, "", errors.New("MCP Docker catalog is invalid or incompatible; update the panel and adapter together") + } + tools, err := contract.ValidateCatalog(manifest.Tools) + return tools, response.ImageID, err +} + +func (m *DockerManager) Status(ctx context.Context, enabled bool) State { + response, err := m.call(ctx, "status", enabled, false, "") + if err != nil { + return State{Status: "unsupported", Reason: err.Error(), Image: m.image} + } + if response.State == nil { + return State{Status: "unsupported", Reason: "MCP Docker agent returned no state", Image: m.image} + } + s := response.State + return State{Installed: s.Installed, Status: s.Status, Ready: s.Ready, Reason: s.Reason, AdapterVersion: s.AdapterVersion, Image: s.Image, ImageID: s.ImageID} +} + +func (m *DockerManager) Apply(ctx context.Context, enabled, restart bool) error { + var imageID string + if enabled { + var err error + if _, imageID, err = m.readCatalog(ctx, "candidate_catalog"); err != nil { + return err + } + } + _, err := m.call(ctx, "apply", enabled, restart, imageID) + return err +} + +func (m *DockerManager) InstallImage(ctx context.Context, enabled bool) error { + m.installMu.Lock() + m.installImageID = "" + m.installMu.Unlock() + if _, err := m.call(ctx, "pull", false, false, ""); err != nil { + return err + } + _, imageID, err := m.readCatalog(ctx, "candidate_catalog") + if err != nil { + return err + } + m.installMu.Lock() + m.installImageID = imageID + m.installMu.Unlock() + _, err = m.call(ctx, "install", enabled, true, imageID) + return err +} + +func (m *DockerManager) RestoreAbsent(ctx context.Context) error { + m.installMu.Lock() + imageID := m.installImageID + m.installMu.Unlock() + if imageID == "" { + return nil + } + _, err := m.call(ctx, "rollback_absent", false, false, imageID) + return err +} + +func (m *DockerManager) Restore(ctx context.Context, imageID string, enabled bool) error { + if imageID != "" && !dockerDigestPattern.MatchString(imageID) { + return errors.New("invalid MCP rollback image identity") + } + if enabled && imageID == "" { + return errors.New("MCP rollback requires the previous image identity") + } + _, err := m.call(ctx, "rollback", enabled, false, imageID) + return err +} + +func (m *DockerManager) Logs(ctx context.Context) (string, error) { + response, err := m.call(ctx, "logs", false, false, "") + if err != nil { + return "", err + } + if len(response.Logs) > 64<<10 { + return "", errors.New("MCP Docker logs exceed permitted size") + } + return redactLog(response.Logs), nil +} diff --git a/core/utils/mcpruntime/manager.go b/core/utils/mcpruntime/manager.go new file mode 100644 index 000000000..cf271dc42 --- /dev/null +++ b/core/utils/mcpruntime/manager.go @@ -0,0 +1,33 @@ +package mcpruntime + +import ( + "regexp" + + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" +) + +const ( + ContractVersion = contract.ContractVersion + Protocol = "streamable-http" + DefaultPort = 18081 + maxCatalogBytes = 2 << 20 +) + +type State struct { + Installed bool + Status string + Ready bool + Reason string + AdapterVersion string + Image string + ImageID string +} + +var catalogSlots = make(chan struct{}, 8) +var versionPattern = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$`) +var bearerPattern = regexp.MustCompile(`(?i)bearer\s+[^\s"']+`) +var sensitivePattern = regexp.MustCompile(`(?i)("?(?:password|token|secret|authorization)"?\s*[:=]\s*)("[^"]*"|[^\s,}]+)`) + +func redactLog(value string) string { + return sensitivePattern.ReplaceAllString(bearerPattern.ReplaceAllString(value, "Bearer [REDACTED]"), "${1}[REDACTED]") +} diff --git a/core/utils/xpack/helper/auth_helper.go b/core/utils/xpack/helper/auth_helper.go index dc54259f1..293763ac3 100644 --- a/core/utils/xpack/helper/auth_helper.go +++ b/core/utils/xpack/helper/auth_helper.go @@ -21,6 +21,36 @@ import ( type authHelper struct{ auth.PanelAPIKeyOwnerProvider } +var _ providers.MCPManagementAuthorizer = (*authHelper)(nil) + +func (a *authHelper) AuthorizeMCPManagement(owner auth.APIKeyOwner, nodeName string, requiredCodes []string) error { + if err := providers.ValidateMCPManagementBoundary(nodeName, requiredCodes); err != nil { + return err + } + _, err := a.ResolveAPIKeyOwner(nil, owner.Type, owner.ID) + return err +} + +var _ providers.MCPAPIAuthorizer = (*authHelper)(nil) + +func (a *authHelper) AuthorizeMCPAPI(owner auth.APIKeyOwner, nodeName, method, requestPath string) error { + if err := providers.ValidateMCPAPIBoundary(nodeName, method, requestPath); err != nil { + return err + } + _, err := a.ResolveMCPNode(owner, nodeName) + return err +} + +func (a *authHelper) BindMCPAPIIdentity(c *gin.Context, owner auth.APIKeyOwner) error { + current, err := a.ResolveAPIKeyOwner(c, owner.Type, owner.ID) + if err != nil { + return err + } + providers.BindMCPResolvedOwner(c, current) + c.Set(psession.GinContextSessionUserKey, psession.SessionUser{ID: current.ID, Name: current.Name, Role: "ADMIN"}) + return nil +} + func NewIAuthProvider() providers.AuthProvider { return &authHelper{} } diff --git a/core/utils/xpack/helper/mcp.go b/core/utils/xpack/helper/mcp.go new file mode 100644 index 000000000..ba2fcc8b4 --- /dev/null +++ b/core/utils/xpack/helper/mcp.go @@ -0,0 +1,27 @@ +package helper + +import ( + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/buserr" + "github.com/1Panel-dev/1Panel/core/utils/xpack/providers" +) + +var _ providers.MCPNodeProvider = (*authHelper)(nil) + +func (a *authHelper) ResolveMCPNode(owner auth.APIKeyOwner, id string) (providers.MCPNode, error) { + if id != "local" { + return providers.MCPNode{}, buserr.New("ErrNoSuchNode") + } + if _, err := a.ResolveAPIKeyOwner(nil, owner.Type, owner.ID); err != nil { + return providers.MCPNode{}, err + } + return providers.MCPNode{ID: "local", Name: "local", Status: "Healthy", IsLocal: true}, nil +} + +func (a *authHelper) ListMCPNodes(owner auth.APIKeyOwner) ([]providers.MCPNode, error) { + node, err := a.ResolveMCPNode(owner, "local") + if err != nil { + return nil, err + } + return []providers.MCPNode{node}, nil +} diff --git a/core/utils/xpack/providers/mcp.go b/core/utils/xpack/providers/mcp.go new file mode 100644 index 000000000..5fe1d1095 --- /dev/null +++ b/core/utils/xpack/providers/mcp.go @@ -0,0 +1,90 @@ +package providers + +import ( + "net/http" + "path" + "strconv" + "strings" + + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/buserr" + contract "github.com/1Panel-dev/1Panel/core/utils/mcp" + "github.com/gin-gonic/gin" +) + +type MCPManagementAuthorizer interface { + AuthorizeMCPManagement(owner auth.APIKeyOwner, nodeName string, requiredCodes []string) error +} + +type MCPAPIAuthorizer interface { + AuthorizeMCPAPI(owner auth.APIKeyOwner, nodeID, method, requestPath string) error + BindMCPAPIIdentity(c *gin.Context, owner auth.APIKeyOwner) error +} + +type MCPNode struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + IsLocal bool `json:"isLocal"` +} + +type MCPNodeProvider interface { + ListMCPNodes(owner auth.APIKeyOwner) ([]MCPNode, error) + ResolveMCPNode(owner auth.APIKeyOwner, id string) (MCPNode, error) +} + +func ValidateMCPNodeID(id string) error { + if id == "local" { + return nil + } + value, err := strconv.ParseUint(id, 10, 64) + if err != nil || value == 0 || strconv.FormatUint(value, 10) != id { + return buserr.New("ErrRBAC") + } + return nil +} + +func ValidateMCPAPIBoundary(nodeID, method, requestPath string) error { + if ValidateMCPNodeID(nodeID) != nil || !strings.HasPrefix(requestPath, "/api/v2/") || path.Clean(requestPath) != requestPath || strings.ContainsAny(requestPath, "%?#\\\x00\r\n") { + return buserr.New("ErrRBAC") + } + if nodeID != "local" && strings.HasPrefix(requestPath, "/api/v2/core/") { + return buserr.New("ErrRBAC") + } + switch method { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + default: + return buserr.New("ErrRBAC") + } + for _, prefix := range []string{"/api/v2/internal", "/api/v2/core/mcp", "/api/v2/core/auth", "/api/v2/hosts/terminal", "/api/v2/xpack/alert/offline", "/api/v2/hosts/firewall/port"} { + if requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") { + if requestPath == contract.NodesPath && nodeID == "local" && method == http.MethodGet { + return nil + } + return buserr.New("ErrRBAC") + } + } + return nil +} + +func BindMCPResolvedOwner(c *gin.Context, owner auth.APIKeyOwner) { + c.Set("API_AUTH", true) + c.Set("MCP_API_AUTH", true) + c.Set("API_AUTH_USERNAME", owner.Name) + c.Set("API_AUTH_USER_ID", owner.ID) + c.Set("API_AUTH_IS_SUPER_ADMIN", owner.IsSuperAdmin) +} + +func ValidateMCPManagementBoundary(nodeName string, requiredCodes []string) error { + if nodeName != "local" || len(requiredCodes) == 0 { + return buserr.New("ErrRBAC") + } + for _, code := range requiredCodes { + switch code { + case "mcp_access_view", "mcp_access_manage": + default: + return buserr.New("ErrRBAC") + } + } + return nil +} diff --git a/frontend/src/api/interface/panel-mcp.ts b/frontend/src/api/interface/panel-mcp.ts new file mode 100644 index 000000000..7e5aa41bb --- /dev/null +++ b/frontend/src/api/interface/panel-mcp.ts @@ -0,0 +1,122 @@ +export namespace PanelMCP { + export type CredentialStatus = 'Enable' | 'Disable' | 'Revoked' | 'Expired'; + export interface EditableCredential { + name: string; + description: string; + expiresAt: string | null; + tools: string[]; + toolPolicies: Record; + toolReadOnly: Record; + nodeIds: string[]; + } + export interface Credential extends EditableCredential { + id: string; + keyHint: string; + status: CredentialStatus; + revision: number; + createdAt: string; + ownerName: string; + staleTools: string[]; + } + export interface Reference { + id: string; + revision: number; + } + export interface Created { + item: Credential; + token: string; + alreadyCreated?: boolean; + } + export type Client = 'generic' | 'codex' | 'claude'; + export interface Revealed { + id: string; + token: string; + } + export interface Exported { + filename: string; + content: string; + } + export interface ToolOperation { + id: string; + method: string; + path: string; + bodySchema: Record; + responseFields: string[]; + } + export interface Tool { + toolId: string; + schemaVersion: number; + name: string; + description: string; + categoryId: string; + categoryName: string; + categoryOrder: number; + readOnly: boolean; + risk: string; + inputSchema: Record; + operations: ToolOperation[]; + policyHash: string; + } + export interface Catalog { + contractVersion: number; + tools: Tool[]; + scope: 'nodes'; + toolNodeIds: Record; + } + export interface Node { + id: string; + name: string; + status: string; + isLocal: boolean; + } + export type RuntimeType = 'docker'; + export interface Settings { + runtimeType: RuntimeType; + dockerImage: string; + enabled: boolean; + listenPort: number; + externalUrl: string; + revision: number; + accessPolicy: 'inherit' | 'restricted' | 'all'; + allowedIPs: string[]; + } + export interface ServiceStatus { + runtimeType: RuntimeType; + dockerImage: string; + containerName: string; + imageId: string; + enabled: boolean; + installed: boolean; + status: 'not_installed' | 'unsupported' | 'stopped' | 'running' | 'unhealthy' | 'incompatible'; + ready: boolean; + reason: string; + adapterVersion: string; + contractVersion: number; + protocol: 'streamable-http'; + endpoint: string; + internalEndpoint: string; + canManageService: boolean; + } + export interface Installation { + dockerImage: string; + listenPort: number; + externalUrl: string; + revision: number; + } + export type Operation = 'start' | 'stop' | 'restart'; + export interface Invocation { + id: string; + requestId: string; + credentialId: string; + credentialName: string; + ownerName: string; + toolId: string; + nodeId: string; + revision: number; + status: 'running' | 'success' | 'denied' | 'failed'; + code: string; + startedAt: string; + finishedAt: string | null; + durationMs: number; + } +} diff --git a/frontend/src/api/modules/panel-mcp.ts b/frontend/src/api/modules/panel-mcp.ts new file mode 100644 index 000000000..ef7e9ae72 --- /dev/null +++ b/frontend/src/api/modules/panel-mcp.ts @@ -0,0 +1,39 @@ +import http from '@/api'; +import type { ReqPage, ResPage } from '@/api/interface'; +import type { PanelMCP } from '@/api/interface/panel-mcp'; + +const prefix = '/core/mcp'; +const local = { headers: { CurrentNode: 'local' } }; + +export const getPanelMCPStatus = () => http.get(`${prefix}/status`, {}, local); +export const getPanelMCPSettings = () => http.get(`${prefix}/settings`, {}, local); +export const savePanelMCPSettings = (params: PanelMCP.Settings) => + http.postWithConfig(`${prefix}/settings`, params, { ...local, timeout: 60000 }); +export const operatePanelMCPService = (operation: PanelMCP.Operation) => + http.postWithConfig(`${prefix}/service/operate`, { operation }, { ...local, timeout: 60000 }); +export const installPanelMCPService = (params: PanelMCP.Installation) => + http.postWithConfig(`${prefix}/service/install`, params, { ...local, timeout: 420000 }); +export const getPanelMCPLogs = () => http.get<{ content: string }>(`${prefix}/service/logs`, {}, local); +export const getPanelMCPCatalog = () => http.get(`${prefix}/catalog`, {}, local); +export const getPanelMCPNodes = () => http.get(`${prefix}/nodes`, {}, local); +export const searchPanelMCPCredentials = (params: ReqPage & { excludeRevoked?: boolean }) => + http.postWithConfig>(`${prefix}/credentials/search`, params, local); +export const createPanelMCPCredential = (params: PanelMCP.EditableCredential & { requestID: string }) => + http.postWithConfig(`${prefix}/credentials/create`, params, local); +export const updatePanelMCPCredential = (params: PanelMCP.EditableCredential & PanelMCP.Reference) => + http.postWithConfig(`${prefix}/credentials/update`, params, local); +export const setPanelMCPCredentialStatus = (params: PanelMCP.Reference & { status: 'Enable' | 'Disable' }) => + http.postWithConfig(`${prefix}/credentials/status`, params, local); +export const revokePanelMCPCredential = (params: PanelMCP.Reference) => + http.postWithConfig(`${prefix}/credentials/revoke`, params, local); +export const revealPanelMCPCredential = (params: PanelMCP.Reference, signal?: AbortSignal) => + http.postWithConfig(`${prefix}/credentials/reveal`, params, { ...local, signal }); +export const rotatePanelMCPCredential = (params: PanelMCP.Reference, signal?: AbortSignal) => + http.postWithConfig(`${prefix}/credentials/rotate`, params, { ...local, signal }); +export const exportPanelMCPCredential = ( + params: PanelMCP.Reference & { client: PanelMCP.Client }, + signal?: AbortSignal, +) => http.postWithConfig(`${prefix}/credentials/export`, params, { ...local, signal }); +export const searchPanelMCPInvocations = ( + params: ReqPage & { credentialId?: string; toolId?: string; status?: string }, +) => http.postWithConfig>(`${prefix}/invocations/search`, params, local); diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index addeffb76..f641e58fd 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1720,8 +1720,8 @@ const message = { }, mcp: { server: 'MCP Server', - baseUrl: 'External Access Path', - baseUrlHelper: 'For example: http://192.168.1.2:8000', + baseUrl: 'Base URL', + baseUrlHelper: 'For example: http://192.168.1.2:8000, without the MCP path', ssePath: 'SSE Path', ssePathHelper: 'For example: /sse, note not to duplicate with other servers', environment: 'Environment Variables', @@ -1732,13 +1732,13 @@ const message = { domain: 'Default Access Address', domainHelper: 'For example: 192.168.1.1 or example.com', bindDomain: 'Bind Website', - commandPlaceHolder: 'Currently only supports npx and binary startup commands', + commandPlaceHolder: 'Currently supports npx, uvx, and binary startup commands', importMcpJson: 'Import MCP Server Configuration', importMcpJsonError: 'mcpServers structure is incorrect', bindDomainHelper: 'After binding the website, it will modify the access address of all installed MCP Servers and close external access to the ports', - outputTransport: 'Output Type', - streamableHttpPath: 'Streaming Path', + outputTransport: 'Transport Type', + streamableHttpPath: 'Streamable HTTP Path', streamableHttpPathHelper: 'For example: /mcp, note that it should not overlap with other Servers', npxHelper: 'Suitable for mcp started with npx or binary', uvxHelper: 'Suitable for mcp started with uvx', @@ -1762,6 +1762,147 @@ const message = { modelType: 'Model type', }, }, + panelMCP: { + hideToken: 'Hide Token', + revealTokenFailed: 'Could not retrieve the Token. Please try again.', + copyTokenFailed: 'Could not copy the Token. Please try again.', + exportFailed: 'Could not export the connection guide. Please try again.', + rotateTokenFailed: 'Could not regenerate the Token. Please try again.', + rotateTokenUncertain: + 'The regeneration result could not be confirmed. Refresh the credential and reveal its current Token before proceeding.', + allowedIPs: 'Allowed IPs / CIDRs', + allowedIPsPlaceholder: 'One IP address or CIDR per line; up to 64 entries', + allowedIPsInvalid: + 'Enter up to 64 valid IP addresses or CIDRs, each at most 64 characters. A restricted service list cannot be empty.', + accessPolicy: 'MCP source policy', + accessInherit: 'Inherit panel access policy', + accessRestricted: 'Allow listed IPs / CIDRs only', + accessAll: 'Allow any source', + accessPolicyHelp: 'Controls source IP access without changing credential or user permissions.', + accessAllConfirm: 'Allow all source IPs to access MCP?', + revealToken: 'Reveal Token', + copyToken: 'Copy Token', + rotateToken: 'Regenerate Token', + rotateTokenConfirm: 'Regenerate this Token? The current Token will become invalid immediately.', + tokenMemoryHelp: 'Keep the Token and connection guide secure.', + clientType: 'MCP client', + genericClient: 'Generic MCP client', + revealConfigHelp: 'Reveal the Token to generate the client configuration.', + downloadGuide: 'Download connection guide (.md)', + saveSecret: 'Save the Token and select "I have saved the token".', + alreadyCreated: 'Credential created. View the Token in Connection details.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: + 'Access is limited by node grants, tool grants, and account permissions. New nodes require separate authorization.', + nodeUnavailable: 'Node {0} is unavailable. Remove it before saving.', + allCategories: 'All categories', + toolCategory: 'Tool category', + selectedOnly: 'Selected only', + bulkActions: 'Batch actions', + toolDetails: 'Details', + hideToolDetails: 'Hide details', + outsideSelection: '{0} granted tools are outside the current view', + selectionSummary: 'Read only: {0} · Write: {1} · Unavailable: {2}', + unavailableSelected: '{0} granted tools are unavailable. Remove them before saving.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: 'Grant {0} matching tools', + clearVisible: 'Revoke {0} matching tools', + readOnlyVisible: 'Keep only read access for {0} matches', + bulkHelp: + 'Bulk actions affect matching tools in all categories, including hidden items, regardless of "Selected only". New tools require separate authorization.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'Clients must use the connection URL.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: 'Search by name, description, or ID', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: 'Some tool grants need to be reconfirmed.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: 'Replace in the configuration with the credential Token.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'All statuses', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: 'Reloading will replace unsaved configuration. Continue?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: 'Use a fixed version tag or SHA-256 digest. The latest tag is not supported.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: + 'Run docker load -i on the primary node to import the image before installing.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: 'Leave blank to use the panel URL. The path must be /mcp.', + invalidUrl: 'Enter an HTTP/HTTPS URL with the /mcp path and no credentials, query parameters, or fragment.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: 'Preparation time limit, excluded from the overall timeout.', pointTimeoutHelp: diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 28b35685c..29b424e4b 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1733,8 +1733,8 @@ const message = { }, mcp: { server: 'Servidor MCP', - baseUrl: 'Ruta de acceso externa', - baseUrlHelper: 'Por ejemplo: http://192.168.1.2:8000', + baseUrl: 'URL base', + baseUrlHelper: 'Por ejemplo: http://192.168.1.2:8000, sin la ruta MCP', ssePath: 'Ruta SSE', ssePathHelper: 'Por ejemplo: /sse, asegúrese de que no se repita con otros servidores', environment: 'Variables de entorno', @@ -1745,13 +1745,13 @@ const message = { domain: 'Dirección de acceso predeterminada', domainHelper: 'Por ejemplo: 192.168.1.1 o ejemplo.com', bindDomain: 'Vincular sitio web', - commandPlaceHolder: 'Actualmente solo se admiten comandos de inicio npx y binarios', + commandPlaceHolder: 'Actualmente solo se admiten comandos de inicio npx, uvx y binarios', importMcpJson: 'Importar configuración del servidor MCP', importMcpJsonError: 'La estructura de mcpServers es incorrecta', bindDomainHelper: 'Al vincular el sitio web, se modificará la dirección de acceso de todos los servidores MCP instalados y se cerrará el acceso externo a los puertos', - outputTransport: 'Tipo de salida', - streamableHttpPath: 'Ruta de transmisión', + outputTransport: 'Tipo de transporte', + streamableHttpPath: 'Ruta Streamable HTTP', streamableHttpPathHelper: 'Por ejemplo: /mcp, tenga en cuenta que no debe superponerse con otros servidores', npxHelper: 'Adecuado para mcp iniciado con npx o binario', @@ -1776,6 +1776,149 @@ const message = { modelType: 'Tipo de modelo', }, }, + panelMCP: { + hideToken: 'Ocultar Token', + revealTokenFailed: 'No se pudo obtener el Token. Inténtelo de nuevo.', + copyTokenFailed: 'No se pudo copiar el Token. Inténtelo de nuevo.', + exportFailed: 'No se pudo exportar la guía de conexión. Inténtelo de nuevo.', + rotateTokenFailed: 'No se pudo regenerar el Token. Inténtelo de nuevo.', + rotateTokenUncertain: + 'No se pudo confirmar la regeneración. Actualice la credencial y consulte su Token actual antes de continuar.', + allowedIPs: 'IP / CIDR permitidos', + allowedIPsPlaceholder: 'Una dirección IP o CIDR por línea; hasta 64 entradas', + allowedIPsInvalid: + 'Introduce hasta 64 direcciones IP o CIDR válidos, de hasta 64 caracteres cada uno. La lista del servicio no puede estar vacía si se restringen los orígenes.', + accessPolicy: 'Política de origen de MCP', + accessInherit: 'Heredar la política de acceso del panel', + accessRestricted: 'Permitir solo las IP / CIDR indicadas', + accessAll: 'Permitir cualquier origen', + accessPolicyHelp: + 'Controla las IP de origen sin modificar los permisos de las credenciales ni de los usuarios.', + accessAllConfirm: '¿Permitir que todas las IP de origen accedan a MCP?', + revealToken: 'Mostrar Token', + copyToken: 'Copiar Token', + rotateToken: 'Regenerar Token', + rotateTokenConfirm: '¿Regenerar este Token? El Token actual dejará de ser válido inmediatamente.', + tokenMemoryHelp: 'Proteja el Token y la guía de conexión.', + clientType: 'Cliente MCP', + genericClient: 'Cliente MCP genérico', + revealConfigHelp: 'Muestre el Token para generar la configuración del cliente.', + downloadGuide: 'Descargar guía de conexión (.md)', + saveSecret: 'Guarde el Token y seleccione «I have saved the token».', + alreadyCreated: 'Credencial creada. Consulte el Token en los detalles de conexión.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: + 'El acceso depende de los nodos y herramientas autorizados y de los permisos de la cuenta. Los nodos nuevos requieren autorización adicional.', + nodeUnavailable: 'El nodo {0} no está disponible. Elimínelo antes de guardar.', + allCategories: 'Todas las categorías', + toolCategory: 'Categoría de herramientas', + selectedOnly: 'Solo seleccionadas', + bulkActions: 'Acciones por lotes', + toolDetails: 'Detalles', + hideToolDetails: 'Ocultar detalles', + outsideSelection: '{0} herramientas autorizadas están fuera de la vista actual', + selectionSummary: 'Solo lectura: {0} · Escritura: {1} · No disponibles: {2}', + unavailableSelected: '{0} herramientas autorizadas no están disponibles. Elimínelas antes de guardar.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: 'Autorizar {0} herramientas coincidentes', + clearVisible: 'Revocar {0} herramientas coincidentes', + readOnlyVisible: 'Conservar solo lectura para {0} coincidencias', + bulkHelp: + 'Las acciones masivas afectan a las herramientas coincidentes de todas las categorías, incluidas las ocultas, independientemente de «Solo seleccionadas». Las herramientas nuevas requieren autorización adicional.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'Los clientes deben usar la URL de conexión.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: 'Buscar por nombre, descripción o ID', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: 'Es necesario volver a confirmar algunos permisos de herramientas.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: 'Sustituya en la configuración por el Token de la credencial.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'Todos los estados', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: 'Se reemplazará la configuración sin guardar. ¿Continuar?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: 'Use una etiqueta de versión fija o un resumen SHA-256. No se admite latest.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: + 'Ejecute docker load -i en el nodo principal para importar la imagen antes de instalarla.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: 'Déjelo vacío para usar la URL del panel. La ruta debe ser /mcp.', + invalidUrl: + 'Introduzca una URL HTTP/HTTPS con la ruta /mcp, sin credenciales, parámetros de consulta ni fragmento.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: 'Límite de preparación, excluido del tiempo límite total.', pointTimeoutHelp: diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index 29c263112..6b1b703a6 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -1703,8 +1703,8 @@ const message = { }, mcp: { server: 'سرور MCP', - baseUrl: 'مسیر دسترسی خارجی', - baseUrlHelper: 'مثلاً: http://192.168.1.2:8000', + baseUrl: 'آدرس پایه', + baseUrlHelper: 'مثلاً: http://192.168.1.2:8000، بدون مسیر MCP', ssePath: 'مسیر SSE', ssePathHelper: 'مثلاً: /sse، توجه کنید که با سایر سرورها تکراری نشود', environment: 'متغیرهای محیطی', @@ -1715,13 +1715,13 @@ const message = { domain: 'آدرس دسترسی پیش‌فرض', domainHelper: 'مثلاً: 192.168.1.1 یا example.com', bindDomain: 'اتصال وب‌سایت', - commandPlaceHolder: 'در حال حاضر فقط از دستورات راه‌اندازی npx و باینری پشتیبانی می‌شود', + commandPlaceHolder: 'در حال حاضر فقط از دستورات راه‌اندازی npx، uvx و باینری پشتیبانی می‌شود', importMcpJson: 'واردات پیکربندی سرور MCP', importMcpJsonError: 'ساختار mcpServers نادرست است', bindDomainHelper: 'پس از اتصال وب‌سایت، آدرس دسترسی همه سرورهای MCP نصب شده را تغییر می‌دهد و دسترسی خارجی به پورت‌ها را می‌بندد', - outputTransport: 'نوع خروجی', - streamableHttpPath: 'مسیر جریانی', + outputTransport: 'نوع انتقال', + streamableHttpPath: 'مسیر Streamable HTTP', streamableHttpPathHelper: 'مثلاً: /mcp، توجه کنید که با سایر سرورها تکراری نشود', npxHelper: 'مناسب برای mcp راه‌اندازی شده با npx یا باینری', uvxHelper: 'مناسب برای mcp راه‌اندازی شده با uvx', @@ -1743,6 +1743,146 @@ const message = { modelType: 'نوع مدل', }, }, + panelMCP: { + hideToken: 'پنهان کردن Token', + revealTokenFailed: 'دریافت Token انجام نشد. دوباره تلاش کنید.', + copyTokenFailed: 'کپی Token انجام نشد. دوباره تلاش کنید.', + exportFailed: 'صدور راهنمای اتصال انجام نشد. دوباره تلاش کنید.', + rotateTokenFailed: 'تولید دوباره Token انجام نشد. دوباره تلاش کنید.', + rotateTokenUncertain: + 'نتیجه تولید دوباره تأیید نشد. پیش از ادامه، اعتبارنامه را تازه‌سازی و Token فعلی آن را مشاهده کنید.', + allowedIPs: 'IP / CIDR مجاز', + allowedIPsPlaceholder: 'در هر خط یک آدرس IP یا CIDR؛ حداکثر 64 مورد', + allowedIPsInvalid: + 'حداکثر 64 آدرس IP یا CIDR معتبر با طول حداکثر 64 نویسه وارد کنید. هنگام محدود کردن مبدأ، فهرست سرویس نباید خالی باشد.', + accessPolicy: 'سیاست مبدأ MCP', + accessInherit: 'ارث‌بری سیاست دسترسی پنل', + accessRestricted: 'فقط IP / CIDR فهرست‌شده مجاز باشد', + accessAll: 'همه مبدأها مجاز باشند', + accessPolicyHelp: 'دسترسی IP مبدأ را کنترل می‌کند و مجوزهای اعتبارنامه و کاربر را تغییر نمی‌دهد.', + accessAllConfirm: 'به همه IPهای مبدأ اجازه دسترسی به MCP داده شود؟', + revealToken: 'نمایش Token', + copyToken: 'کپی Token', + rotateToken: 'تولید دوباره Token', + rotateTokenConfirm: 'Token دوباره تولید شود؟ Token فعلی بلافاصله نامعتبر می‌شود.', + tokenMemoryHelp: 'Token و راهنمای اتصال را امن نگه دارید.', + clientType: 'کلاینت MCP', + genericClient: 'کلاینت عمومی MCP', + revealConfigHelp: 'برای ایجاد پیکربندی کلاینت، Token را نمایش دهید.', + downloadGuide: 'دانلود راهنمای اتصال (.md)', + saveSecret: 'Token را ذخیره و گزینه «I have saved the token» را انتخاب کنید.', + alreadyCreated: 'اعتبارنامه ایجاد شد. Token را در جزئیات اتصال مشاهده کنید.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: 'دسترسی به مجوزهای گره، ابزار و حساب محدود است. گره‌های جدید به مجوز جداگانه نیاز دارند.', + nodeUnavailable: 'گره {0} در دسترس نیست. پیش از ذخیره آن را حذف کنید.', + allCategories: 'همه دسته‌ها', + toolCategory: 'دسته ابزار', + selectedOnly: 'فقط انتخاب‌شده‌ها', + bulkActions: 'عملیات گروهی', + toolDetails: 'جزئیات', + hideToolDetails: 'پنهان کردن جزئیات', + outsideSelection: '{0} ابزار مجاز خارج از نمای فعلی هستند', + selectionSummary: 'فقط خواندن: {0} · نوشتن: {1} · در دسترس نیست: {2}', + unavailableSelected: '{0} ابزار مجاز در دسترس نیستند. پیش از ذخیره آن‌ها را حذف کنید.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: 'اجازه به {0} ابزار مطابق', + clearVisible: 'لغو اجازه {0} ابزار مطابق', + readOnlyVisible: 'حفظ فقط دسترسی خواندن برای {0} مورد مطابق', + bulkHelp: + 'عملیات گروهی بر ابزارهای مطابق در همه دسته‌ها، از جمله موارد پنهان، بدون توجه به «فقط انتخاب‌شده‌ها» اعمال می‌شود. ابزارهای جدید به مجوز جداگانه نیاز دارند.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'کلاینت‌ها باید از نشانی اتصال استفاده کنند.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: 'جستجو بر اساس نام، توضیح یا ID', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: 'برخی مجوزهای ابزار باید دوباره تأیید شوند.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: 'در پیکربندی، را با Token اعتبارنامه جایگزین کنید.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'همه وضعیت‌ها', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: 'بارگذاری دوباره، پیکربندی ذخیره‌نشده را جایگزین می‌کند. ادامه می‌دهید؟', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: 'از برچسب نسخه ثابت یا خلاصه SHA-256 استفاده کنید. برچسب latest پشتیبانی نمی‌شود.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: + 'پیش از نصب، برای وارد کردن تصویر روی گره اصلی دستور docker load -i را اجرا کنید.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: 'برای استفاده از نشانی پنل، خالی بگذارید. مسیر باید /mcp باشد.', + invalidUrl: 'یک نشانی HTTP/HTTPS با مسیر /mcp و بدون اطلاعات احراز هویت، پارامترهای پرس‌وجو یا قطعه وارد کنید.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: 'مهلت آماده‌سازی در مهلت کلی آزمون محاسبه نمی‌شود.', pointTimeoutHelp: diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 592b8b4c1..458fb9991 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1704,8 +1704,8 @@ const message = { }, mcp: { server: 'MCP サーバー', - baseUrl: '外部アクセスパス', - baseUrlHelper: '例: http://192.168.1.2:8000', + baseUrl: 'ベース URL', + baseUrlHelper: '例: http://192.168.1.2:8000(MCP パスを含まない)', ssePath: 'SSE パス', ssePathHelper: '例: /sse, 他のサーバーと重複しないように注意してください', environment: '環境変数', @@ -1716,13 +1716,13 @@ const message = { domain: 'デフォルトアクセスアドレス', domainHelper: '例: 192.168.1.1 または example.com', bindDomain: 'ウェブサイトをバインド', - commandPlaceHolder: '現在、npx およびバイナリスタートアップコマンドのみをサポートしています', + commandPlaceHolder: '現在、npx、uvx およびバイナリの起動コマンドのみをサポートしています', importMcpJson: 'MCP サーバー設定をインポート', importMcpJsonError: 'mcpServers 構造が正しくありません', bindDomainHelper: 'ウェブサイトをバインドした後、インストールされたすべての MCP サーバーのアクセスアドレスを変更し、ポートへの外部アクセスを閉じます', - outputTransport: '出力タイプ', - streamableHttpPath: 'ストリーミングパス', + outputTransport: '転送方式', + streamableHttpPath: 'Streamable HTTP パス', streamableHttpPathHelper: '例:/mcp、他のサーバーと重複しないように注意してください', npxHelper: 'npx またはバイナリで起動する mcp に適しています', uvxHelper: 'uvx で起動する mcp に適しています', @@ -1746,6 +1746,148 @@ const message = { modelType: 'モデルタイプ', }, }, + panelMCP: { + hideToken: 'Token を隠す', + revealTokenFailed: 'Token を取得できませんでした。もう一度お試しください。', + copyTokenFailed: 'Token をコピーできませんでした。もう一度お試しください。', + exportFailed: '接続ガイドをエクスポートできませんでした。もう一度お試しください。', + rotateTokenFailed: 'Token を再生成できませんでした。もう一度お試しください。', + rotateTokenUncertain: + '再生成の結果を確認できませんでした。認証情報を更新し、現在の Token を表示してから操作を続けてください。', + allowedIPs: '許可する IP / CIDR', + allowedIPsPlaceholder: '1 行に IP アドレスまたは CIDR を 1 件入力(最大 64 件)', + allowedIPsInvalid: + '有効な IP アドレスまたは CIDR を最大 64 件、各 64 文字以内で入力してください。接続元を制限する場合、サービスのリストは空にできません。', + accessPolicy: 'MCP 接続元ポリシー', + accessInherit: 'パネルのアクセスポリシーを継承', + accessRestricted: '指定した IP / CIDR のみ許可', + accessAll: 'すべての接続元を許可', + accessPolicyHelp: '接続元 IP を制御します。認証情報とユーザーの権限は変更されません。', + accessAllConfirm: 'すべての接続元 IP に MCP へのアクセスを許可しますか?', + revealToken: 'Token を表示', + copyToken: 'Token をコピー', + rotateToken: 'Token を再生成', + rotateTokenConfirm: 'この Token を再生成しますか?現在の Token は直ちに無効になります。', + tokenMemoryHelp: 'Token と接続ガイドを安全に保管してください。', + clientType: 'MCP クライアント', + genericClient: '汎用 MCP クライアント', + revealConfigHelp: 'Token を表示すると、クライアント設定を生成できます。', + downloadGuide: '接続ガイドをダウンロード(.md)', + saveSecret: 'Token を保存し、「I have saved the token」を選択してください。', + alreadyCreated: '認証情報を作成しました。接続の詳細で Token を確認できます。', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: + 'アクセスはノードとツールの認可およびアカウント権限で制限されます。新しいノードには個別の認可が必要です。', + nodeUnavailable: 'ノード {0} は利用できません。削除してから保存してください。', + allCategories: 'すべてのカテゴリ', + toolCategory: 'ツールカテゴリ', + selectedOnly: '選択済みのみ', + bulkActions: '一括操作', + toolDetails: '詳細', + hideToolDetails: '詳細を閉じる', + outsideSelection: '現在の表示範囲外に許可済みツールが {0} 件あります', + selectionSummary: '読み取り専用: {0} · 書き込み: {1} · 利用不可: {2}', + unavailableSelected: '許可済みツール {0} 件が利用できません。保存前に削除してください。', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: '一致する {0} 件を許可', + clearVisible: '一致する {0} 件の許可を解除', + readOnlyVisible: '一致する {0} 件を読み取り専用にする', + bulkHelp: + '一括操作は、非表示の項目を含む全カテゴリの一致するツールに適用され、「選択済みのみ」の設定には影響されません。新しいツールには個別の認可が必要です。', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'クライアントは接続 URL を使用してください。', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: '名前、説明、ID で検索', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: '一部のツールの認可を再確認してください。', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: '設定の を認証情報の Token に置き換えてください。', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'すべての状態', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: '再読み込みすると未保存の設定が置き換えられます。続行しますか?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: '固定のバージョンタグまたは SHA-256 ダイジェストを使用してください。latest は使用できません。', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: + 'インストール前に主ノードで docker load -i を実行してイメージをインポートしてください。', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: '空欄の場合はパネルの URL を使用します。パスは /mcp にしてください。', + invalidUrl: + '認証情報、クエリパラメーター、フラグメントを含まない、パスが /mcp の HTTP/HTTPS URL を入力してください。', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: '準備時間の上限です。テスト全体のタイムアウトには含みません。', pointTimeoutHelp: @@ -3678,8 +3820,6 @@ const message = { restoreCommunityConfirm: 'バージョン切り替え後は、現在の Enterprise スーパー管理者アカウントでコミュニティ版にログインできます。切り替え中に Enterprise 専用データが消去されるため、慎重に操作してください。', restoreCommunityStarting: 'コミュニティ版を準備しています。完了後、パネルは自動的に再起動します。', - technicalAdvice: '技術的な相談', - advice: '相談', levelUpPro: '商用版にアップグレード', licenseSync: 'ライセンス同期', knowMorePro: 'もっと詳しく知る', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 66d4a4644..5e671f91d 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1689,8 +1689,8 @@ const message = { }, mcp: { server: 'MCP 서버', - baseUrl: '외부 접근 경로', - baseUrlHelper: '예: http://192.168.1.2:8000', + baseUrl: '기본 URL', + baseUrlHelper: '예: http://192.168.1.2:8000 (MCP 경로 제외)', ssePath: 'SSE 경로', ssePathHelper: '예: /sse, 다른 서버와 중복되지 않도록 주의하세요', environment: '환경 변수', @@ -1701,13 +1701,13 @@ const message = { domain: '기본 접근 주소', domainHelper: '예: 192.168.1.1 또는 example.com', bindDomain: '웹사이트 바인딩', - commandPlaceHolder: '현재 npx 및 바이너리 시작 명령만 지원합니다', + commandPlaceHolder: '현재 npx, uvx 및 바이너리 시작 명령만 지원합니다', importMcpJson: 'MCP 서버 구성 가져오기', importMcpJsonError: 'mcpServers 구조가 올바르지 않습니다', bindDomainHelper: '웹사이트를 바인딩한 후, 설치된 모든 MCP 서버의 접근 주소를 수정하고 포트의 외부 접근을 닫습니다', - outputTransport: '출력 유형', - streamableHttpPath: '스트리밍 경로', + outputTransport: '전송 유형', + streamableHttpPath: 'Streamable HTTP 경로', streamableHttpPathHelper: '예: /mcp, 다른 서버와 중복되지 않도록 주의하세요', npxHelper: 'npx 또는 바이너리로 시작하는 mcp에 적합', uvxHelper: 'uvx로 시작하는 mcp에 적합', @@ -1729,6 +1729,145 @@ const message = { modelType: '모델 유형', }, }, + panelMCP: { + hideToken: 'Token 숨기기', + revealTokenFailed: 'Token을 가져오지 못했습니다. 다시 시도하세요.', + copyTokenFailed: 'Token을 복사하지 못했습니다. 다시 시도하세요.', + exportFailed: '연결 안내서를 내보내지 못했습니다. 다시 시도하세요.', + rotateTokenFailed: 'Token을 다시 생성하지 못했습니다. 다시 시도하세요.', + rotateTokenUncertain: + '재생성 결과를 확인할 수 없습니다. 자격 증명을 새로고침하고 현재 Token을 확인한 후 계속하세요.', + allowedIPs: '허용된 IP / CIDR', + allowedIPsPlaceholder: '한 줄에 IP 주소 또는 CIDR 하나씩 입력하세요. 최대 64개', + allowedIPsInvalid: + '유효한 IP 주소 또는 CIDR을 최대 64개, 각각 64자 이하로 입력하세요. 서비스 접속 원본을 제한할 때는 목록을 비울 수 없습니다.', + accessPolicy: 'MCP 접속 원본 정책', + accessInherit: '패널 접근 정책 상속', + accessRestricted: '지정된 IP / CIDR만 허용', + accessAll: '모든 접속 원본 허용', + accessPolicyHelp: '접속 IP를 제어하며 자격 증명 및 사용자 권한은 변경하지 않습니다.', + accessAllConfirm: '모든 원본 IP에서 MCP에 접근하도록 허용하시겠습니까?', + revealToken: 'Token 보기', + copyToken: 'Token 복사', + rotateToken: 'Token 다시 생성', + rotateTokenConfirm: 'Token을 다시 생성하시겠습니까? 기존 Token은 즉시 무효화됩니다.', + tokenMemoryHelp: 'Token과 연결 안내서를 안전하게 보관하세요.', + clientType: 'MCP 클라이언트', + genericClient: '일반 MCP 클라이언트', + revealConfigHelp: 'Token을 표시하면 클라이언트 구성을 생성할 수 있습니다.', + downloadGuide: '연결 안내 다운로드 (.md)', + saveSecret: 'Token을 저장하고 "I have saved the token"을 선택하세요.', + alreadyCreated: '자격 증명이 생성되었습니다. 연결 상세 정보에서 Token을 확인할 수 있습니다.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: '접근 범위는 노드 및 도구 승인과 계정 권한으로 제한됩니다. 새 노드는 별도 승인이 필요합니다.', + nodeUnavailable: '노드 {0}을 사용할 수 없습니다. 저장하기 전에 제거하세요.', + allCategories: '모든 카테고리', + toolCategory: '도구 카테고리', + selectedOnly: '선택한 항목만', + bulkActions: '일괄 작업', + toolDetails: '상세 정보', + hideToolDetails: '상세 정보 접기', + outsideSelection: '허용된 도구 {0}개가 현재 표시 범위 밖에 있습니다', + selectionSummary: '읽기 전용: {0} · 쓰기: {1} · 사용 불가: {2}', + unavailableSelected: '허용된 도구 {0}개를 사용할 수 없습니다. 저장하기 전에 제거하세요.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: '일치하는 도구 {0}개 허용', + clearVisible: '일치하는 도구 {0}개 권한 해제', + readOnlyVisible: '일치하는 도구 {0}개를 읽기 전용으로 설정', + bulkHelp: + '일괄 작업은 "선택한 항목만" 설정과 관계없이 숨겨진 항목을 포함하여 모든 범주의 일치하는 도구에 적용됩니다. 새 도구는 별도 승인이 필요합니다.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: '클라이언트는 연결 주소를 사용해야 합니다.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: '이름, 설명 또는 ID 검색', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: '일부 도구 권한을 다시 확인해야 합니다.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: '구성의 을 자격 증명 Token으로 바꾸세요.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: '모든 상태', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: '다시 불러오면 저장하지 않은 구성을 덮어씁니다. 계속하시겠습니까?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: '고정 버전 태그 또는 SHA-256 다이제스트를 사용하세요. latest 태그는 지원하지 않습니다.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: '설치하기 전에 주 노드에서 docker load -i 를 실행하여 이미지를 가져오세요.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: '비워 두면 패널 주소를 사용합니다. 경로는 /mcp여야 합니다.', + invalidUrl: '인증 정보, 쿼리 매개변수, 프래그먼트가 없고 경로가 /mcp인 HTTP/HTTPS 주소를 입력하세요.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: '준비 시간 제한이며 전체 시간 제한에서 제외됩니다.', pointTimeoutHelp: '측정점별로 시작, 워밍업 및 모든 요청 시간을 측정하며, 남은 전체 시간의 제한도 받습니다.', @@ -3612,8 +3751,6 @@ const message = { restoreCommunityConfirm: '버전 전환 후 현재 Enterprise 슈퍼 관리자 계정으로 커뮤니티 버전에 로그인할 수 있습니다. 전환 과정에서 Enterprise 전용 데이터가 삭제되므로 신중하게 진행하세요.', restoreCommunityStarting: '커뮤니티 버전을 준비 중입니다. 완료되면 패널이 자동으로 다시 시작됩니다.', - technicalAdvice: '기술 상담', - advice: '상담', levelUpPro: '상용 버전으로 업그레이드', licenseSync: '라이센스 동기화', knowMorePro: '더 알아보기', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index c22af1d5d..a587ec322 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -1188,7 +1188,6 @@ const message = { requestBody: 'ເນື້ອຫາຄຳຮ້ອງຂໍ', requestBodySize: 'ຂະໜາດ Request Body', requestBodyTruncated: 'ຖືກຕັດບາງສ່ວນ', - requestBodyHash: 'Hash ຂອງ Request Body', responseBody: 'ເນື້ອຫາຄຳຕອບ', responseBodySize: 'ຂະໜາດ Response Body', responseBodyTruncated: 'Response Body ຖືກຕັດບາງສ່ວນ', @@ -1681,8 +1680,8 @@ const message = { }, mcp: { server: 'MCP Server', - baseUrl: 'ເສັ້ນທາງການເຂົ້າເຖິງພາຍນອກ', - baseUrlHelper: 'ຕົວຢ່າງ: http://192.168.1.2:8000', + baseUrl: 'URL ພື້ນຖານ', + baseUrlHelper: 'ຕົວຢ່າງ: http://192.168.1.2:8000, ບໍ່ລວມເສັ້ນທາງ MCP', ssePath: 'ເສັ້ນທາງ SSE', ssePathHelper: 'ຕົວຢ່າງ: /sse, ລະວັງບໍ່ໃຫ້ຊ້ຳກັບ server ອື່ນ', environment: 'ຕົວປ່ຽນສະພາບແວດລ້ອມ', @@ -1693,13 +1692,13 @@ const message = { domain: 'ທີ່ຢູ່ເຂົ້າເຖິງເລີ່ມຕົ້ນ', domainHelper: 'ຕົວຢ່າງ: 192.168.1.1 ຫຼື example.com', bindDomain: 'ຜູກເວັບໄຊ', - commandPlaceHolder: 'ປັດຈຸບັນຮອງຮັບສະເພາະຄຳສັ່ງ npx ແລະ binary', + commandPlaceHolder: 'ປັດຈຸບັນຮອງຮັບສະເພາະຄຳສັ່ງ npx, uvx ແລະ binary', importMcpJson: 'ນຳເຂົ້າການກຳນົດຄ່າ MCP Server', importMcpJsonError: 'ໂຄງສ້າງ mcpServers ບໍ່ຖືກຕ້ອງ', bindDomainHelper: 'ຫຼັງຈາກຜູກເວັບໄຊແລ້ວ ມັນຈະປ່ຽນທີ່ຢູ່ເຂົ້າເຖິງຂອງ MCP Server ທັງໝົດ ແລະ ປິດການເຂົ້າເຖິງພອດຈາກພາຍນອກ', - outputTransport: 'ປະເພດ Output', - streamableHttpPath: 'ເສັ້ນທາງ Streaming', + outputTransport: 'ປະເພດການສົ່ງຂໍ້ມູນ', + streamableHttpPath: 'ເສັ້ນທາງ Streamable HTTP', streamableHttpPathHelper: 'ຕົວຢ່າງ: /mcp, ລະວັງບໍ່ໃຫ້ຊ້ຳກັບ Server ອື່ນ', npxHelper: 'ເໝາະສຳລັບ mcp ທີ່ເລີ່ມດ້ວຍ npx ຫຼື binary', uvxHelper: 'ເໝາະສຳລັບ mcp ທີ່ເລີ່ມດ້ວຍ uvx', @@ -1720,6 +1719,145 @@ const message = { modelType: 'ປະເພດໂມເດວ', }, }, + panelMCP: { + hideToken: 'ເຊື່ອງ Token', + revealTokenFailed: 'ບໍ່ສາມາດຮັບ Token ໄດ້. ກະລຸນາລອງໃໝ່.', + copyTokenFailed: 'ບໍ່ສາມາດຄັດລອກ Token ໄດ້. ກະລຸນາລອງໃໝ່.', + exportFailed: 'ບໍ່ສາມາດສົ່ງອອກຄູ່ມືເຊື່ອມຕໍ່ໄດ້. ກະລຸນາລອງໃໝ່.', + rotateTokenFailed: 'ບໍ່ສາມາດສ້າງ Token ໃໝ່ໄດ້. ກະລຸນາລອງໃໝ່.', + rotateTokenUncertain: + 'ບໍ່ສາມາດຢືນຢັນຜົນການສ້າງໃໝ່ໄດ້. ກະລຸນາໂຫຼດຂໍ້ມູນຢືນຢັນຄືນ ແລະ ເບິ່ງ Token ປັດຈຸບັນກ່ອນດຳເນີນການຕໍ່.', + allowedIPs: 'IP / CIDR ທີ່ອະນຸຍາດ', + allowedIPsPlaceholder: 'ໜຶ່ງ IP ຫຼື CIDR ຕໍ່ແຖວ; ສູງສຸດ 64 ລາຍການ', + allowedIPsInvalid: + 'ປ້ອນ IP ຫຼື CIDR ທີ່ຖືກຕ້ອງສູງສຸດ 64 ລາຍການ, ລາຍການລະບໍ່ເກີນ 64 ຕົວອັກສອນ. ເມື່ອຈຳກັດແຫຼ່ງເຂົ້າເຖິງ ລາຍການຂອງບໍລິການຕ້ອງບໍ່ຫວ່າງ.', + accessPolicy: 'ນະໂຍບາຍແຫຼ່ງເຂົ້າເຖິງ MCP', + accessInherit: 'ສືບທອດນະໂຍບາຍເຂົ້າເຖິງຂອງແຜງ', + accessRestricted: 'ອະນຸຍາດສະເພາະ IP / CIDR ໃນລາຍການ', + accessAll: 'ອະນຸຍາດທຸກແຫຼ່ງເຂົ້າເຖິງ', + accessPolicyHelp: 'ຄວບຄຸມ IP ຕົ້ນທາງ ໂດຍບໍ່ປ່ຽນສິດຂອງຂໍ້ມູນຢືນຢັນ ແລະ ຜູ້ໃຊ້.', + accessAllConfirm: 'ອະນຸຍາດໃຫ້ IP ຕົ້ນທາງທັງໝົດເຂົ້າເຖິງ MCP ຫຼືບໍ່?', + revealToken: 'ສະແດງ Token', + copyToken: 'ຄັດລອກ Token', + rotateToken: 'ສ້າງ Token ໃໝ່', + rotateTokenConfirm: 'ສ້າງ Token ໃໝ່ຫຼືບໍ່? Token ເກົ່າຈະໃຊ້ບໍ່ໄດ້ທັນທີ.', + tokenMemoryHelp: 'ເກັບ Token ແລະ ຄູ່ມືເຊື່ອມຕໍ່ໄວ້ຢ່າງປອດໄພ.', + clientType: 'ໄຄລເອັນ MCP', + genericClient: 'ໄຄລເອັນ MCP ທົ່ວໄປ', + revealConfigHelp: 'ສະແດງ Token ເພື່ອສ້າງການຕັ້ງຄ່າໄຄລເອັນ.', + downloadGuide: 'ດາວໂຫຼດຄູ່ມືເຊື່ອມຕໍ່ (.md)', + saveSecret: 'ບັນທຶກ Token ແລ້ວເລືອກ "I have saved the token".', + alreadyCreated: 'ສ້າງຂໍ້ມູນຢືນຢັນແລ້ວ. ເບິ່ງ Token ໄດ້ໃນລາຍລະອຽດການເຊື່ອມຕໍ່.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: 'ການເຂົ້າເຖິງຖືກຈຳກັດໂດຍສິດໂນດ, ສິດເຄື່ອງມື ແລະ ສິດບັນຊີ. ໂນດໃໝ່ຕ້ອງໄດ້ຮັບອະນຸຍາດແຍກຕ່າງຫາກ.', + nodeUnavailable: 'ໂນດ {0} ໃຊ້ງານບໍ່ໄດ້. ລຶບອອກກ່ອນບັນທຶກ.', + allCategories: 'ທຸກໝວດໝູ່', + toolCategory: 'ໝວດໝູ່ເຄື່ອງມື', + selectedOnly: 'ສະເພາະລາຍການທີ່ເລືອກ', + bulkActions: 'ດຳເນີນການເປັນກຸ່ມ', + toolDetails: 'ລາຍລະອຽດ', + hideToolDetails: 'ຊ່ອນລາຍລະອຽດ', + outsideSelection: 'ມີ {0} ເຄື່ອງມືທີ່ອະນຸຍາດຢູ່ນອກມຸມມອງປັດຈຸບັນ', + selectionSummary: 'ອ່ານຢ່າງດຽວ: {0} · ຂຽນ: {1} · ໃຊ້ບໍ່ໄດ້: {2}', + unavailableSelected: 'ມີ {0} ເຄື່ອງມືທີ່ອະນຸຍາດແຕ່ໃຊ້ບໍ່ໄດ້. ກະລຸນາລຶບກ່ອນບັນທຶກ.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: 'ອະນຸຍາດ {0} ເຄື່ອງມືທີ່ກົງກັນ', + clearVisible: 'ຖອນສິດ {0} ເຄື່ອງມືທີ່ກົງກັນ', + readOnlyVisible: 'ໃຫ້ {0} ລາຍການທີ່ກົງກັນມີສິດອ່ານຢ່າງດຽວ', + bulkHelp: + 'ການດຳເນີນການແບບກຸ່ມມີຜົນຕໍ່ເຄື່ອງມືທີ່ກົງກັນໃນທຸກໝວດໝູ່ ລວມທັງລາຍການທີ່ເຊື່ອງໄວ້ ໂດຍບໍ່ຂຶ້ນກັບ "ສະເພາະລາຍການທີ່ເລືອກ". ເຄື່ອງມືໃໝ່ຕ້ອງໄດ້ຮັບອະນຸຍາດແຍກຕ່າງຫາກ.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'ໄຄລເອັນຕ້ອງໃຊ້ທີ່ຢູ່ເຊື່ອມຕໍ່.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: 'ຄົ້ນຫາຕາມຊື່, ຄຳອະທິບາຍ ຫຼື ID', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: 'ຕ້ອງຢືນຢັນສິດເຄື່ອງມືບາງລາຍການອີກຄັ້ງ.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: 'ແທນທີ່ ໃນການຕັ້ງຄ່າດ້ວຍ Token ຂອງຂໍ້ມູນຢືນຢັນ.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'ທຸກສະຖານະ', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: 'ການໂຫຼດໃໝ່ຈະແທນທີ່ການຕັ້ງຄ່າທີ່ຍັງບໍ່ໄດ້ບັນທຶກ. ດຳເນີນຕໍ່ຫຼືບໍ່?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: 'ໃຊ້ແທັກເວີຊັນຄົງທີ່ ຫຼື ຄ່າ SHA-256. ບໍ່ຮອງຮັບແທັກ latest.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: 'ກ່ອນຕິດຕັ້ງ ໃຫ້ໃຊ້ຄຳສັ່ງ docker load -i ໃນໂນດຫຼັກເພື່ອນຳເຂົ້າອິມເມຈ.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: 'ປ່ອຍວ່າງເພື່ອໃຊ້ທີ່ຢູ່ແຜງ. ເສັ້ນທາງຕ້ອງເປັນ /mcp.', + invalidUrl: 'ປ້ອນທີ່ຢູ່ HTTP/HTTPS ທີ່ມີເສັ້ນທາງ /mcp ໂດຍບໍ່ມີຂໍ້ມູນຢືນຢັນ, ພາຣາມິເຕີຄິວຣີ ຫຼື ສ່ວນຍ່ອຍ.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: 'ເວລາຈຳກັດການກຽມການ ບໍ່ນັບໃນເວລາທັງຮອບ.', pointTimeoutHelp: 'ຈັບເວລາແຍກຕໍ່ຈຸດ ລວມການເລີ່ມ, ອຸ່ນເຄື່ອງ ແລະ ທຸກຄຳຮ້ອງຂໍ; ຈຳກັດດ້ວຍເວລາທັງຮອບທີ່ເຫຼືອ.', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index de2cbd4a4..63592bc6d 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1735,8 +1735,8 @@ const message = { }, mcp: { server: 'Pelayan MCP', - baseUrl: 'Laluan Akses Luar', - baseUrlHelper: 'Contoh: http://192.168.1.2:8000', + baseUrl: 'URL Asas', + baseUrlHelper: 'Contoh: http://192.168.1.2:8000, tanpa laluan MCP', ssePath: 'Laluan SSE', ssePathHelper: 'Contoh: /sse, berhati-hati jangan bertindan dengan pelayan lain', environment: 'Pemboleh Ubah Persekitaran', @@ -1747,13 +1747,13 @@ const message = { domain: 'Alamat Akses Lalai', domainHelper: 'Contoh: 192.168.1.1 atau example.com', bindDomain: 'Sematkan Laman Web', - commandPlaceHolder: 'Kini hanya menyokong perintah pelancaran npx dan binari', + commandPlaceHolder: 'Kini hanya menyokong perintah pelancaran npx, uvx dan binari', importMcpJson: 'Import Konfigurasi Pelayan MCP', importMcpJsonError: 'Struktur mcpServers tidak betul', bindDomainHelper: 'Setelah mengikat laman web, ia akan mengubah alamat akses semua Pelayan MCP yang dipasang dan menutup akses luaran ke pelabuhan', - outputTransport: 'Jenis Output', - streamableHttpPath: 'Laluan Streaming', + outputTransport: 'Jenis Pengangkutan', + streamableHttpPath: 'Laluan Streamable HTTP', streamableHttpPathHelper: 'Contoh: /mcp, elakkan daripada bertindan dengan pelayan lain', npxHelper: 'Sesuai untuk mcp yang dimulakan dengan npx atau binari', uvxHelper: 'Sesuai untuk mcp yang dimulakan dengan uvx', @@ -1776,6 +1776,146 @@ const message = { modelType: 'Jenis model', }, }, + panelMCP: { + hideToken: 'Sembunyikan Token', + revealTokenFailed: 'Token tidak dapat diperoleh. Sila cuba lagi.', + copyTokenFailed: 'Token tidak dapat disalin. Sila cuba lagi.', + exportFailed: 'Panduan sambungan tidak dapat dieksport. Sila cuba lagi.', + rotateTokenFailed: 'Token tidak dapat dijana semula. Sila cuba lagi.', + rotateTokenUncertain: + 'Hasil penjanaan semula tidak dapat disahkan. Muat semula kelayakan dan lihat Token semasanya sebelum meneruskan.', + allowedIPs: 'IP / CIDR yang dibenarkan', + allowedIPsPlaceholder: 'Satu alamat IP atau CIDR setiap baris; sehingga 64 entri', + allowedIPsInvalid: + 'Masukkan sehingga 64 alamat IP atau CIDR yang sah, setiap satu tidak melebihi 64 aksara. Senarai perkhidmatan tidak boleh kosong apabila sumber dihadkan.', + accessPolicy: 'Dasar sumber MCP', + accessInherit: 'Warisi dasar akses panel', + accessRestricted: 'Benarkan IP / CIDR tersenarai sahaja', + accessAll: 'Benarkan semua sumber', + accessPolicyHelp: 'Mengawal IP sumber tanpa mengubah kebenaran kelayakan atau pengguna.', + accessAllConfirm: 'Benarkan semua IP sumber mengakses MCP?', + revealToken: 'Tunjukkan Token', + copyToken: 'Salin Token', + rotateToken: 'Jana semula Token', + rotateTokenConfirm: 'Jana semula Token ini? Token semasa akan menjadi tidak sah serta-merta.', + tokenMemoryHelp: 'Simpan Token dan panduan sambungan dengan selamat.', + clientType: 'Klien MCP', + genericClient: 'Klien MCP umum', + revealConfigHelp: 'Paparkan Token untuk menjana konfigurasi klien.', + downloadGuide: 'Muat turun panduan sambungan (.md)', + saveSecret: 'Simpan Token dan pilih "I have saved the token".', + alreadyCreated: 'Kelayakan telah dicipta. Lihat Token dalam butiran sambungan.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: 'Akses dihadkan oleh kebenaran nod, alat dan akaun. Nod baharu memerlukan kebenaran berasingan.', + nodeUnavailable: 'Nod {0} tidak tersedia. Keluarkannya sebelum menyimpan.', + allCategories: 'Semua kategori', + toolCategory: 'Kategori alat', + selectedOnly: 'Yang dipilih sahaja', + bulkActions: 'Tindakan kelompok', + toolDetails: 'Butiran', + hideToolDetails: 'Sembunyikan butiran', + outsideSelection: '{0} alat yang dibenarkan berada di luar paparan semasa', + selectionSummary: 'Baca sahaja: {0} · Tulis: {1} · Tidak tersedia: {2}', + unavailableSelected: '{0} alat yang dibenarkan tidak tersedia. Buangnya sebelum menyimpan.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: 'Benarkan {0} alat yang sepadan', + clearVisible: 'Batalkan kebenaran {0} alat yang sepadan', + readOnlyVisible: 'Kekalkan akses baca sahaja untuk {0} padanan', + bulkHelp: + 'Tindakan pukal mempengaruhi alat yang sepadan dalam semua kategori, termasuk item tersembunyi, tanpa mengira "Yang dipilih sahaja". Alat baharu memerlukan kebenaran berasingan.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'Klien mesti menggunakan URL sambungan.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: 'Cari mengikut nama, penerangan atau ID', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: 'Sesetengah kebenaran alat perlu disahkan semula.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: 'Gantikan dalam konfigurasi dengan Token kelayakan.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'Semua status', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: 'Memuat semula akan menggantikan konfigurasi yang belum disimpan. Teruskan?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: 'Gunakan tag versi tetap atau cerna SHA-256. Tag latest tidak disokong.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: + 'Jalankan docker load -i pada nod utama untuk mengimport imej sebelum pemasangan.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: 'Biarkan kosong untuk menggunakan URL panel. Laluan mestilah /mcp.', + invalidUrl: 'Masukkan URL HTTP/HTTPS dengan laluan /mcp tanpa kelayakan, parameter pertanyaan atau fragmen.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: 'Had masa persediaan, tidak termasuk had masa keseluruhan.', pointTimeoutHelp: @@ -3761,8 +3901,6 @@ const message = { 'Selepas menukar versi, anda boleh log masuk ke Community Edition menggunakan akaun pentadbir super Enterprise semasa. Data khusus Enterprise akan dipadamkan semasa pertukaran. Sila teruskan dengan berhati-hati.', restoreCommunityStarting: 'Community Edition sedang disediakan. Panel akan dimulakan semula secara automatik apabila selesai.', - technicalAdvice: 'Konsultasi Teknikal', - advice: 'Konsultasi', levelUpPro: 'Naik taraf ke Edisi Komersial', licenseSync: 'Penyegerakan Lesen', knowMorePro: 'Ketahui Lebih Lanjut', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 7b57cf9d9..a5d158f5e 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1734,8 +1734,8 @@ const message = { }, mcp: { server: 'Servidor MCP', - baseUrl: 'Caminho de Acesso Externo', - baseUrlHelper: 'Por exemplo: http://192.168.1.2:8000', + baseUrl: 'URL Base', + baseUrlHelper: 'Por exemplo: http://192.168.1.2:8000, sem o caminho MCP', ssePath: 'Caminho SSE', ssePathHelper: 'Por exemplo: /sse, tome cuidado para não duplicar com outros servidores', environment: 'Variáveis de Ambiente', @@ -1746,13 +1746,13 @@ const message = { domain: 'Endereço de Acesso Padrão', domainHelper: 'Por exemplo: 192.168.1.1 ou example.com', bindDomain: 'Vincular Site', - commandPlaceHolder: 'Atualmente, apenas comandos de inicialização npx e binários são suportados', + commandPlaceHolder: 'Atualmente, apenas comandos de inicialização npx, uvx e binários são suportados', importMcpJson: 'Importar Configuração do Servidor MCP', importMcpJsonError: 'A estrutura mcpServers está incorreta', bindDomainHelper: 'Após vincular o site, ele modificará o endereço de acesso de todos os servidores MCP instalados e fechará o acesso externo às portas', - outputTransport: 'Tipo de Saída', - streamableHttpPath: 'Caminho de Streaming', + outputTransport: 'Tipo de Transporte', + streamableHttpPath: 'Caminho Streamable HTTP', streamableHttpPathHelper: 'Por exemplo: /mcp, certifique-se de que não se sobreponha a outros Servidores', npxHelper: 'Adequado para mcp iniciado com npx ou binário', uvxHelper: 'Adequado para mcp iniciado com uvx', @@ -1776,6 +1776,148 @@ const message = { modelType: 'Tipo de modelo', }, }, + panelMCP: { + hideToken: 'Ocultar Token', + revealTokenFailed: 'Não foi possível obter o Token. Tente novamente.', + copyTokenFailed: 'Não foi possível copiar o Token. Tente novamente.', + exportFailed: 'Não foi possível exportar o guia de conexão. Tente novamente.', + rotateTokenFailed: 'Não foi possível gerar um novo Token. Tente novamente.', + rotateTokenUncertain: + 'Não foi possível confirmar a geração. Atualize a credencial e veja seu Token atual antes de continuar.', + allowedIPs: 'IPs / CIDRs permitidos', + allowedIPsPlaceholder: 'Um endereço IP ou CIDR por linha; até 64 entradas', + allowedIPsInvalid: + 'Insira até 64 endereços IP ou CIDRs válidos, com até 64 caracteres cada. A lista do serviço não pode ficar vazia quando as origens são restritas.', + accessPolicy: 'Política de origem do MCP', + accessInherit: 'Herdar a política de acesso do painel', + accessRestricted: 'Permitir apenas os IPs / CIDRs listados', + accessAll: 'Permitir qualquer origem', + accessPolicyHelp: 'Controla os IPs de origem sem alterar as permissões das credenciais ou dos usuários.', + accessAllConfirm: 'Permitir que todos os IPs de origem acessem o MCP?', + revealToken: 'Mostrar Token', + copyToken: 'Copiar Token', + rotateToken: 'Gerar novo Token', + rotateTokenConfirm: 'Gerar este Token novamente? O Token atual será invalidado imediatamente.', + tokenMemoryHelp: 'Mantenha o Token e o guia de conexão seguros.', + clientType: 'Cliente MCP', + genericClient: 'Cliente MCP genérico', + revealConfigHelp: 'Exiba o Token para gerar a configuração do cliente.', + downloadGuide: 'Baixar guia de conexão (.md)', + saveSecret: 'Salve o Token e selecione “I have saved the token”.', + alreadyCreated: 'Credencial criada. Veja o Token nos detalhes de conexão.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: + 'O acesso depende dos nós e ferramentas autorizados e das permissões da conta. Novos nós exigem autorização separada.', + nodeUnavailable: 'O nó {0} está indisponível. Remova-o antes de salvar.', + allCategories: 'Todas as categorias', + toolCategory: 'Categoria de ferramentas', + selectedOnly: 'Somente selecionadas', + bulkActions: 'Ações em lote', + toolDetails: 'Detalhes', + hideToolDetails: 'Ocultar detalhes', + outsideSelection: '{0} ferramentas autorizadas estão fora da visualização atual', + selectionSummary: 'Somente leitura: {0} · Escrita: {1} · Indisponíveis: {2}', + unavailableSelected: '{0} ferramentas autorizadas estão indisponíveis. Remova-as antes de salvar.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: 'Autorizar {0} ferramentas correspondentes', + clearVisible: 'Revogar {0} ferramentas correspondentes', + readOnlyVisible: 'Manter somente leitura para {0} correspondências', + bulkHelp: + 'As ações em lote afetam as ferramentas correspondentes de todas as categorias, incluindo as ocultas, independentemente de “Somente selecionadas”. Novas ferramentas exigem autorização separada.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'Os clientes devem usar a URL de conexão.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: 'Buscar por nome, descrição ou ID', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: 'Algumas permissões de ferramentas precisam ser confirmadas novamente.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: 'Substitua na configuração pelo Token da credencial.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'Todos os status', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: 'A configuração não salva será substituída. Continuar?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: 'Use uma tag de versão fixa ou um resumo SHA-256. A tag latest não é aceita.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: + 'Execute docker load -i no nó principal para importar a imagem antes de instalar.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: 'Deixe em branco para usar a URL do painel. O caminho deve ser /mcp.', + invalidUrl: + 'Insira uma URL HTTP/HTTPS com o caminho /mcp, sem credenciais, parâmetros de consulta ou fragmento.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: 'Limite da preparação, excluído do tempo limite total.', pointTimeoutHelp: @@ -3767,8 +3909,6 @@ const message = { 'Após trocar de versão, você poderá entrar na Community Edition com a conta atual de superadministrador da Enterprise. Os dados exclusivos da Enterprise serão removidos durante a troca. Prossiga com cuidado.', restoreCommunityStarting: 'Preparando a Community Edition. O painel será reiniciado automaticamente quando estiver pronto.', - technicalAdvice: 'Consultoria técnica', - advice: 'Consultoria', levelUpPro: 'Fazer upgrade para a edição comercial', licenseSync: 'Sincronização de Licença', knowMorePro: 'Saiba mais', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 9805a0c10..22db387e7 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1724,8 +1724,8 @@ const message = { }, mcp: { server: 'Сервер MCP', - baseUrl: 'Внешний путь доступа', - baseUrlHelper: 'Например: http://192.168.1.2:8000', + baseUrl: 'Базовый URL', + baseUrlHelper: 'Например: http://192.168.1.2:8000, без пути MCP', ssePath: 'Путь SSE', ssePathHelper: 'Например: /sse, будьте осторожны, чтобы не дублировать с другими серверами', environment: 'Переменные среды', @@ -1736,13 +1736,13 @@ const message = { domain: 'Адрес доступа по умолчанию', domainHelper: 'Например: 192.168.1.1 или example.com', bindDomain: 'Привязать сайт', - commandPlaceHolder: 'В настоящее время поддерживаются только команды запуска npx и двоичных файлов', + commandPlaceHolder: 'В настоящее время поддерживаются только команды запуска npx, uvx и двоичных файлов', importMcpJson: 'Импортировать конфигурацию сервера MCP', importMcpJsonError: 'Структура mcpServers некорректна', bindDomainHelper: 'После привязки веб-сайта он изменит адрес доступа для всех установленных серверов MCP и закроет внешний доступ к портам', - outputTransport: 'Тип вывода', - streamableHttpPath: 'Путь потоковой передачи', + outputTransport: 'Тип транспорта', + streamableHttpPath: 'Путь Streamable HTTP', streamableHttpPathHelper: 'Например: /mcp, обратите внимание, чтобы не перекрывать другие серверы', npxHelper: 'Подходит для mcp, запущенного с помощью npx или бинарного файла', uvxHelper: 'Подходит для mcp, запущенного с помощью uvx', @@ -1765,6 +1765,148 @@ const message = { modelType: 'Тип модели', }, }, + panelMCP: { + hideToken: 'Скрыть Token', + revealTokenFailed: 'Не удалось получить Token. Повторите попытку.', + copyTokenFailed: 'Не удалось скопировать Token. Повторите попытку.', + exportFailed: 'Не удалось экспортировать инструкцию по подключению. Повторите попытку.', + rotateTokenFailed: 'Не удалось создать новый Token. Повторите попытку.', + rotateTokenUncertain: + 'Не удалось подтвердить результат создания. Обновите учётные данные и проверьте текущий Token, прежде чем продолжать.', + allowedIPs: 'Разрешённые IP / CIDR', + allowedIPsPlaceholder: 'Один IP-адрес или CIDR в строке; не более 64 записей', + allowedIPsInvalid: + 'Введите не более 64 корректных IP-адресов или CIDR, до 64 символов каждый. При ограничении источников список сервиса не может быть пустым.', + accessPolicy: 'Политика источников MCP', + accessInherit: 'Наследовать политику доступа панели', + accessRestricted: 'Разрешить только указанные IP / CIDR', + accessAll: 'Разрешить любые источники', + accessPolicyHelp: + 'Управляет доступом по IP-адресу источника без изменения прав учётных данных и пользователей.', + accessAllConfirm: 'Разрешить всем исходным IP-адресам доступ к MCP?', + revealToken: 'Показать Token', + copyToken: 'Копировать Token', + rotateToken: 'Создать новый Token', + rotateTokenConfirm: 'Создать новый Token? Текущий Token немедленно станет недействительным.', + tokenMemoryHelp: 'Храните Token и инструкцию по подключению в безопасности.', + clientType: 'Клиент MCP', + genericClient: 'Универсальный клиент MCP', + revealConfigHelp: 'Покажите Token, чтобы создать конфигурацию клиента.', + downloadGuide: 'Скачать инструкцию подключения (.md)', + saveSecret: 'Сохраните Token и отметьте «I have saved the token».', + alreadyCreated: 'Учётные данные созданы. Token доступен в сведениях о подключении.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: + 'Доступ ограничен разрешениями узлов, инструментов и аккаунта. Новые узлы требуют отдельного разрешения.', + nodeUnavailable: 'Узел {0} недоступен. Удалите его перед сохранением.', + allCategories: 'Все категории', + toolCategory: 'Категория инструментов', + selectedOnly: 'Только выбранные', + bulkActions: 'Пакетные действия', + toolDetails: 'Подробности', + hideToolDetails: 'Скрыть подробности', + outsideSelection: '{0} разрешённых инструментов вне текущего представления', + selectionSummary: 'Только чтение: {0} · Запись: {1} · Недоступно: {2}', + unavailableSelected: 'Недоступно {0} разрешённых инструментов. Удалите их перед сохранением.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: 'Разрешить {0} совпадающих инструментов', + clearVisible: 'Отозвать доступ к {0} совпадающим инструментам', + readOnlyVisible: 'Оставить только чтение для {0} совпадений', + bulkHelp: + 'Массовые действия применяются к подходящим инструментам во всех категориях, включая скрытые, независимо от параметра «Только выбранные». Новые инструменты требуют отдельного разрешения.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'Клиенты должны использовать адрес подключения.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: 'Поиск по имени, описанию или ID', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: 'Некоторые разрешения инструментов нужно подтвердить повторно.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: 'Замените в конфигурации на Token учётных данных.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'Все статусы', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: 'Повторная загрузка заменит несохранённую конфигурацию. Продолжить?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: 'Используйте фиксированный тег версии или дайджест SHA-256. Тег latest не поддерживается.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: + 'Перед установкой выполните docker load -i на основном узле для импорта образа.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: 'Оставьте пустым, чтобы использовать адрес панели. Путь должен быть /mcp.', + invalidUrl: 'Укажите HTTP/HTTPS-адрес с путём /mcp без учётных данных, параметров запроса и фрагмента.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: 'Лимит подготовки, не входит в общий тайм-аут.', pointTimeoutHelp: @@ -3740,8 +3882,6 @@ const message = { 'После смены версии можно войти в Community Edition с текущей учётной записью суперадминистратора Enterprise. Во время переключения данные, доступные только в Enterprise, будут удалены. Будьте осторожны.', restoreCommunityStarting: 'Подготавливается Community Edition. После завершения панель автоматически перезапустится.', - technicalAdvice: 'Техническая консультация', - advice: 'Консультация', levelUpPro: 'Обновить до коммерческой версии', licenseSync: 'Синхронизация лицензии', knowMorePro: 'Узнать больше', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index f55c8c95c..d669c93ee 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1731,8 +1731,8 @@ const message = { }, mcp: { server: 'MCP Sunucusu', - baseUrl: 'Harici Erişim Yolu', - baseUrlHelper: 'Örneğin: http://192.168.1.2:8000', + baseUrl: 'Temel URL', + baseUrlHelper: 'Örneğin: http://192.168.1.2:8000, MCP yolu olmadan', ssePath: 'SSE Yolu', ssePathHelper: 'Örneğin: /sse, diğer sunucularla çoğaltmamaya dikkat edin', environment: 'Ortam Değişkenleri', @@ -1743,13 +1743,13 @@ const message = { domain: 'Varsayılan Erişim Adresi', domainHelper: 'Örneğin: 192.168.1.1 veya example.com', bindDomain: 'Web Sitesi Bağla', - commandPlaceHolder: 'Şu anda yalnızca npx ve ikili başlatma komutları desteklenir', + commandPlaceHolder: 'Şu anda yalnızca npx, uvx ve ikili başlatma komutları desteklenir', importMcpJson: 'MCP Sunucu Yapılandırmasını İçe Aktar', importMcpJsonError: 'mcpServers yapısı yanlış', bindDomainHelper: 'Web sitesini bağladıktan sonra, kurulu tüm MCP Sunucularının erişim adresini değiştirecek ve portlara harici erişimi kapatacaktır', - outputTransport: 'Çıktı Türü', - streamableHttpPath: 'Akış Yolu', + outputTransport: 'Aktarım Türü', + streamableHttpPath: 'Streamable HTTP Yolu', streamableHttpPathHelper: 'Örneğin: /mcp, diğer Sunucularla çakışmaması gerektiğine dikkat edin', npxHelper: 'npx veya ikili dosya ile başlatılan mcp için uygundur', uvxHelper: 'uvx ile başlatılan mcp için uygundur', @@ -1773,6 +1773,148 @@ const message = { modelType: 'Model türü', }, }, + panelMCP: { + hideToken: 'Token değerini gizle', + revealTokenFailed: 'Token alınamadı. Lütfen tekrar deneyin.', + copyTokenFailed: 'Token kopyalanamadı. Lütfen tekrar deneyin.', + exportFailed: 'Bağlantı kılavuzu dışa aktarılamadı. Lütfen tekrar deneyin.', + rotateTokenFailed: 'Token yeniden oluşturulamadı. Lütfen tekrar deneyin.', + rotateTokenUncertain: + 'Yeniden oluşturma sonucu doğrulanamadı. Devam etmeden önce kimlik bilgisini yenileyin ve geçerli Token değerini görüntüleyin.', + allowedIPs: 'İzin verilen IP / CIDR adresleri', + allowedIPsPlaceholder: 'Her satıra bir IP adresi veya CIDR; en fazla 64 kayıt', + allowedIPsInvalid: + 'Her biri en fazla 64 karakter olan, en fazla 64 geçerli IP adresi veya CIDR girin. Kaynaklar kısıtlandığında hizmet listesi boş bırakılamaz.', + accessPolicy: 'MCP kaynak politikası', + accessInherit: 'Panel erişim politikasını devral', + accessRestricted: 'Yalnızca listedeki IP / CIDR adreslerine izin ver', + accessAll: 'Tüm kaynaklara izin ver', + accessPolicyHelp: 'Kaynak IP erişimini yönetir; kimlik bilgisi ve kullanıcı izinlerini değiştirmez.', + accessAllConfirm: 'Tüm kaynak IP adreslerinin MCP erişimine izin verilsin mi?', + revealToken: 'Token göster', + copyToken: 'Token kopyala', + rotateToken: 'Token yeniden oluştur', + rotateTokenConfirm: 'Bu Token yeniden oluşturulsun mu? Mevcut Token hemen geçersiz olur.', + tokenMemoryHelp: 'Token ve bağlantı kılavuzunu güvenli saklayın.', + clientType: 'MCP istemcisi', + genericClient: 'Genel MCP istemcisi', + revealConfigHelp: 'İstemci yapılandırmasını oluşturmak için Token değerini gösterin.', + downloadGuide: 'Bağlantı kılavuzunu indir (.md)', + saveSecret: 'Token değerini kaydedin ve "I have saved the token" seçeneğini işaretleyin.', + alreadyCreated: 'Kimlik bilgisi oluşturuldu. Token değerini bağlantı ayrıntılarında görüntüleyin.', + nodes: 'Allowed nodes', + primaryNode: 'Primary node', + targetNode: 'Target node ID', + selectNodes: 'Select at least one node.', + nodeGrantHelp: + 'Erişim, düğüm ve araç yetkileri ile hesap izinleriyle sınırlıdır. Yeni düğümler ayrıca yetkilendirilmelidir.', + nodeUnavailable: 'Düğüm {0} kullanılamıyor. Kaydetmeden önce kaldırın.', + allCategories: 'Tüm kategoriler', + toolCategory: 'Araç kategorisi', + selectedOnly: 'Yalnızca seçilenler', + bulkActions: 'Toplu işlemler', + toolDetails: 'Ayrıntılar', + hideToolDetails: 'Ayrıntıları gizle', + outsideSelection: 'İzin verilen {0} araç geçerli görünümün dışında', + selectionSummary: 'Salt okunur: {0} · Yazma: {1} · Kullanılamayan: {2}', + unavailableSelected: 'İzin verilen {0} araç kullanılamıyor. Kaydetmeden önce kaldırın.', + filteredTools: 'Matching: {0}', + toolFilter: 'Tool type', + allTools: 'All types', + selectVisible: 'Eşleşen {0} araca izin ver', + clearVisible: 'Eşleşen {0} aracın iznini kaldır', + readOnlyVisible: 'Eşleşen {0} araç için yalnızca okuma iznini koru', + bulkHelp: + 'Toplu işlemler, "Yalnızca seçilenler" seçeneğinden bağımsız olarak gizli öğeler dahil tüm kategorilerdeki eşleşen araçları etkiler. Yeni araçlar ayrıca yetkilendirilmelidir.', + groupSelected: '{0} / {1} granted', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + endpointUnavailable: 'Connection URL unavailable. Refresh or contact an administrator.', + internalEndpoint: 'Internal listening address', + internalEndpointHelp: 'İstemciler bağlantı adresini kullanmalıdır.', + category: { + system: 'System', + website: 'Websites', + certificate: 'Certificates', + app: 'Applications', + database: 'Databases', + }, + + title: '1Panel MCP', + credentials: 'Access credentials', + create: 'Create credential', + tools: 'Allowed tools', + searchTools: 'Ada, açıklamaya veya ID değerine göre ara', + selectedTools: 'Selected: {0} · Available: {1}', + noMatchingTools: 'No matching tools', + noTools: 'No tools granted', + readOnly: 'Read only', + write: 'Write', + writeHelp: 'Write tools modify server resources. Grant access as required.', + catalogUnavailable: 'Tool catalog unavailable. Check service status and refresh.', + catalogEmpty: 'No tools available for authorization.', + catalogChanged: 'Tool permissions have changed. Reopen the editor and review authorization.', + grantsNeedReview: 'Bazı araç yetkilerinin yeniden onaylanması gerekir.', + acceptToolPolicy: 'I have reviewed and confirmed the current tool authorization.', + removeGrant: 'Remove grant', + connection: 'Connection details', + endpoint: 'Connection URL', + clientConfig: 'Client configuration', + connectionHelp: 'Yapılandırmadaki değerini kimlik bilgisinin Token değeriyle değiştirin.', + tokenSaved: 'I have saved the token', + uncertain: + 'Creation has not been confirmed. Retry in this window or check the credential list before creating another.', + revokeConfirm: 'Revoke credential “{0}”? This cannot be undone. New calls will be rejected.', + disableConfirm: 'Disable credential “{0}”? New calls will be rejected.', + enableConfirm: 'Enable credential “{0}” with the current authorization?', + conflict: 'The data has changed. Reload the latest values before submitting.', + invocations: 'Call records', + requestId: 'Request ID', + duration: 'Duration (ms)', + resultCode: 'Result code', + resultSuccess: 'Succeeded', + resultRunning: 'In progress / outcome unconfirmed', + resultDenied: 'Denied', + resultFailed: 'Failed', + allResults: 'Tüm durumlar', + serviceStatus: 'Service status', + statusUnknown: 'Unknown', + serviceUnsupported: 'This environment does not support 1Panel MCP deployment.', + serviceUnhealthy: 'Service health checks failed. Check logs and version compatibility.', + statusUnsupported: 'Unsupported environment', + statusNotInstalled: 'Not installed', + statusStopped: 'Stopped', + statusRunning: 'Running', + statusUnhealthy: 'Unhealthy', + statusIncompatible: 'Incompatible version', + ready: 'Service ready', + notReady: 'Service not ready', + installTitle: 'Install 1Panel MCP', + installEnable: 'Install and enable', + installEmpty: '1Panel MCP is not installed.', + upgradeTitle: 'Upgrade service', + serviceConfiguration: 'Service configuration', + imageUpgrade: 'Image upgrade', + reloadInstallConfirm: 'Yeniden yükleme, kaydedilmemiş yapılandırmayı değiştirir. Devam edilsin mi?', + serviceOperateConfirm: 'Confirm {0} for the 1Panel MCP service?', + serviceAdminOnly: 'Only super administrators can manage the MCP service.', + dockerImage: 'Docker image', + dockerImageHelp: 'Sabit bir sürüm etiketi veya SHA-256 özeti kullanın. latest etiketi desteklenmez.', + dockerImageInvalid: 'Use 1panel/1panel-mcp-server with a valid fixed version or SHA-256 digest.', + dockerOfflineHelp: + 'Kurulumdan önce imajı içe aktarmak için birincil düğümde docker load -i komutunu çalıştırın.', + offlineInstall: 'Offline installation', + boundWebsiteHelp: 'Manage the bound website from the website list. Delete it to disable the proxy.', + containerName: 'Container name', + imageId: 'Image ID', + externalUrlHelp: 'Panel adresini kullanmak için boş bırakın. Yol /mcp olmalıdır.', + invalidUrl: + 'Kimlik bilgileri, sorgu parametreleri veya parça içermeyen, /mcp yoluna sahip bir HTTP/HTTPS adresi girin.', + listenPort: 'Local listening port', + listenPortInvalid: 'Enter an integer port from 1024 to 65535.', + logEmpty: 'No service logs', + statusUnavailable: 'Service status unavailable. Refresh to retry.', + }, benchmarkPlan: { prepareTimeoutHelp: 'Hazırlık süresi sınırıdır; toplam zaman aşımına dahil değildir.', pointTimeoutHelp: diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 500f004f9..4e459b6e1 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1632,8 +1632,8 @@ const message = { }, mcp: { server: 'MCP Server', - baseUrl: '外部存取路徑', - baseUrlHelper: '例:http://192.168.1.1:8000', + baseUrl: '外部基礎位址', + baseUrlHelper: '例:http://192.168.1.1:8000,不含 MCP 路徑', ssePath: 'SSE 路徑', ssePathHelper: '例:/sse,注意不要與其他 Server 重複', environment: '環境變數', @@ -1644,12 +1644,12 @@ const message = { domain: '預設存取地址', domainHelper: '例:192.168.1.1 或 example.com', bindDomain: '綁定網站', - commandPlaceHolder: '僅支援 npx 和 二進制啟動的指令', + commandPlaceHolder: '支援 npx、uvx 和二進位程式的啟動指令', importMcpJson: '匯入 MCP Server 設定', importMcpJsonError: 'mcpServers 結構不正確', bindDomainHelper: '綁定網站之後會修改所有已安裝 MCP Server 的存取地址,並關閉埠的外部存取', - outputTransport: '輸出類型', - streamableHttpPath: '流式傳輸路徑', + outputTransport: '傳輸類型', + streamableHttpPath: 'Streamable HTTP 路徑', streamableHttpPathHelper: '例:/mcp, 注意不要與其他 Server 重複', npxHelper: '適合 npx 或 二進制啟動的 mcp', uvxHelper: '適合 uvx 啟動的 mcp', @@ -1670,6 +1670,140 @@ const message = { modelType: '模型類型', }, }, + panelMCP: { + hideToken: '隱藏 Token', + revealTokenFailed: '取得 Token 失敗,請重試。', + copyTokenFailed: '複製 Token 失敗,請重試。', + exportFailed: '匯出連線說明失敗,請重試。', + rotateTokenFailed: '重新產生 Token 失敗,請重試。', + rotateTokenUncertain: '無法確認重新產生的結果,請重新整理憑證並檢視目前的 Token 後再操作。', + allowedIPs: '允許的 IP / CIDR', + allowedIPsPlaceholder: '每行一個 IP 位址或 CIDR,最多 64 筆', + allowedIPsInvalid: '請輸入最多 64 筆有效 IP 位址或 CIDR,每筆不超過 64 個字元;服務限定來源時,清單不得為空。', + accessPolicy: 'MCP 來源策略', + accessInherit: '繼承面板存取策略', + accessRestricted: '僅允許指定 IP / CIDR', + accessAll: '允許所有來源', + accessPolicyHelp: '控制來源 IP,不變更憑證和使用者權限。', + accessAllConfirm: '確認允許所有來源 IP 存取 MCP?', + revealToken: '檢視 Token', + copyToken: '複製 Token', + rotateToken: '重新產生 Token', + rotateTokenConfirm: '確認重新產生 Token?舊 Token 將立即失效。', + tokenMemoryHelp: '請妥善保管 Token 和連線說明。', + clientType: 'MCP 用戶端', + genericClient: '通用 MCP 用戶端', + revealConfigHelp: '查看 Token 後可產生用戶端設定。', + downloadGuide: '下載連線說明(.md)', + saveSecret: '請儲存 Token,並勾選「已儲存 Token」。', + alreadyCreated: '憑證已建立,可在連線詳細資訊中查看 Token。', + nodes: '節點授權', + primaryNode: '主節點', + targetNode: '目標節點 ID', + selectNodes: '請至少選擇一個節點。', + nodeGrantHelp: '呼叫範圍受節點、工具及帳號權限限制;新增節點須個別授權。', + nodeUnavailable: '節點 {0} 無法使用,請移除後儲存。', + allCategories: '全部分類', + toolCategory: '工具分類', + selectedOnly: '僅看已選', + bulkActions: '批次操作', + toolDetails: '詳情', + hideToolDetails: '收起詳情', + outsideSelection: '目前顯示範圍外仍有 {0} 項已授權', + selectionSummary: '唯讀 {0} 項 · 寫入 {1} 項 · 無法使用 {2} 項', + unavailableSelected: '有 {0} 項授權無法使用,請移除後儲存。', + filteredTools: '符合 {0} 項', + toolFilter: '工具類型', + allTools: '全部類型', + selectVisible: '授權符合的 {0} 項', + clearVisible: '取消符合的 {0} 項授權', + readOnlyVisible: '符合的 {0} 項僅保留唯讀授權', + bulkHelp: '批次操作影響所有分類的符合工具,包含未顯示項目,不受「僅看已選」影響;新增工具須個別授權。', + groupSelected: '已授權 {0} / {1} 項', + expandGroup: '展開', + collapseGroup: '收起', + endpointUnavailable: '連線位址無法使用,請重新整理或聯絡管理員。', + internalEndpoint: '內部監聽位址', + internalEndpointHelp: '用戶端請使用連線位址。', + category: { + system: '系統', + website: '網站', + certificate: '憑證', + app: '應用程式', + database: '資料庫', + }, + title: '1Panel MCP', + credentials: '存取憑證', + create: '建立憑證', + tools: '工具授權', + searchTools: '搜尋名稱、說明或 ID', + selectedTools: '已選 {0} 項 · 可用 {1} 項', + noMatchingTools: '暫無符合工具', + noTools: '未授權工具', + readOnly: '唯讀', + write: '寫入', + writeHelp: '寫入工具可修改伺服器資源,請依需要授權。', + catalogUnavailable: '工具清單無法使用,請檢查服務狀態後重新整理。', + catalogEmpty: '暫無可授權工具。', + catalogChanged: '工具權限定義已更新,請重新開啟編輯視窗並確認授權。', + grantsNeedReview: '部分工具授權須重新確認。', + acceptToolPolicy: '我已核對並確認目前工具授權。', + removeGrant: '移除授權', + connection: '連線詳情', + endpoint: '連線位址', + clientConfig: '用戶端設定', + connectionHelp: '將設定中的 替換為憑證 Token。', + tokenSaved: '已儲存 Token', + uncertain: '建立結果尚未確認。請在目前視窗重試,或先檢查憑證清單,避免重複建立。', + revokeConfirm: '確認撤銷憑證「{0}」?此操作無法復原,新的呼叫將被拒絕。', + disableConfirm: '確認停用憑證「{0}」?停用後將拒絕新的呼叫。', + enableConfirm: '確認啟用憑證「{0}」並套用目前授權?', + conflict: '資料已更新,請重新載入最新內容後提交。', + invocations: '呼叫紀錄', + requestId: '請求 ID', + duration: '耗時(毫秒)', + resultCode: '結果代碼', + resultSuccess: '成功', + resultRunning: '執行中 / 結果待確認', + resultDenied: '已拒絕', + resultFailed: '失敗', + allResults: '全部狀態', + serviceStatus: '服務狀態', + statusUnknown: '未知', + serviceUnsupported: '目前環境不支援部署 1Panel MCP。', + serviceUnhealthy: '服務健康檢查失敗,請檢查日誌及版本相容性。', + statusUnsupported: '環境不支援', + statusNotInstalled: '未安裝', + statusStopped: '已停止', + statusRunning: '執行中', + statusUnhealthy: '服務異常', + statusIncompatible: '版本不相容', + ready: '服務就緒', + notReady: '服務未就緒', + installTitle: '安裝 1Panel MCP', + installEnable: '安裝並啟用', + installEmpty: '未安裝 1Panel MCP 服務。', + upgradeTitle: '升級服務', + serviceConfiguration: '服務設定', + imageUpgrade: '映像升級', + reloadInstallConfirm: '重新載入將覆寫尚未儲存的設定,是否繼續?', + serviceOperateConfirm: '確認{0} 1Panel MCP 服務?', + serviceAdminOnly: '僅超級管理員可管理 MCP 服務。', + dockerImage: 'Docker 映像', + dockerImageHelp: '使用固定版本標籤或 SHA-256 摘要,不支援 latest。', + dockerImageInvalid: '映像須為 1panel/1panel-mcp-server 的有效固定版本或 SHA-256 摘要。', + dockerOfflineHelp: '在主節點執行 docker load -i 匯入映像後安裝。', + offlineInstall: '離線安裝', + boundWebsiteHelp: '在網站清單管理綁定的網站;刪除網站可停用代理。', + containerName: '容器名稱', + imageId: '映像 ID', + externalUrlHelp: '留空使用面板位址,路徑須為 /mcp。', + invalidUrl: '請輸入路徑為 /mcp 的 HTTP/HTTPS 位址,不含認證資訊、查詢參數或片段。', + listenPort: '本機監聽連接埠', + listenPortInvalid: '請輸入 1024~65535 的整數連接埠。', + logEmpty: '暫無服務日誌', + statusUnavailable: '無法取得服務狀態,請重新整理後重試。', + }, benchmarkPlan: { prepareTimeoutHelp: '準備階段的時間上限,不計入整輪逾時。', pointTimeoutHelp: '每個測點獨立計時,含啟動、預熱和全部請求,受整輪剩餘時間限制。', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index d7dec6126..d67cea269 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1646,8 +1646,8 @@ const message = { }, mcp: { server: 'MCP Server', - baseUrl: '外部访问路径', - baseUrlHelper: '例如:http://192.168.1.1:8000', + baseUrl: '外部基础地址', + baseUrlHelper: '例如:http://192.168.1.1:8000,不含 MCP 路径', ssePath: 'SSE 路径', ssePathHelper: '例如:/sse,注意不要与其他 Server 重复', environment: '环境变量', @@ -1658,12 +1658,12 @@ const message = { domain: '默认访问地址', domainHelper: '例如:192.168.1.1 或 example.com', bindDomain: '绑定网站', - commandPlaceHolder: '当前仅支持 npx 和 二进制启动的命令', + commandPlaceHolder: '当前支持 npx、uvx 和二进制启动的命令', importMcpJson: '导入 MCP Server 配置', importMcpJsonError: 'mcpServers 结构不正确', bindDomainHelper: '绑定网站之后会修改所有已安装 MCP Server 的访问地址,并关闭端口的外部访问', - outputTransport: '输出类型', - streamableHttpPath: '流式传输路径', + outputTransport: '传输类型', + streamableHttpPath: 'Streamable HTTP 路径', streamableHttpPathHelper: '例如:/mcp, 注意不要与其他 Server 重复', npxHelper: '适合 npx 或 二进制启动的 mcp', uvxHelper: '适合 uvx 启动的 mcp', @@ -1684,6 +1684,135 @@ const message = { modelType: '模型类型', }, }, + panelMCP: { + hideToken: '隐藏 Token', + revealTokenFailed: '获取 Token 失败,请重试。', + copyTokenFailed: '复制 Token 失败,请重试。', + exportFailed: '导出接入说明失败,请重试。', + rotateTokenFailed: '重新生成 Token 失败,请重试。', + rotateTokenUncertain: '未能确认重新生成结果,请刷新凭据并查看当前 Token 后再操作。', + allowedIPs: '允许的 IP / CIDR', + allowedIPsPlaceholder: '每行一个 IP 地址或 CIDR,最多 64 条', + allowedIPsInvalid: '请输入最多 64 条有效 IP 地址或 CIDR,每条不超过 64 个字符;服务限定来源时列表不能为空。', + accessPolicy: 'MCP 来源策略', + accessInherit: '继承面板访问策略', + accessRestricted: '仅允许指定 IP / CIDR', + accessAll: '允许所有来源', + accessPolicyHelp: '控制来源 IP,不改变凭据和用户权限。', + accessAllConfirm: '确认允许所有来源 IP 访问 MCP?', + revealToken: '查看 Token', + copyToken: '复制 Token', + rotateToken: '重新生成 Token', + rotateTokenConfirm: '确认重新生成 Token?旧 Token 将立即失效。', + tokenMemoryHelp: '请妥善保管 Token 和接入说明。', + clientType: 'MCP 客户端', + genericClient: '通用 MCP 客户端', + revealConfigHelp: '查看 Token 后可生成客户端配置。', + downloadGuide: '下载接入说明(.md)', + saveSecret: '请保存 Token,并勾选“已保存 Token”。', + alreadyCreated: '凭据已创建,可在连接详情中查看 Token。', + nodes: '节点授权', + primaryNode: '主节点', + targetNode: '目标节点 ID', + selectNodes: '请至少选择一个节点。', + nodeGrantHelp: '调用范围受节点、工具及账号权限限制;新增节点需单独授权。', + nodeUnavailable: '节点 {0} 不可用,请移除后保存。', + allCategories: '全部分类', + toolCategory: '工具分类', + selectedOnly: '仅看已选', + bulkActions: '批量操作', + toolDetails: '详情', + hideToolDetails: '收起详情', + outsideSelection: '当前显示范围外仍有 {0} 项已授权', + selectionSummary: '只读 {0} 项 · 写入 {1} 项 · 不可用 {2} 项', + unavailableSelected: '有 {0} 项授权不可用,请移除后保存。', + filteredTools: '匹配 {0} 项', + toolFilter: '工具类型', + allTools: '全部类型', + selectVisible: '授权匹配的 {0} 项', + clearVisible: '取消匹配的 {0} 项授权', + readOnlyVisible: '匹配的 {0} 项仅保留只读授权', + bulkHelp: '批量操作影响所有分类的匹配工具,包含未显示项,不受“仅看已选”影响;新增工具需单独授权。', + groupSelected: '已授权 {0} / {1} 项', + expandGroup: '展开', + collapseGroup: '收起', + endpointUnavailable: '接入地址不可用,请刷新或联系管理员。', + internalEndpoint: '内部监听地址', + internalEndpointHelp: '客户端请使用接入地址。', + category: { system: '系统', website: '网站', certificate: '证书', app: '应用', database: '数据库' }, + + title: '1Panel MCP', + credentials: '访问凭据', + create: '创建凭据', + tools: '工具授权', + searchTools: '搜索名称、说明或 ID', + selectedTools: '已选 {0} 项 · 可用 {1} 项', + noMatchingTools: '暂无匹配工具', + noTools: '未授权工具', + readOnly: '只读', + write: '写入', + writeHelp: '写入工具可修改服务器资源,请按需授权。', + catalogUnavailable: '工具列表不可用,请检查服务状态后刷新。', + catalogEmpty: '暂无可授权工具。', + catalogChanged: '工具权限定义已更新,请重新打开编辑窗口并确认授权。', + grantsNeedReview: '部分工具授权需重新确认。', + acceptToolPolicy: '我已核对并确认当前工具授权。', + removeGrant: '移除授权', + connection: '连接详情', + endpoint: '接入地址', + clientConfig: '客户端配置', + connectionHelp: '将配置中的 替换为凭据 Token。', + tokenSaved: '已保存 Token', + uncertain: '创建结果尚未确认。请在当前窗口重试,或先检查凭据列表,避免重复创建。', + revokeConfirm: '确认撤销凭据“{0}”?该操作不可恢复,新的调用将被拒绝。', + disableConfirm: '确认停用凭据“{0}”?停用后将拒绝新的调用。', + enableConfirm: '确认启用凭据“{0}”并应用当前授权?', + conflict: '数据已更新,请重新加载最新内容后提交。', + invocations: '调用记录', + requestId: '请求 ID', + duration: '耗时(毫秒)', + resultCode: '结果代码', + resultSuccess: '成功', + resultRunning: '执行中 / 结果待确认', + resultDenied: '已拒绝', + resultFailed: '失败', + allResults: '全部状态', + serviceStatus: '服务状态', + statusUnknown: '未知', + serviceUnsupported: '当前环境不支持部署 1Panel MCP。', + serviceUnhealthy: '服务健康检查失败,请检查日志及版本兼容性。', + statusUnsupported: '环境不支持', + statusNotInstalled: '未安装', + statusStopped: '已停止', + statusRunning: '运行中', + statusUnhealthy: '服务异常', + statusIncompatible: '版本不兼容', + ready: '服务就绪', + notReady: '服务未就绪', + installTitle: '安装 1Panel MCP', + installEnable: '安装并启用', + installEmpty: '未安装 1Panel MCP 服务。', + upgradeTitle: '升级服务', + serviceConfiguration: '服务配置', + imageUpgrade: '镜像升级', + reloadInstallConfirm: '重新加载将覆盖未保存的配置,是否继续?', + serviceOperateConfirm: '确认{0} 1Panel MCP 服务?', + serviceAdminOnly: '仅超级管理员可管理 MCP 服务。', + dockerImage: 'Docker 镜像', + dockerImageHelp: '使用固定版本标签或 SHA-256 摘要,不支持 latest。', + dockerImageInvalid: '镜像须为 1panel/1panel-mcp-server 的有效固定版本或 SHA-256 摘要。', + dockerOfflineHelp: '在主节点执行 docker load -i 导入镜像后安装。', + offlineInstall: '离线安装', + boundWebsiteHelp: '在网站列表管理绑定的网站;删除网站可取消代理。', + containerName: '容器名称', + imageId: '镜像 ID', + externalUrlHelp: '留空使用面板地址,路径须为 /mcp。', + invalidUrl: '请输入路径为 /mcp 的 HTTP/HTTPS 地址,不含认证信息、查询参数或片段。', + listenPort: '本地监听端口', + listenPortInvalid: '请输入 1024~65535 的整数端口。', + logEmpty: '暂无服务日志', + statusUnavailable: '无法获取服务状态,请刷新重试。', + }, benchmarkPlan: { prepareTimeoutHelp: '准备阶段的时间上限,不计入整轮超时。', pointTimeoutHelp: '每个测点独立计时,含启动、预热和全部请求,受整轮剩余时间限制。', diff --git a/frontend/src/routers/modules/ai.ts b/frontend/src/routers/modules/ai.ts index 5077c1f6d..e545d9e43 100644 --- a/frontend/src/routers/modules/ai.ts +++ b/frontend/src/routers/modules/ai.ts @@ -1,4 +1,5 @@ import { Layout } from '@/routers/constant'; +import { GlobalStore } from '@/store'; const aiRouter = { sort: 4, @@ -56,13 +57,35 @@ const aiRouter = { { path: '/ai/mcp', name: 'MCPServer', - component: () => import('@/views/ai/mcp/server/index.vue'), + redirect: () => (GlobalStore().hasPermission('ai_mcp_view') ? '/ai/mcp/server' : '/ai/mcp/1panel'), meta: { icon: 'p-mcp-menu', title: 'menu.mcp', + permission: ['ai_mcp_view', 'mcp_access_view'], + }, + }, + { + path: '/ai/mcp/server', + name: 'MCPServerList', + hidden: true, + component: () => import('@/views/ai/mcp/server/index.vue'), + meta: { + title: 'menu.mcp', + activeMenu: '/ai/mcp', permission: 'ai_mcp_view', }, }, + { + path: '/ai/mcp/1panel', + name: 'PanelMCP', + hidden: true, + component: () => import('@/views/ai/mcp/panel/index.vue'), + meta: { + title: 'panelMCP.title', + activeMenu: '/ai/mcp', + permission: 'mcp_access_view', + }, + }, { path: '/ai/gpu/current', name: 'GPU', diff --git a/frontend/src/utils/panel-mcp.ts b/frontend/src/utils/panel-mcp.ts new file mode 100644 index 000000000..8e2a5afca --- /dev/null +++ b/frontend/src/utils/panel-mcp.ts @@ -0,0 +1,273 @@ +import type { PanelMCP } from '@/api/interface/panel-mcp'; +import { isAxiosError } from 'axios'; + +export const DEFAULT_MCP_DOCKER_IMAGE = '1panel/1panel-mcp-server:v0.0.1'; +const releaseVersionPattern = + 'v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?'; +export const isMCPDockerImage = (image: string) => + image.length <= 180 && + new RegExp(`^1panel/1panel-mcp-server(?::${releaseVersionPattern}|@sha256:[a-f0-9]{64})$`).test(image); + +export const copyMCPSettings = (settings: PanelMCP.Settings): PanelMCP.Settings => ({ + runtimeType: settings.runtimeType, + dockerImage: settings.dockerImage, + enabled: settings.enabled, + listenPort: settings.listenPort, + externalUrl: settings.externalUrl, + revision: settings.revision, + accessPolicy: settings.accessPolicy, + allowedIPs: [...settings.allowedIPs], +}); + +export const hasMCPSettingsChanges = (settings: PanelMCP.Settings, saved: PanelMCP.Settings) => + (['listenPort', 'externalUrl', 'accessPolicy'] as const).some((field) => settings[field] !== saved[field]) || + JSON.stringify(settings.allowedIPs) !== JSON.stringify(saved.allowedIPs); + +export const getMCPCredentialStatus = ( + item: Pick, + now = Date.now(), +): PanelMCP.CredentialStatus => { + if (item.status === 'Revoked') return 'Revoked'; + if (item.expiresAt && (!Number.isFinite(Date.parse(item.expiresAt)) || Date.parse(item.expiresAt) <= now)) { + return 'Expired'; + } + return item.status; +}; + +export const buildMCPClientConfig = ( + endpoint: string, + token = '', + client: PanelMCP.Client = 'generic', + credentialId = '', +) => { + if (!endpoint) return ''; + const name = credentialId ? `1panel_${credentialId.replaceAll('-', '')}` : '1panel'; + if (client === 'codex') { + return `[mcp_servers.${JSON.stringify(name)}]\nurl = ${JSON.stringify(endpoint)}\nhttp_headers = { Authorization = ${JSON.stringify(`Bearer ${token}`)} }\n`; + } + return JSON.stringify( + { + mcpServers: { + [name]: { type: 'http', url: endpoint, headers: { Authorization: `Bearer ${token}` } }, + }, + }, + null, + 2, + ); +}; + +export const parseMCPAllowedIPs = (value: string) => [ + ...new Set( + value + .split(/[\s,]+/) + .map((item) => item.trim()) + .filter(Boolean), + ), +]; + +export const isMCPAllowedIPList = (values: string[]) => + values.length <= 64 && + values.every((value) => { + if (!value || value.length > 64 || value.includes('%')) return false; + const [ip, prefix, extra] = value.split('/'); + if (extra !== undefined) return false; + const v6 = ip.includes(':'); + if (prefix !== undefined && (!/^(0|[1-9]\d*)$/.test(prefix) || Number(prefix) > (v6 ? 128 : 32))) return false; + if (!v6) { + const parts = ip.split('.'); + return parts.length === 4 && parts.every((part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255); + } + if (!/^[0-9a-fA-F:.]+$/.test(ip)) return false; + try { + return new URL(`http://[${ip}]`).hostname.startsWith('['); + } catch { + return false; + } + }); + +export type MCPToolFilter = 'all' | 'read' | 'write'; +export const filterMCPTools = (tools: PanelMCP.Tool[], search: string, mode: MCPToolFilter) => { + const keyword = search.trim().toLocaleLowerCase(); + return tools.filter( + (tool) => + (mode === 'all' || tool.readOnly === (mode === 'read')) && + [tool.toolId, tool.name, tool.description].some((value) => value.toLocaleLowerCase().includes(keyword)), + ); +}; + +export const groupMCPTools = (tools: PanelMCP.Tool[], filtered: PanelMCP.Tool[], selected: string[]) => { + const visibleIds = new Set(filtered.map((tool) => tool.toolId)); + const selectedIds = new Set(selected); + const groups = new Map< + string, + { id: string; name: string; order: number; total: number; selected: number; tools: PanelMCP.Tool[] } + >(); + for (const tool of tools) { + let group = groups.get(tool.categoryId); + if (!group) { + group = { + id: tool.categoryId, + name: tool.categoryName, + order: tool.categoryOrder, + total: 0, + selected: 0, + tools: [], + }; + groups.set(tool.categoryId, group); + } + group.total++; + if (selectedIds.has(tool.toolId)) group.selected++; + if (visibleIds.has(tool.toolId)) group.tools.push(tool); + } + return [...groups.values()] + .filter((group) => group.tools.length) + .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); +}; + +export const changeVisibleMCPGrants = ( + selected: string[], + visible: PanelMCP.Tool[], + action: 'select' | 'clear' | 'readOnly', +) => { + const result = new Set(selected); + for (const tool of visible) { + if (action === 'select' || (action === 'readOnly' && tool.readOnly)) result.add(tool.toolId); + else result.delete(tool.toolId); + } + return [...result]; +}; + +export const isMCPRevisionConflict = (error: unknown) => { + const candidate = error as { + code?: number; + errorCode?: string; + response?: { status?: number; data?: { code?: number; errorCode?: string } }; + }; + return ( + candidate?.code === 409 || + candidate?.errorCode === 'MCP_CONFLICT' || + candidate?.response?.data?.code === 409 || + candidate?.response?.data?.errorCode === 'MCP_CONFLICT' || + candidate?.response?.status === 409 + ); +}; + +export const isMCPPolicyChanged = (error: unknown) => { + const candidate = error as { errorCode?: string; response?: { data?: { errorCode?: string } } }; + return ( + candidate?.errorCode === 'MCP_POLICY_CHANGED' || candidate?.response?.data?.errorCode === 'MCP_POLICY_CHANGED' + ); +}; + +export const isMCPMutationUncertain = (error: unknown) => { + const isServerError = (code: unknown) => typeof code === 'number' && code >= 500 && code < 600; + if (isAxiosError(error)) return !error.response || isServerError(error.response.status); + return isServerError((error as { code?: unknown })?.code); +}; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isMCPNodeID = (id: unknown): id is string => + typeof id === 'string' && + (id === 'local' || (/^[1-9][0-9]{0,19}$/.test(id) && BigInt(id) <= 18446744073709551615n)); + +export const isUsableMCPToolNodes = (tools: PanelMCP.Tool[], mapping: unknown): mapping is Record => + isObject(mapping) && + Object.keys(mapping).length === tools.length && + new Set(tools.map((tool) => tool.toolId)).size === tools.length && + tools.every((tool) => { + const nodes = mapping[tool.toolId]; + return ( + Object.hasOwn(mapping, tool.toolId) && + Array.isArray(nodes) && + nodes.every(isMCPNodeID) && + new Set(nodes).size === nodes.length + ); + }); + +export const filterMCPToolsByNodes = ( + tools: PanelMCP.Tool[], + snapshot: Record, + nodeIds: string[], + current = snapshot, +) => + tools.filter((tool) => + nodeIds.some((id) => snapshot[tool.toolId]?.includes(id) && current[tool.toolId]?.includes(id)), + ); + +const isToolOperation = (operation: PanelMCP.ToolOperation) => + isObject(operation) && + typeof operation.id === 'string' && + !!operation.id && + typeof operation.method === 'string' && + !!operation.method && + typeof operation.path === 'string' && + operation.path.startsWith('/') && + !/[?#\s]/.test(operation.path) && + isObject(operation.bodySchema) && + Array.isArray(operation.responseFields) && + operation.responseFields.every((field) => typeof field === 'string'); + +export const isUsableMCPCatalog = (catalog: PanelMCP.Catalog) => + catalog?.contractVersion === 5 && + catalog.scope === 'nodes' && + Array.isArray(catalog.tools) && + catalog.tools.every( + (tool) => + isObject(tool) && + typeof tool.toolId === 'string' && + !!tool.toolId && + typeof tool.name === 'string' && + typeof tool.description === 'string' && + typeof tool.categoryId === 'string' && + !!tool.categoryId.trim() && + typeof tool.categoryName === 'string' && + !!tool.categoryName.trim() && + Number.isInteger(tool.categoryOrder) && + tool.categoryOrder >= 0 && + typeof tool.policyHash === 'string' && + !!tool.policyHash && + Array.isArray(tool.operations) && + tool.operations.length > 0 && + tool.operations.every(isToolOperation) && + new Set(tool.operations.map((operation) => operation.id)).size === tool.operations.length && + typeof tool.readOnly === 'boolean', + ) && + new Set(catalog.tools.map((tool) => tool.toolId)).size === catalog.tools.length && + isUsableMCPToolNodes(catalog.tools, catalog.toolNodeIds); + +export const getMCPStaleTools = (credential: PanelMCP.Credential, tools: PanelMCP.Tool[]) => + credential.tools.filter((id) => { + const current = tools.find((tool) => tool.toolId === id); + if ( + !current?.policyHash || + !Array.isArray(credential.staleTools) || + credential.staleTools.some((tool) => typeof tool !== 'string') || + !isObject(credential.toolReadOnly) || + !Object.hasOwn(credential.toolReadOnly, id) + ) + return true; + const reviewedReadOnly = credential.toolReadOnly[id]; + // Core returns the effective contract for compatible grants. Bind that + // decision to this catalog so responses from different upgrades cannot mix. + return ( + credential.staleTools.includes(id) || + credential.toolPolicies?.[id] !== current.policyHash || + typeof reviewedReadOnly !== 'boolean' || + reviewedReadOnly !== current.readOnly + ); + }); + +export const isUsableMCPNodes = (nodes: unknown): nodes is PanelMCP.Node[] => + Array.isArray(nodes) && + nodes.every( + (node) => + node && + isMCPNodeID(node.id) && + typeof node.name === 'string' && + node.name.length > 0 && + typeof node.status === 'string' && + node.isLocal === (node.id === 'local'), + ) && + new Set(nodes.map((node) => node.id)).size === nodes.length; diff --git a/frontend/src/views/ai/mcp/index.vue b/frontend/src/views/ai/mcp/index.vue index e30c0d498..7a14025ec 100644 --- a/frontend/src/views/ai/mcp/index.vue +++ b/frontend/src/views/ai/mcp/index.vue @@ -1,19 +1,21 @@ diff --git a/frontend/src/views/ai/mcp/panel/credential-editor.vue b/frontend/src/views/ai/mcp/panel/credential-editor.vue new file mode 100644 index 000000000..084d7acce --- /dev/null +++ b/frontend/src/views/ai/mcp/panel/credential-editor.vue @@ -0,0 +1,729 @@ + + + + + diff --git a/frontend/src/views/ai/mcp/panel/index.vue b/frontend/src/views/ai/mcp/panel/index.vue new file mode 100644 index 000000000..3d447f25d --- /dev/null +++ b/frontend/src/views/ai/mcp/panel/index.vue @@ -0,0 +1,861 @@ + + + + + diff --git a/frontend/src/views/ai/mcp/panel/invocations.vue b/frontend/src/views/ai/mcp/panel/invocations.vue new file mode 100644 index 000000000..09d4bda37 --- /dev/null +++ b/frontend/src/views/ai/mcp/panel/invocations.vue @@ -0,0 +1,159 @@ + + + diff --git a/frontend/src/views/ai/mcp/panel/service.vue b/frontend/src/views/ai/mcp/panel/service.vue new file mode 100644 index 000000000..860ecb810 --- /dev/null +++ b/frontend/src/views/ai/mcp/panel/service.vue @@ -0,0 +1,885 @@ + + + + + diff --git a/frontend/src/views/ai/mcp/server/bind/index.vue b/frontend/src/views/ai/mcp/server/bind/index.vue index 904e3f5fa..f8eadbe39 100644 --- a/frontend/src/views/ai/mcp/server/bind/index.vue +++ b/frontend/src/views/ai/mcp/server/bind/index.vue @@ -4,25 +4,23 @@ - - {{ $t('aiTools.proxy.proxyHelper4') }} - - - {{ $t('aiTools.proxy.proxyHelper6') }} + + {{ $t('panelMCP.boundWebsiteHelp') }} {{ $t('firewall.quickJump') }} - {{ $t('aiTools.mcp.bindDomainHelper') }} + + {{ $t('aiTools.mcp.bindDomainHelper') }} + - + {{ $t('aiTools.proxy.whiteListHelper') }} @@ -77,12 +75,14 @@ diff --git a/frontend/src/views/ai/mcp/server/config/index.vue b/frontend/src/views/ai/mcp/server/config/index.vue index a9d75c5c0..d8f7821d4 100644 --- a/frontend/src/views/ai/mcp/server/config/index.vue +++ b/frontend/src/views/ai/mcp/server/config/index.vue @@ -1,22 +1,26 @@ diff --git a/frontend/src/views/ai/mcp/server/import/index.vue b/frontend/src/views/ai/mcp/server/import/index.vue index bf834d27a..6122c3e6c 100644 --- a/frontend/src/views/ai/mcp/server/import/index.vue +++ b/frontend/src/views/ai/mcp/server/import/index.vue @@ -3,11 +3,12 @@ -
+
{{ $t('commons.button.confirm') }} - +
diff --git a/frontend/src/views/ai/mcp/server/operate/index.vue b/frontend/src/views/ai/mcp/server/operate/index.vue index 95bbf4bc1..864fdebfe 100644 --- a/frontend/src/views/ai/mcp/server/operate/index.vue +++ b/frontend/src/views/ai/mcp/server/operate/index.vue @@ -27,7 +27,7 @@ - + {{ $t('aiTools.mcp.' + mcpServer.type + 'Helper') }} @@ -53,7 +53,7 @@ - + {{ $t('aiTools.mcp.baseUrlHelper') }} @@ -65,7 +65,7 @@ - + {{ $t('aiTools.mcp.ssePathHelper') }} @@ -75,7 +75,7 @@ v-if="mcpServer.outputTransport === 'streamableHttp'" > - + {{ $t('aiTools.mcp.streamableHttpPathHelper') }} @@ -85,7 +85,7 @@ prop="protocolVersion" > - + {{ $t('aiTools.mcp.protocolVersionHelper') }} @@ -97,7 +97,7 @@ :maxlength="4096" placeholder='--header "x-user-id: 123" --logLevel debug' /> - + {{ $t('aiTools.mcp.gatewayArgsHelper') }} @@ -107,7 +107,10 @@ - +
@@ -137,12 +140,12 @@