diff --git a/cmd/climc/shell/compute/cloudaccounts.go b/cmd/climc/shell/compute/cloudaccounts.go index 33b8cfa429..0465c5620a 100644 --- a/cmd/climc/shell/compute/cloudaccounts.go +++ b/cmd/climc/shell/compute/cloudaccounts.go @@ -24,7 +24,7 @@ func init() { cmd := shell.NewResourceCmd(&modules.Cloudaccounts).WithKeyword("cloud-account") cmd.List(&options.CloudaccountListOptions{}) - cmd.Show(&options.SCloudAccountIdOptions{}) + cmd.Show(&options.CloudaccountShowOptions{}) cmd.Delete(&options.SCloudAccountIdOptions{}) cmd.Update(&options.SCloudAccountUpdateBaseOptions{}) // cmd.PerformClassWithKeyword("preparenets-vmware", "prepare-nets", &options.SVMwareCloudAccountPrepareNetsOptions{}) diff --git a/cmd/climc/shell/compute/cloudregions.go b/cmd/climc/shell/compute/cloudregions.go index 564bdc4021..96c5e2d4e9 100644 --- a/cmd/climc/shell/compute/cloudregions.go +++ b/cmd/climc/shell/compute/cloudregions.go @@ -34,16 +34,18 @@ func init() { cmd.Perform("purge", &compute.CloudregionPurgeOptions{}) type CloudregionListOptions struct { + _ struct{} `mcp-desc:"【创建流程中的中间步骤】本工具不能完成创建。指定云厂商时必须传 provider,例如创建阿里云:provider=[\"Aliyun\"]。创建虚拟机时必须 usable=true(MCP 默认注入)。查完后继续 capability/镜像/sku,最后 climc_server_create。严禁只查区域后停止"` + options.BaseListOptions - Usable *bool `help:"List regions where networks are usable"` - UsableVpc *bool `help:"List regions where VPC are usable"` - Service string `help:"List regions which service has available skus" choices:"dbinstances|servers|elasticcaches"` + Usable *bool `help:"只列网络可用的区域;创建虚拟机时必须为 true(MCP 默认注入 usable=true)" mcp:"true"` + UsableVpc *bool `help:"List regions where VPC are usable" mcp:"true"` + Service string `help:"List regions which service has available skus" choices:"dbinstances|servers|elasticcaches" mcp:"true"` ReadOnly *bool `help:"List regions with read only account"` - City string `help:"List regions in the specified city"` + City string `help:"List regions in the specified city" mcp:"true"` - Capability []string `help:"capability filter" choices:"project|compute|network|loadbalancer|objectstore|rds|cache|event"` + Capability []string `help:"capability filter" choices:"project|compute|network|loadbalancer|objectstore|rds|cache|event" mcp:"true"` DistinctField string `help:"list the specified distinct field, e.g. city, region"` @@ -224,8 +226,10 @@ func init() { }) type CloudregionCapabiltyOptions struct { - ID string `help:"ID or name of cloud region to check" json:"-"` - Domain string `help:"cloud region domain"` + _ struct{} `mcp-desc:"【创建流程中的中间步骤】查询区域能力,尤其是可用磁盘存储类型(storage_types2 / system_storage_types)。公有云创建前必须调用:ID 用 climc_cloud_region_list 返回的 id。从返回中选系统盘 backend(如 cloud_essd、cloud_ssd),写入 disk 的 backend=。查完继续镜像/网络/sku,最后 climc_server_create"` + + ID string `help:"ID or name of cloud region to check;必须用 cloud-region-list 的 id" json:"-" mcp:"required"` + Domain string `help:"cloud region domain" mcp:"true"` ShowEmulated bool `help:"show emulated cloud region"` } diff --git a/cmd/climc/shell/compute/common.go b/cmd/climc/shell/compute/common.go index 77bfc7409e..fc74d91e80 100644 --- a/cmd/climc/shell/compute/common.go +++ b/cmd/climc/shell/compute/common.go @@ -24,7 +24,7 @@ var ( R = shell.R printList = shell.PrintList printObject = shell.PrintObject - printBatchResults = printutils.PrintJSONBatchResults + printBatchResults = shell.PrintBatchResults InvalidUpdateError = shell.InvalidUpdateError printObjectRecursive = printutils.PrintJSONObjectRecursive diff --git a/cmd/climc/shell/compute/dbinstances.go b/cmd/climc/shell/compute/dbinstances.go index 26f3b832f4..f66996355f 100644 --- a/cmd/climc/shell/compute/dbinstances.go +++ b/cmd/climc/shell/compute/dbinstances.go @@ -29,7 +29,7 @@ func init() { cmd.List(&compute.DBInstanceListOptions{}) cmd.Create(&compute.DBInstanceCreateOptions{}) cmd.Update(&compute.DBInstanceUpdateOptions{}) - cmd.Show(&compute.DBInstanceIdOptions{}) + cmd.Show(&compute.DBInstanceShowOptions{}) cmd.Delete(&compute.DBInstanceDeleteOptions{}) cmd.Perform("renew", &compute.DBInstanceRenewOptions{}) cmd.Perform("change-config", &compute.DBInstanceChangeConfigOptions{}) diff --git a/cmd/climc/shell/compute/disks.go b/cmd/climc/shell/compute/disks.go index 3235341dda..aca064c394 100644 --- a/cmd/climc/shell/compute/disks.go +++ b/cmd/climc/shell/compute/disks.go @@ -42,7 +42,7 @@ import ( func init() { cmd := shell.NewResourceCmd(&modules.Disks) cmd.List(&compute_options.DiskListOptions{}) - cmd.Show(&compute_options.DiskIdOptions{}) + cmd.Show(&compute_options.DiskShowOptions{}) cmd.Perform("public", &compute_options.DiskIdOptions{}) cmd.Perform("private", &compute_options.DiskIdOptions{}) cmd.Perform("syncstatus", &compute_options.DiskIdOptions{}) @@ -55,13 +55,7 @@ func init() { cmd.Perform("change-billing-type", new(compute_options.DiskChangeBillingTypeOptions)) cmd.Perform("change-storage-type", &compute_options.DiskChangeStorageTypeOptions{}) - type DiskDeleteOptions struct { - ID []string `help:"ID of disks to delete" metavar:"DISK"` - OverridePendingDelete bool `help:"Delete disk directly instead of pending delete" short-token:"f"` - DeleteSnapshots bool `help:"Delete disk snapshots before delete disk"` - } - - R(&DiskDeleteOptions{}, "disk-delete", "Delete a disk", func(s *mcclient.ClientSession, args *DiskDeleteOptions) error { + R(&compute_options.DiskDeleteOptions{}, "disk-delete", "Delete a disk", func(s *mcclient.ClientSession, args *compute_options.DiskDeleteOptions) error { params := jsonutils.NewDict() if args.OverridePendingDelete { params.Add(jsonutils.JSONTrue, "override_pending_delete") @@ -173,11 +167,7 @@ func init() { return nil }) - type DiskResizeOptions struct { - DISK string `help:"ID or name of disk"` - SIZE string `help:"Size of disk"` - } - R(&DiskResizeOptions{}, "disk-resize", "Resize a disk", func(s *mcclient.ClientSession, args *DiskResizeOptions) error { + R(&compute_options.DiskResizeOptions{}, "disk-resize", "Resize a disk", func(s *mcclient.ClientSession, args *compute_options.DiskResizeOptions) error { params := jsonutils.NewDict() params.Add(jsonutils.NewString(args.SIZE), "size") disk, err := modules.Disks.PerformAction(s, args.DISK, "resize", params) diff --git a/cmd/climc/shell/compute/elasticcache.go b/cmd/climc/shell/compute/elasticcache.go index f06230650c..23e64a24a5 100644 --- a/cmd/climc/shell/compute/elasticcache.go +++ b/cmd/climc/shell/compute/elasticcache.go @@ -27,7 +27,7 @@ import ( func init() { cmd := shell.NewResourceCmd(&modules.ElasticCache).WithKeyword("elastic-cache") cmd.List(&compute.ElasticCacheListOptions{}) - cmd.Show(&compute.ElasticCacheIdOption{}) + cmd.Show(&compute.ElasticCacheShowOptions{}) cmd.Create(&compute.ElasticCacheCreateOptions{}) cmd.Delete(&compute.ElasticCacheIdOption{}) cmd.Perform("restart", &compute.ElasticCacheIdOption{}) diff --git a/cmd/climc/shell/compute/elasticips.go b/cmd/climc/shell/compute/elasticips.go index 1b92fe46ec..1257f6201c 100644 --- a/cmd/climc/shell/compute/elasticips.go +++ b/cmd/climc/shell/compute/elasticips.go @@ -27,7 +27,7 @@ func init() { cmd.Create(&compute.EipCreateOptions{}) cmd.Delete(&options.BaseIdOptions{}) cmd.Update(&compute.EipUpdateOptions{}) - cmd.Show(&options.BaseShowOptions{}) + cmd.Show(&compute.EipShowOptions{}) cmd.Perform("purge", &options.BaseIdOptions{}) cmd.Perform("associate", &compute.EipAssociateOptions{}) cmd.Perform("dissociate", &compute.EipDissociateOptions{}) diff --git a/cmd/climc/shell/compute/hosts.go b/cmd/climc/shell/compute/hosts.go index 44ce1aa037..9d278951fb 100644 --- a/cmd/climc/shell/compute/hosts.go +++ b/cmd/climc/shell/compute/hosts.go @@ -36,7 +36,7 @@ import ( func init() { cmd := shell.NewResourceCmd(&modules.Hosts) - cmd.List(&compute.HostListOptions{}) + cmd.List(&compute.HostListForMcpOptions{}) cmd.GetMetadata(&options.BaseIdOptions{}) cmd.GetProperty(&compute.HostStatusStatisticsOptions{}) cmd.Update(&compute.HostUpdateOptions{}) diff --git a/cmd/climc/shell/compute/kafka.go b/cmd/climc/shell/compute/kafka.go index 3418b21ef4..e789b0fd50 100644 --- a/cmd/climc/shell/compute/kafka.go +++ b/cmd/climc/shell/compute/kafka.go @@ -25,7 +25,7 @@ func init() { cmd := shell.NewResourceCmd(&modules.Kafkas) cmd.List(&compute.KafkaListOptions{}) cmd.Update(&compute.KafkaUpdateOptions{}) - cmd.Show(&compute.KafkaIdOption{}) + cmd.Show(&compute.KafkaShowOptions{}) cmd.Get("topics", &options.BaseIdOptions{}) cmd.Delete(&compute.KafkaIdOption{}) cmd.Perform("syncstatus", &compute.KafkaIdOption{}) diff --git a/cmd/climc/shell/compute/mongodb.go b/cmd/climc/shell/compute/mongodb.go index cba26f807e..4bff17fb43 100644 --- a/cmd/climc/shell/compute/mongodb.go +++ b/cmd/climc/shell/compute/mongodb.go @@ -25,7 +25,7 @@ func init() { cmd := shell.NewResourceCmd(&modules.MongoDB) cmd.List(&compute.MongoDBListOptions{}) cmd.Update(&compute.MongoDBUpdateOptions{}) - cmd.Show(&options.BaseIdOptions{}) + cmd.Show(&compute.MongoDBShowOptions{}) cmd.Delete(&options.BaseIdOptions{}) cmd.Get("backups", &options.BaseIdOptions{}) cmd.Perform("syncstatus", &options.BaseIdOptions{}) diff --git a/cmd/climc/shell/compute/secgrouprules.go b/cmd/climc/shell/compute/secgrouprules.go index 706c82d69d..2f6df552ea 100644 --- a/cmd/climc/shell/compute/secgrouprules.go +++ b/cmd/climc/shell/compute/secgrouprules.go @@ -17,15 +17,14 @@ package compute import ( "yunion.io/x/onecloud/cmd/climc/shell" modules "yunion.io/x/onecloud/pkg/mcclient/modules/compute" - "yunion.io/x/onecloud/pkg/mcclient/options" "yunion.io/x/onecloud/pkg/mcclient/options/compute" ) func init() { cmd := shell.NewResourceCmd(&modules.SecGroupRules).WithKeyword("secgroup-rule") cmd.List(&compute.SecGroupRulesListOptions{}) - cmd.Show(&options.BaseShowOptions{}) - cmd.Delete(&options.BaseIdOptions{}) + cmd.Show(&compute.SecGroupRuleShowOptions{}) + cmd.Delete(&compute.SecGroupRuleDeleteOptions{}) cmd.Create(&compute.SecGroupRulesCreateOptions{}) cmd.Update(&compute.SecGroupRulesUpdateOptions{}) } diff --git a/cmd/climc/shell/compute/secgroups.go b/cmd/climc/shell/compute/secgroups.go index 3f6135604a..72c7ae8c79 100644 --- a/cmd/climc/shell/compute/secgroups.go +++ b/cmd/climc/shell/compute/secgroups.go @@ -25,9 +25,9 @@ func init() { cmd := shell.NewResourceCmd(&modules.SecGroups) cmd.List(&options.SecgroupListOptions{}) cmd.Create(&options.SecgroupCreateOptions{}) - cmd.Show(&options.SecgroupIdOptions{}) + cmd.Show(&options.SecgroupShowOptions{}) cmd.Update(&baseoptions.BaseUpdateOptions{}) - cmd.Delete(&options.SecgroupIdOptions{}) + cmd.Delete(&options.SecgroupDeleteOptions{}) cmd.Perform("public", &options.SecgroupIdOptions{}) cmd.Perform("syncstatus", &options.SecgroupIdOptions{}) cmd.Perform("private", &options.SecgroupIdOptions{}) diff --git a/cmd/climc/shell/compute/serverdisks.go b/cmd/climc/shell/compute/serverdisks.go index b318726440..6a529a8599 100644 --- a/cmd/climc/shell/compute/serverdisks.go +++ b/cmd/climc/shell/compute/serverdisks.go @@ -113,10 +113,12 @@ func init() { }) type ServerAttachDiskOptions struct { + _ struct{} `mcp-desc:"将已有硬盘挂载到虚机。SERVER/DISK 为 id/name;可选 driver。先 climc_server_list + climc_disk_list"` + SERVER string `help:"ID or name of server"` DISK string `help:"ID of name of disk to attach"` - Driver string `help:"Driver" choices:"virtio|ide|scsi"` - Cache string `help:"Cache mode" choices:"writeback|none|writethrought"` + Driver string `help:"Driver" choices:"virtio|ide|scsi" mcp:"true"` + Cache string `help:"Cache mode" choices:"writeback|none|writethrought" mcp:"true"` } R(&ServerAttachDiskOptions{}, "server-attach-disk", "Attach an existing virtual disks to a virtual server", func(s *mcclient.ClientSession, args *ServerAttachDiskOptions) error { params := jsonutils.NewDict() @@ -136,9 +138,11 @@ func init() { }) type ServerDetachDiskOptions struct { + _ struct{} `mcp-desc:"从虚机卸载硬盘。SERVER/DISK 为 id/name;可选 delete-disk。先 climc_server_list / climc_disk_list"` + SERVER string `help:"ID or name of server"` DISK string `help:"ID or name of disk to detach"` - DeleteDisk bool `help:"Delete disk if the disk not has flag of auto_delete when detached"` + DeleteDisk bool `help:"Delete disk if the disk not has flag of auto_delete when detached" mcp:"true"` } R(&ServerDetachDiskOptions{}, "server-detach-disk", "Detach a disk from a virtual server", func(s *mcclient.ClientSession, args *ServerDetachDiskOptions) error { params := jsonutils.NewDict() diff --git a/cmd/climc/shell/compute/servers.go b/cmd/climc/shell/compute/servers.go index 130c400968..ca557c5f6d 100644 --- a/cmd/climc/shell/compute/servers.go +++ b/cmd/climc/shell/compute/servers.go @@ -127,8 +127,9 @@ func init() { cmd.Perform("update-sub-ips", &options.ServerUpdateSubIpsOptions{}) cmd.BatchPerform("restore-virtual-isolated-devices", &options.ServerIdsOptions{}) cmd.BatchPerform("set-os-info", &options.ServerSetOSInfoOptions{}) - cmd.BatchPerform("start-rescue", &options.ServerStartOptions{}) - cmd.BatchPerform("stop-rescue", &options.ServerStartOptions{}) + // 与 server-start 复用参数时单独包一层,避免继承 mcp-desc 被注册为 MCP tool + cmd.BatchPerform("start-rescue", &options.ServerStartRescueOptions{}) + cmd.BatchPerform("stop-rescue", &options.ServerStopRescueOptions{}) cmd.BatchPerform("sync-os-info", &options.ServerIdsOptions{}) cmd.BatchPerform("set-root-disk-matcher", &options.ServerSetRootDiskMatcher{}) cmd.Perform("disable-auto-merge-snapshot", &options.ServerDisableAutoMergeSnapshot{}) diff --git a/cmd/climc/shell/identity/common.go b/cmd/climc/shell/identity/common.go index 8c7f0d7ebd..fa1d85eb4b 100644 --- a/cmd/climc/shell/identity/common.go +++ b/cmd/climc/shell/identity/common.go @@ -22,9 +22,9 @@ import ( var ( R = shell.R - printList = printutils.PrintJSONList - printObject = printutils.PrintJSONObject - printBatchResults = printutils.PrintJSONBatchResults + printList = shell.PrintList + printObject = shell.PrintObject + printBatchResults = shell.PrintBatchResults InvalidUpdateError = shell.InvalidUpdateError printObjectRecursive = printutils.PrintJSONObjectRecursive diff --git a/cmd/climc/shell/identity/domains.go b/cmd/climc/shell/identity/domains.go index cc707d3021..2ac15d29d6 100644 --- a/cmd/climc/shell/identity/domains.go +++ b/cmd/climc/shell/identity/domains.go @@ -32,10 +32,12 @@ func init() { cmd.GetProperty(&identity_options.DomainGetPropertyTagValuePairOptions{}) cmd.GetProperty(&identity_options.DomainGetPropertyTagValueTreeOptions{}) - type DomainDetailOptions struct { + type DomainShowOptions struct { + _ struct{} `mcp-desc:"查询域详情。ID 可用 climc_domain_list 返回的 id/name"` + ID string `help:"ID or domain"` } - R(&DomainDetailOptions{}, "domain-show", "Show detail of domain", func(s *mcclient.ClientSession, args *DomainDetailOptions) error { + R(&DomainShowOptions{}, "domain-show", "Show detail of domain", func(s *mcclient.ClientSession, args *DomainShowOptions) error { result, err := modules.Domains.Get(s, args.ID, nil) if err != nil { return err @@ -43,7 +45,12 @@ func init() { printObject(result) return nil }) - R(&DomainDetailOptions{}, "domain-delete", "Delete a domain", func(s *mcclient.ClientSession, args *DomainDetailOptions) error { + type DomainDeleteOptions struct { + _ struct{} `mcp-desc:"删除域。若尚不知 id,先用 climc_domain_list 定位;确认域下无项目/用户后再删"` + + ID string `help:"ID or domain"` + } + R(&DomainDeleteOptions{}, "domain-delete", "Delete a domain", func(s *mcclient.ClientSession, args *DomainDeleteOptions) error { objId, err := modules.Domains.GetId(s, args.ID, nil) if err != nil { return err @@ -72,12 +79,14 @@ func init() { }) */ type DomainCreateOptions struct { - NAME string `help:"Name of domain"` - Desc string `help:"Description"` - Enabled bool `help:"Set the domain enabled"` - Disabled bool `help:"Set the domain disabled"` + _ struct{} `mcp-desc:"创建域。NAME 必填;可选 displayname/desc/enabled"` - Displayname string `help:"display name"` + NAME string `help:"Name of domain"` + Desc string `help:"Description" mcp:"true"` + Enabled bool `help:"Set the domain enabled" mcp:"true"` + Disabled bool `help:"Set the domain disabled" mcp:"true"` + + Displayname string `help:"display name" mcp:"true"` } R(&DomainCreateOptions{}, "domain-create", "Create a new domain", func(s *mcclient.ClientSession, args *DomainCreateOptions) error { params := jsonutils.NewDict() diff --git a/cmd/climc/shell/identity/projects.go b/cmd/climc/shell/identity/projects.go index 79ccaf7a8d..3bd13670fb 100644 --- a/cmd/climc/shell/identity/projects.go +++ b/cmd/climc/shell/identity/projects.go @@ -43,8 +43,10 @@ func init() { cmd.PerformClass("clean", &identity_options.ProjectCleanOptions{}) type ProjectShowOptions struct { + _ struct{} `mcp-desc:"查询项目详情。ID 可用 climc_project_list 返回的 id/name;跨域时可传 domain"` + ID string `help:"ID or Name of project"` - Domain string `help:"Domain"` + Domain string `help:"Domain" mcp:"true"` } R(&ProjectShowOptions{}, "project-show", "Show details of project", func(s *mcclient.ClientSession, args *ProjectShowOptions) error { query := jsonutils.NewDict() @@ -62,7 +64,13 @@ func init() { printObject(result) return nil }) - R(&ProjectShowOptions{}, "project-delete", "Delete a project", func(s *mcclient.ClientSession, args *ProjectShowOptions) error { + type ProjectDeleteOptions struct { + _ struct{} `mcp-desc:"删除项目。若尚不知 id,先用 climc_project_list 定位;跨域时可传 domain"` + + ID string `help:"ID or Name of project"` + Domain string `help:"Domain" mcp:"true"` + } + R(&ProjectDeleteOptions{}, "project-delete", "Delete a project", func(s *mcclient.ClientSession, args *ProjectDeleteOptions) error { query := jsonutils.NewDict() if len(args.Domain) > 0 { domainId, err := modules.Domains.GetId(s, args.Domain, nil) @@ -83,12 +91,14 @@ func init() { }) type ProjectCreateOptions struct { + _ struct{} `mcp-desc:"创建项目。NAME 必填;可选 domain/displayname/desc"` + NAME string `help:"Name of new project"` - Displayname string `help:"display name"` - Domain string `help:"Domain"` - Desc string `help:"Description"` - Enabled bool `help:"Project is enabled"` - Disabled bool `help:"Project is disabled"` + Displayname string `help:"display name" mcp:"true"` + Domain string `help:"Domain" mcp:"true"` + Desc string `help:"Description" mcp:"true"` + Enabled bool `help:"Project is enabled" mcp:"true"` + Disabled bool `help:"Project is disabled" mcp:"true"` } R(&ProjectCreateOptions{}, "project-create", "Create a project", func(s *mcclient.ClientSession, args *ProjectCreateOptions) error { params := jsonutils.NewDict() diff --git a/cmd/climc/shell/identity/users.go b/cmd/climc/shell/identity/users.go index 3925242267..7075dd7d57 100644 --- a/cmd/climc/shell/identity/users.go +++ b/cmd/climc/shell/identity/users.go @@ -64,12 +64,14 @@ func init() { return nil })*/ - type UserDetailOptions struct { + type UserShowOptions struct { + _ struct{} `mcp-desc:"查询用户详情。ID 可用 climc_user_list 返回的 id/name;跨域时可传 domain"` + ID string `help:"ID of user"` - Domain string `help:"Domain"` - System bool `help:"show system user"` + Domain string `help:"Domain" mcp:"true"` + System bool `help:"show system user" mcp:"true"` } - R(&UserDetailOptions{}, "user-show", "Show details of user", func(s *mcclient.ClientSession, args *UserDetailOptions) error { + R(&UserShowOptions{}, "user-show", "Show details of user", func(s *mcclient.ClientSession, args *UserShowOptions) error { query := jsonutils.NewDict() if len(args.Domain) > 0 { domainId, err := modules.Domains.GetId(s, args.Domain, nil) @@ -89,7 +91,14 @@ func init() { return nil }) - R(&UserDetailOptions{}, "user-delete", "Delete user", func(s *mcclient.ClientSession, args *UserDetailOptions) error { + type UserDeleteOptions struct { + _ struct{} `mcp-desc:"删除用户。若尚不知 id,先用 climc_user_list 定位;跨域时可传 domain"` + + ID string `help:"ID of user"` + Domain string `help:"Domain" mcp:"true"` + System bool `help:"show system user" mcp:"true"` + } + R(&UserDeleteOptions{}, "user-delete", "Delete user", func(s *mcclient.ClientSession, args *UserDeleteOptions) error { query := jsonutils.NewDict() if len(args.Domain) > 0 { domainId, err := modules.Domains.GetId(s, args.Domain, nil) @@ -109,6 +118,11 @@ func init() { return nil }) + type UserDetailOptions struct { + ID string `help:"ID of user"` + Domain string `help:"Domain"` + System bool `help:"show system user"` + } R(&UserDetailOptions{}, "user-project-list", "List projects of user", func(s *mcclient.ClientSession, args *UserDetailOptions) error { query := jsonutils.NewDict() if len(args.Domain) > 0 { @@ -170,29 +184,31 @@ func init() { }) type UserCreateOptions struct { - NAME string `help:"Name of the new user"` - Domain string `help:"Domain"` - Desc string `help:"Description"` - Password *string `help:"Password"` - Displayname string `help:"Displayname"` - Email string `help:"Email"` - Mobile string `help:"Mobile"` - Enabled bool `help:"Enabled"` - Disabled bool `help:"Disabled"` + _ struct{} `mcp-desc:"创建用户。NAME 必填;建议传 password;可选 domain/email/mobile/displayname"` - SkipPasswordComplexityCheck bool `help:"do password complexity check, default is false"` + NAME string `help:"Name of the new user"` + Domain string `help:"Domain" mcp:"true"` + Desc string `help:"Description" mcp:"true"` + Password *string `help:"Password" mcp:"true"` + Displayname string `help:"Displayname" mcp:"true"` + Email string `help:"Email" mcp:"true"` + Mobile string `help:"Mobile" mcp:"true"` + Enabled bool `help:"Enabled" mcp:"true"` + Disabled bool `help:"Disabled" mcp:"true"` + + SkipPasswordComplexityCheck bool `help:"do password complexity check, default is false" mcp:"true"` // DefaultProject string `help:"Default project"` - SystemAccount bool `help:"is a system account?"` - NoWebConsole bool `help:"allow web console access"` - EnableMfa bool `help:"enable TOTP mfa"` + SystemAccount bool `help:"is a system account?" mcp:"true"` + NoWebConsole bool `help:"allow web console access" mcp:"true"` + EnableMfa bool `help:"enable TOTP mfa" mcp:"true"` - IdpId string `help:"Id of identity provider to link with"` - IdpEntityId string `help:"Entity id of identity provider to link with"` + IdpId string `help:"Id of identity provider to link with" mcp:"true"` + IdpEntityId string `help:"Entity id of identity provider to link with" mcp:"true"` - Lang string `help:"user default language"` + Lang string `help:"user default language" mcp:"true"` - Expire string `help:"user expired at"` + Expire string `help:"user expired at" mcp:"true"` } R(&UserCreateOptions{}, "user-create", "Create a user", func(s *mcclient.ClientSession, args *UserCreateOptions) error { params := jsonutils.NewDict() diff --git a/cmd/climc/shell/image/common.go b/cmd/climc/shell/image/common.go index 4b3fc8a3e9..c801af8f6d 100644 --- a/cmd/climc/shell/image/common.go +++ b/cmd/climc/shell/image/common.go @@ -22,9 +22,9 @@ import ( var ( R = shell.R - printList = printutils.PrintJSONList - printObject = printutils.PrintJSONObject - printBatchResults = printutils.PrintJSONBatchResults + printList = shell.PrintList + printObject = shell.PrintObject + printBatchResults = shell.PrintBatchResults InvalidUpdateError = shell.InvalidUpdateError printObjectRecursive = printutils.PrintJSONObjectRecursive diff --git a/cmd/climc/shell/logger/actions.go b/cmd/climc/shell/logger/actions.go index 4a6e7a0edd..f8f9e818de 100644 --- a/cmd/climc/shell/logger/actions.go +++ b/cmd/climc/shell/logger/actions.go @@ -47,9 +47,11 @@ type BaseActionListOptions struct { } type ActionListOptions struct { + _ struct{} `mcp-desc:"查询操作日志(谁在何时对资源做了什么)。可按对象类型 type(如 server/disk/host)、对象 id、时间 since/until、action、user、project、succ/fail 过滤;默认 limit=20。排查创建失败、误操作、审计时优先调用"` + BaseActionListOptions Service []string `help:"service name"` - Id string `help:"" metavar:"OBJ_ID"` + Id string `help:"filter by object id" metavar:"OBJ_ID"` Type []string `help:"Type of relevant object" metavar:"OBJ_TYPE"` } diff --git a/cmd/climc/shell/logger/common.go b/cmd/climc/shell/logger/common.go index b49671c784..503466aba9 100644 --- a/cmd/climc/shell/logger/common.go +++ b/cmd/climc/shell/logger/common.go @@ -15,13 +15,11 @@ package logger import ( - "yunion.io/x/pkg/util/printutils" - "yunion.io/x/onecloud/cmd/climc/shell" ) var ( R = shell.R - printList = printutils.PrintJSONList - printObject = printutils.PrintJSONObject + printList = shell.PrintList + printObject = shell.PrintObject ) diff --git a/cmd/climc/shell/monitor/common.go b/cmd/climc/shell/monitor/common.go index a0c1057a72..53c57ace5c 100644 --- a/cmd/climc/shell/monitor/common.go +++ b/cmd/climc/shell/monitor/common.go @@ -15,17 +15,15 @@ package monitor import ( - "yunion.io/x/pkg/util/printutils" - "yunion.io/x/onecloud/cmd/climc/shell" "yunion.io/x/onecloud/pkg/mcclient/modulebase" ) var ( R = shell.R - printList = printutils.PrintJSONList - printObject = printutils.PrintJSONObject - printBatchResults = printutils.PrintJSONBatchResults + printList = shell.PrintList + printObject = shell.PrintObject + printBatchResults = shell.PrintBatchResults ) func NewResourceCmd(manager modulebase.IBaseManager) *shell.ResourceCmd { diff --git a/cmd/climc/shell/scheduler/common.go b/cmd/climc/shell/scheduler/common.go index 4da810460c..3f7c8fb3c0 100644 --- a/cmd/climc/shell/scheduler/common.go +++ b/cmd/climc/shell/scheduler/common.go @@ -22,9 +22,9 @@ import ( var ( R = shell.R - printList = printutils.PrintJSONList - printObject = printutils.PrintJSONObject - printBatchResults = printutils.PrintJSONBatchResults + printList = shell.PrintList + printObject = shell.PrintObject + printBatchResults = shell.PrintBatchResults InvalidUpdateError = shell.InvalidUpdateError printObjectRecursive = printutils.PrintJSONObjectRecursive diff --git a/cmd/climc/shell/utils.go b/cmd/climc/shell/utils.go index e66974e977..de343c403d 100644 --- a/cmd/climc/shell/utils.go +++ b/cmd/climc/shell/utils.go @@ -15,10 +15,15 @@ package shell import ( + "bytes" "fmt" + "io" "os" + "runtime" "sort" + "strconv" "strings" + "sync" "yunion.io/x/jsonutils" "yunion.io/x/pkg/util/printutils" @@ -37,45 +42,111 @@ const ( var outputFormat = OUTPUT_FORMAT_TABLE +// goroutine 本地输出:MCP 并发 tools/call 时避免劫持全局 os.Stdout。 +type outputState struct { + writer io.Writer + format string +} + +var outputStates sync.Map // uint64(goid) -> *outputState + func OutputFormat(s string) { outputFormat = s } +// PushOutput 将当前 goroutine 的 shell 输出重定向到 w,并可选覆盖格式。 +// 返回的 restore 必须在同一 goroutine 调用。 +func PushOutput(w io.Writer, format string) (restore func()) { + id := goroutineID() + prev, _ := outputStates.Load(id) + outputStates.Store(id, &outputState{writer: w, format: format}) + return func() { + if prev != nil { + outputStates.Store(id, prev) + } else { + outputStates.Delete(id) + } + } +} + +func currentWriter() io.Writer { + if v, ok := outputStates.Load(goroutineID()); ok { + if s := v.(*outputState); s != nil && s.writer != nil { + return s.writer + } + } + return os.Stdout +} + +func currentFormat() string { + if v, ok := outputStates.Load(goroutineID()); ok { + if s := v.(*outputState); s != nil && s.format != "" { + return s.format + } + } + return outputFormat +} + +func goroutineID() uint64 { + b := make([]byte, 64) + b = b[:runtime.Stack(b, false)] + b = bytes.TrimPrefix(b, []byte("goroutine ")) + i := bytes.IndexByte(b, ' ') + if i <= 0 { + return 0 + } + n, _ := strconv.ParseUint(string(b[:i]), 10, 64) + return n +} + func PrintList(list *printutils.ListResult, columns []string) { - switch outputFormat { + w := currentWriter() + switch currentFormat() { case OUTPUT_FORMAT_TABLE: - printutils.PrintJSONList(list, columns) + if w == os.Stdout { + printutils.PrintJSONList(list, columns) + return + } + fmt.Fprint(w, jsonutils.Marshal(list).PrettyString()) + fmt.Fprint(w, "\n") case OUTPUT_FORMAT_JSON: - fmt.Print(jsonutils.Marshal(list).PrettyString()) - fmt.Print("\n") + fmt.Fprint(w, jsonutils.Marshal(list).PrettyString()) + fmt.Fprint(w, "\n") case OUTPUT_FORMAT_YAML: - fmt.Print(jsonutils.Marshal(list).YAMLString()) + fmt.Fprint(w, jsonutils.Marshal(list).YAMLString()) default: - fmt.Fprintf(os.Stderr, "unknown output format: %q\n", outputFormat) + fmt.Fprintf(os.Stderr, "unknown output format: %q\n", currentFormat()) } } func PrintObject(obj jsonutils.JSONObject) { - switch outputFormat { + w := currentWriter() + switch currentFormat() { case OUTPUT_FORMAT_TABLE: - printutils.PrintJSONObject(obj) + if w == os.Stdout { + printutils.PrintJSONObject(obj) + return + } + fmt.Fprint(w, obj.PrettyString()) + fmt.Fprint(w, "\n") case OUTPUT_FORMAT_KV: printObjectFmtKv(obj) case OUTPUT_FORMAT_JSON: - fmt.Print(obj.PrettyString()) - fmt.Print("\n") + fmt.Fprint(w, obj.PrettyString()) + fmt.Fprint(w, "\n") case OUTPUT_FORMAT_YAML: - fmt.Print(obj.YAMLString()) + fmt.Fprint(w, obj.YAMLString()) case OUTPUT_FORMAT_FLATTEN_TABLE: printObjectRecursive(obj) case OUTPUT_FORMAT_FLATTEN_KV: printObjectRecursiveEx(obj, printObjectFmtKv) default: - fmt.Fprintf(os.Stderr, "unknown output format: %q\n", outputFormat) + fmt.Fprintf(os.Stderr, "unknown output format: %q\n", currentFormat()) } } func printObjectFmtKv(obj jsonutils.JSONObject) { + w := currentWriter() m, _ := obj.GetMap() maxWidth := 0 keys := make([]string, 0, len(m)) @@ -95,7 +166,7 @@ func printObjectFmtKv(obj jsonutils.JSONObject) { } else { s = objV.String() } - fmt.Printf("%*s: %s\n", maxWidth, k, s) + fmt.Fprintf(w, "%*s: %s\n", maxWidth, k, s) } } @@ -107,8 +178,24 @@ func printObjectRecursiveEx(obj jsonutils.JSONObject, cb printutils.PrintJSONObj printutils.PrintJSONObjectRecursiveEx(obj, cb) } +func PrintBatchResults(results []printutils.SubmitResult, columns []string) { + w := currentWriter() + switch currentFormat() { + case OUTPUT_FORMAT_JSON: + fmt.Fprint(w, jsonutils.Marshal(results).PrettyString()) + fmt.Fprint(w, "\n") + default: + if w == os.Stdout { + printutils.PrintJSONBatchResults(results, columns) + return + } + fmt.Fprint(w, jsonutils.Marshal(results).PrettyString()) + fmt.Fprint(w, "\n") + } +} + func printBatchResults(results []printutils.SubmitResult, columns []string) { - printutils.PrintJSONBatchResults(results, columns) + PrintBatchResults(results, columns) } func ExportList(list *printutils.ListResult, file string, exportKeys string, exportTexts string, columns []string) { diff --git a/docs/mcp-server/README.md b/docs/mcp-server/README.md index fea2dd6679..fb6cdfa7bf 100644 --- a/docs/mcp-server/README.md +++ b/docs/mcp-server/README.md @@ -1,59 +1,24 @@ # MCP Server -MCP Server 是 Cloudpods 多云管理平台的核心组件之一,负责处理多云资源的统一管理和调度。 +Cloudpods MCP Server:通过 MCP 协议把 climc 能力暴露给 AI 客户端(Cursor / Claude 等)。 ## 目录结构 ``` -├── adapters/ # 适配器模块,用于对接不同云平台的API -├── config/ # 配置模块,处理服务配置和加载 -├── models/ # 数据模型,定义云资源的数据结构 -├── registry/ # 注册中心,管理可用的工具和服务 -├── server/ # 服务核心,包含服务启动和初始化逻辑 -└── tools/ # 工具模块,实现各种云资源管理功能 +├── adapters/ # Cloudpods 认证与 ClientSession +├── climcgen/ # 从 climc CommandTable + Options tag 生成 MCP tools +├── options/ # 服务配置 +├── registry/ # MCP tool 注册 +├── server/ # SSE / stdio 服务 +└── service/ # 进程入口装配 ``` -## 架构设计 - -MCP Server 采用模块化设计,主要包括以下几个核心模块: - -1. **适配器模块 (Adapters)**: 负责与不同云平台的API进行交互,实现资源的统一管理。 -2. **配置模块 (Config)**: 处理服务的配置加载和管理,支持多种配置方式。 -3. **数据模型 (Models)**: 定义云资源的数据结构,为其他模块提供统一的数据访问接口。 -4. **注册中心 (Registry)**: 管理可用的工具和服务,支持动态注册和发现。 -5. **服务核心 (Server)**: 负责服务的启动、初始化和生命周期管理。 -6. **工具模块 (Tools)**: 实现各种云资源管理功能,如VPC、网络、镜像等。 - ## 运行机制 -1. 服务启动时,首先加载配置文件并初始化各个模块。 -2. 适配器模块根据配置连接到相应的云平台。 -3. 注册中心注册所有可用的工具和服务。 -4. 服务核心启动HTTP服务器,监听客户端请求。 -5. 客户端通过API调用相应的工具来管理云资源。 +1. 启动时 blank-import climc shell 包,填充 `shell.CommandTable` +2. 扫描 Options 上带 `mcp-desc` 的命令,用 Options struct tag 生成 schema 并注册 tools +3. 工具调用时用 AK/SK(或 Header)建 session,执行对应 climc callback,JSON 输出返回给客户端 -## 主要功能 +## 扩展工具 -- 统一管理多云资源(VPC、网络、镜像、主机等) -- 支持多种云平台(AWS、Azure、阿里云等) -- 提供RESTful API接口 -- 支持资源的查询、创建、更新和删除操作 - -## 配置说明 - -配置文件位于 `options/options.go`,主要包含以下配置项: - -- ServerConfig: 服务配置,如监听地址、端口等 -- MCPConfig: MCP相关配置 -- ExternalConfig: 外部服务配置 - -## 开发指南 - -1. 实现新的云资源管理功能时,需要在 `tools/` 目录下创建相应的工具文件。 -2. 工具需要实现 `Tool` 接口,包括 `GetTool`、`Handle` 和 `GetName` 方法。 -3. 数据模型定义在 `models/` 目录下,需要根据云平台API文档进行定义。 -4. 适配器实现在 `adapters/` 目录下,用于与云平台API进行交互。 - -## 贡献 - -欢迎提交Issue和Pull Request来改进MCP Server。 \ No newline at end of file +在对应 climc Options 上增加 `_ struct{} \`mcp-desc:"..."\``(并按需给字段加 `mcp:"true"`),重启 mcp-server 即可注册。 diff --git a/pkg/apigateway/handler/mcp_agent.go b/pkg/apigateway/handler/mcp_agent.go index 310889540c..5ba27355e1 100644 --- a/pkg/apigateway/handler/mcp_agent.go +++ b/pkg/apigateway/handler/mcp_agent.go @@ -86,6 +86,8 @@ func mcpAgentChatStreamHandler(ctx context.Context, w http.ResponseWriter, r *ht // Prepare request to backend headers := http.Header{} headers.Set("Content-Type", "application/json") + headers.Set("Accept", "text/event-stream") + headers.Set("Accept-Encoding", "identity") // Forward the request body to the backend var bodyReader io.Reader @@ -125,7 +127,9 @@ func mcpAgentChatStreamHandler(ctx context.Context, w http.ResponseWriter, r *ht w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - // For now just standard SSE headers. + w.Header().Set("X-Accel-Buffering", "no") + // 禁止中间层 gzip 缓冲整段 SSE + w.Header().Set("Content-Encoding", "identity") if f, ok := w.(http.Flusher); ok { f.Flush() @@ -160,6 +164,8 @@ func mcpAgentDefaultChatStreamHandler(ctx context.Context, w http.ResponseWriter headers := http.Header{} headers.Set("Content-Type", "application/json") + headers.Set("Accept", "text/event-stream") + headers.Set("Accept-Encoding", "identity") var bodyReader io.Reader if r.Body != nil { @@ -194,6 +200,8 @@ func mcpAgentDefaultChatStreamHandler(ctx context.Context, w http.ResponseWriter w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.Header().Set("Content-Encoding", "identity") if f, ok := w.(http.Flusher); ok { f.Flush() } diff --git a/pkg/apis/llm/llm.go b/pkg/apis/llm/llm.go index 488c315133..4a525c2f72 100644 --- a/pkg/apis/llm/llm.go +++ b/pkg/apis/llm/llm.go @@ -9,7 +9,8 @@ import ( ) const ( - SERVICE_TYPE = "llm" + SERVICE_TYPE = "llm" + SERVICE_VERSION = "" ) type LLMBaseListDetails struct { diff --git a/pkg/apis/llm/mcp_agent.go b/pkg/apis/llm/mcp_agent.go index 9280a65daf..f695fe0fe8 100644 --- a/pkg/apis/llm/mcp_agent.go +++ b/pkg/apis/llm/mcp_agent.go @@ -13,15 +13,16 @@ const ( LLM_CLIENT_OLLAMA LLMClientType = "ollama" LLM_CLIENT_OPENAI LLMClientType = "openai" - MCP_AGENT_SYSTEM_PROMPT = `你是一个 Cloudpods 云平台管理助手。你可以使用提供的工具来帮助用户管理云资源。 + MCP_AGENT_SYSTEM_PROMPT = `你是一个 %s 云平台管理助手。你可以使用提供的工具来帮助用户管理云资源。 ## 你的能力 -- 查询云平台资源(虚拟机、镜像、网络、存储、区域等) +- 查询云平台资源(虚拟机、镜像、网络、存储、区域、套餐等) - 管理虚拟机(创建、启动、停止、重启、删除、重置密码) - 获取虚拟机监控信息和实时统计数据 ## 重要规则(必须严格遵守) **如果用户的问题涉及查询、创建、修改或删除云资源,你必须先调用相应的工具,而不是直接回答。** +- 严禁空口编造:在未真正调用工具并拿到返回结果前,禁止声称“已查到区域/镜像/网络”“正在创建成功”等。 - 对于需要查询资源的问题(如"列出虚拟机"、"查询状态"等),必须调用工具获取数据后再回答 - 对于需要操作资源的问题(如"创建"、"启动"、"停止"等),必须调用工具执行操作后再回答 - 只有在以下情况才可以直接回复: @@ -29,9 +30,17 @@ const ( 2. 没有合适的工具可以解决用户的问题 3. 工具调用失败后需要向用户说明错误原因 +## 创建虚拟机标准流程(同一轮对话中连续调用工具,不要只说不做;查询可并行以节省轮次) +1. climc_cloud_region_list(公有云必须 provider=["Aliyun"] 等;创建时 usable=true) +2. climc_cloud_region_capability(ID=区域 id;从 storage_types2 取系统盘 backend) +3. climc_cached_image_list(公有云)或 climc_image_list(KVM);公有云必须带 provider + region=区域 id;不要重复调用 +4. climc_server_sku_list(公有云带 provider+cloudregion;用户说 2c2g/2核2G 时传 spec="2c2g") +5. climc_server_create(name、disk 须含 image+backend、instance-type 或 ncpu/mem-spec;公有云 hypervisor=aliyun、prefer-region=区域 id)。net 可省略:未指定时自动 random(nets:[{exit:false}])调度,默认不要先 network-list/vpc-list +查询工具的返回不等于任务完成;必须最终调用 climc_server_create。创建失败时根据工具错误向用户说明原因。 + ## 工作流程 1. 理解用户的需求 -2. **优先检查是否有合适的工具可以完成任务,如果有则必须调用工具** +2. **优先检查是否有合适的工具可以完成任务,如果有则必须调用工具(发 tool_calls,不要只输出计划文字)** 3. 分析工具返回的结果 4. 如果需要更多信息,继续调用其他工具 5. 最后用自然语言总结结果给用户 diff --git a/pkg/llm/drivers/llm_client/openai.go b/pkg/llm/drivers/llm_client/openai.go index 776fdb7e98..324c98d4cd 100644 --- a/pkg/llm/drivers/llm_client/openai.go +++ b/pkg/llm/drivers/llm_client/openai.go @@ -304,10 +304,12 @@ func (o *openai) doChatStreamRequest(ctx context.Context, mcpAgent *models.SMCPA if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + return formatLLMHTTPError(resp.StatusCode, mcpAgent.Model, body) } scanner := bufio.NewScanner(resp.Body) + // 工具调用场景下单行 SSE 可能很大,提高 buffer + scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) for scanner.Scan() { line := scanner.Text() line = strings.TrimSpace(line) @@ -394,7 +396,7 @@ func (o *openai) doChatRequest(ctx context.Context, mcpAgent *models.SMCPAgent, } if resp.StatusCode != http.StatusOK { - return nil, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + return nil, formatLLMHTTPError(resp.StatusCode, mcpAgent.Model, body) } var chatResp OpenAIChatResponse @@ -511,6 +513,57 @@ func (o *openai) ConvertMCPTools(mcpTools []mcp.Tool) []models.ILLMTool { return tools } +// formatLLMHTTPError 把上游 JSON 错误整理成可读单行提示。 +func formatLLMHTTPError(status int, model string, body []byte) error { + msg := extractLLMErrorMessage(body) + if msg == "" { + msg = strings.TrimSpace(string(body)) + msg = strings.ReplaceAll(msg, "\n", " ") + msg = strings.Join(strings.Fields(msg), " ") + } + if msg == "" { + msg = http.StatusText(status) + } + + lower := strings.ToLower(msg) + switch { + case strings.Contains(lower, "unsupported model"): + return errors.Errorf("模型不支持:当前配置为 %q(%s)", model, msg) + case status == http.StatusUnauthorized || strings.Contains(lower, "invalid api key") || strings.Contains(lower, "incorrect api key"): + return errors.Errorf("鉴权失败:请检查 Agent 的 API Key(%s)", msg) + case status == http.StatusTooManyRequests: + return errors.Errorf("请求过于频繁,请稍后重试(%s)", msg) + default: + return errors.Errorf("大模型接口返回 %d:%s", status, msg) + } +} + +func extractLLMErrorMessage(body []byte) string { + body = bytes.TrimSpace(body) + if len(body) == 0 { + return "" + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + if errObj, ok := payload["error"].(map[string]interface{}); ok { + if m, ok := errObj["message"].(string); ok && strings.TrimSpace(m) != "" { + return strings.TrimSpace(m) + } + if m, ok := errObj["msg"].(string); ok && strings.TrimSpace(m) != "" { + return strings.TrimSpace(m) + } + } + if m, ok := payload["message"].(string); ok && strings.TrimSpace(m) != "" { + return strings.TrimSpace(m) + } + if m, ok := payload["msg"].(string); ok && strings.TrimSpace(m) != "" { + return strings.TrimSpace(m) + } + return "" +} + // Structures type OpenAIChatMessage struct { diff --git a/pkg/llm/drivers/llm_client/openai_error_test.go b/pkg/llm/drivers/llm_client/openai_error_test.go new file mode 100644 index 0000000000..e10d09ecd4 --- /dev/null +++ b/pkg/llm/drivers/llm_client/openai_error_test.go @@ -0,0 +1,30 @@ +package llm_client + +import ( + "net/http" + "strings" + "testing" +) + +func TestFormatLLMHTTPErrorUnsupportedModel(t *testing.T) { + body := []byte(`{ + "error": { + "code": "400", + "message": "Unsupported model MiMo-V2.5" + } +}`) + err := formatLLMHTTPError(http.StatusBadRequest, "MiMo-V2.5", body) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "模型不支持") { + t.Fatalf("want 模型不支持, got %q", msg) + } + if !strings.Contains(msg, "MiMo-V2.5") { + t.Fatalf("want model name in message, got %q", msg) + } + if strings.Contains(msg, "\n") { + t.Fatalf("error should be single line, got %q", msg) + } +} diff --git a/pkg/llm/models/mcp_agent.go b/pkg/llm/models/mcp_agent.go index 42d2e1bd7a..19bf1bda5c 100644 --- a/pkg/llm/models/mcp_agent.go +++ b/pkg/llm/models/mcp_agent.go @@ -199,14 +199,20 @@ func (mcp *SMCPAgent) GetApiKey() (string, error) { func (man *SMCPAgentManager) CustomizeHandlerInfo(info *appsrv.SHandlerInfo) { man.SSharableVirtualResourceBaseManager.CustomizeHandlerInfo(info) - // log.Infoln("query name of handler info", info.GetName(nil)) - switch info.GetName(nil) { case "get_specific": info.SetProcessTimeout(time.Hour * 4).SetWorkerManager(mcpAgentWorkerMan) } } +func (man *SMCPAgentManager) SetHandlerProcessTimeout(info *appsrv.SHandlerInfo, r *http.Request) time.Duration { + // 仅 llm 侧 mcp_agents/*/chat-stream + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "chat-stream") { + return 4 * time.Hour + } + return man.SSharableVirtualResourceBaseManager.SetHandlerProcessTimeout(info, r) +} + func (man *SMCPAgentManager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, input *api.MCPAgentCreateInput) (*api.MCPAgentCreateInput, error) { var err error input.SharableVirtualResourceCreateInput, err = man.SSharableVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.SharableVirtualResourceCreateInput) @@ -472,9 +478,12 @@ func (mcp *SMCPAgent) PerformChatStream( } w := appParams.Response - w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.Header().Set("Content-Encoding", "identity") + appParams.OverrideResponseBodyWrapper = true if f, ok := w.(http.Flusher); ok { f.Flush() @@ -482,29 +491,67 @@ func (mcp *SMCPAgent) PerformChatStream( return nil, errors.Error("Streaming unsupported!") } + // 立刻推一条注释帧,避免 ListTools/首轮推理期间前端只看到「思考中」 + if _, err := fmt.Fprintf(w, ": connected\n\n"); err == nil { + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + _, err := mcp.process(ctx, userCred, &input, func(content string) error { - if len(content) > 0 { - for line := range strings.SplitSeq(content, "\n") { - fmt.Fprintf(w, "data: %s\n", line) - } - fmt.Fprintf(w, "\n") - if f, ok := w.(http.Flusher); ok { - f.Flush() + if len(content) == 0 { + return nil + } + // 单个 SSE 事件:多行 content 用多条 data: 表示(前端按事件拼接为 \n) + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + for _, line := range strings.Split(content, "\n") { + if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil { + return err } } + if _, err := fmt.Fprintf(w, "\n"); err != nil { + return err + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } return nil }) if err != nil { - fmt.Fprintf(w, "data: Error: %v\n\n", err) + // 单行推送,避免换行 JSON 被 SSE 截断;去掉冗长 wrap 前缀 + msg := friendlyChatStreamError(err) + fmt.Fprintf(w, "data: Error: %s\n\n", msg) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } } return nil, nil } -// process 处理用户请求 +// friendlyChatStreamError 面向用户的短错误文案(单行,适合 SSE)。 +func friendlyChatStreamError(err error) string { + if err == nil { + return "未知错误" + } + msg := err.Error() + // 去掉 "chat stream round N: " 包装,突出真正原因 + const wrap = "chat stream round " + if i := strings.Index(msg, wrap); i >= 0 { + rest := msg[i+len(wrap):] + if j := strings.Index(rest, ": "); j >= 0 { + msg = rest[j+2:] + } + } + msg = strings.ReplaceAll(msg, "\r\n", " ") + msg = strings.ReplaceAll(msg, "\n", " ") + return strings.Join(strings.Fields(msg), " ") +} + +// process 处理用户请求(多轮工具调用,直到模型不再发 tool_calls 或达到上限) func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCredential, req *api.LLMMCPAgentRequestInput, onStream func(string) error) (*api.MCPAgentResponse, error) { - // 获取 MCP Server 的工具列表 mcpServerUrl, err := mcp.GetMcpServerUrl(ctx, userCred) if err != nil { return nil, errors.Wrap(err, "GetMcpServerUrl") @@ -516,128 +563,143 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden return nil, errors.Wrap(err, "list MCP tools") } log.Infof("Got %d tools from MCP Server", len(mcpTools)) + if onStream != nil { + _ = onStream("正在准备…\n") + } - // get llmClient llmClient := mcp.GetLLMClientDriver() if llmClient == nil { return nil, errors.Error("failed to get LLM client driver") } - tools := llmClient.ConvertMCPTools(mcpTools) - // 构建系统提示词 - systemPrompt := buildSystemPrompt() - - // 初始化消息历史 messages := make([]ILLMChatMessage, 0) - messages = append(messages, llmClient.NewSystemMessage(systemPrompt)) - - // 处理历史消息 + messages = append(messages, llmClient.NewSystemMessage(buildSystemPrompt())) if len(req.History) > 0 { - historyMessages := processHistoryMessages( + messages = append(messages, processHistoryMessages( req.History, llmClient, options.Options.MCPAgentUserCharLimit, options.Options.MCPAgentAssistantCharLimit, - ) - messages = append(messages, historyMessages...) + )...) } - messages = append(messages, llmClient.NewUserMessage(req.Message)) - // 记录工具调用 - var toolCallRecords []api.MCPAgentToolCallRecord - - log.Infof("Phase 1: Thinking & Acting...") - - // 处理流式的工具调用参数 - type accumToolCall struct { - Id string - Name string - RawArguments strings.Builder + maxRounds := options.Options.MCPAgentMaxToolRounds + if maxRounds <= 0 { + maxRounds = 8 } - accToolCalls := make(map[int]*accumToolCall) - var accumulatedContent strings.Builder - var accumulatedReasoning strings.Builder - hasToolCalls := false - err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error { - if chunk.HasToolCalls() { - hasToolCalls = true - for _, tc := range chunk.GetToolCalls() { - idx := tc.GetIndex() - if _, exists := accToolCalls[idx]; !exists { - accToolCalls[idx] = &accumToolCall{ - Id: tc.GetId(), + var toolCallRecords []api.MCPAgentToolCallRecord + var finalAnswer strings.Builder + nudged := false + resourceOp := looksLikeResourceOperation(req.Message) + labels := newProgressLabelCache() + + for round := 1; round <= maxRounds; round++ { + log.Infof("MCP agent tool round %d/%d", round, maxRounds) + + type accumToolCall struct { + Id string + Name string + RawArguments strings.Builder + } + accToolCalls := make(map[int]*accumToolCall) + var accumulatedContent strings.Builder + var accumulatedReasoning strings.Builder + hasToolCalls := false + // 首轮资源操作可能被 nudge:先不流式,避免把「计划文案」推给用户 + optimisticStream := !(round == 1 && resourceOp && !nudged && len(tools) > 0) + streamedAnswer := false + + // 有 tool_calls 时不推送中间文案;纯文本最终答复边生成边推送 + err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error { + if chunk.HasToolCalls() { + hasToolCalls = true + for _, tc := range chunk.GetToolCalls() { + idx := tc.GetIndex() + if _, exists := accToolCalls[idx]; !exists { + accToolCalls[idx] = &accumToolCall{Id: tc.GetId()} + } + atc := accToolCalls[idx] + if id := tc.GetId(); id != "" { + atc.Id = id + } + if name := tc.GetFunction().GetName(); name != "" { + atc.Name = name + } + if args := tc.GetFunction().GetRawArguments(); args != "" { + atc.RawArguments.WriteString(args) } } - - atc := accToolCalls[idx] - if id := tc.GetId(); id != "" { - atc.Id = id - } - if name := tc.GetFunction().GetName(); name != "" { - atc.Name = name - } - if args := tc.GetFunction().GetRawArguments(); args != "" { - atc.RawArguments.WriteString(args) + } + if r := chunk.GetReasoningContent(); len(r) > 0 { + accumulatedReasoning.WriteString(r) + } + if content := chunk.GetContent(); len(content) > 0 { + accumulatedContent.WriteString(content) + // 尚未出现 tool_calls 时按 token 增量推送,避免最终结果整段一次性返回 + if onStream != nil && optimisticStream && !hasToolCalls { + streamedAnswer = true + if err := onStream(content); err != nil { + return err + } } } + return nil + }) + if err != nil { + return nil, errors.Wrapf(err, "chat stream round %d", round) } - if r := chunk.GetReasoningContent(); len(r) > 0 { - accumulatedReasoning.WriteString(r) - } - - content := chunk.GetContent() - if len(content) > 0 { - accumulatedContent.WriteString(content) - if onStream != nil { - if err := onStream(content); err != nil { - return err + if !hasToolCalls { + answer := accumulatedContent.String() + // 首轮对资源操作却不调工具:强制再试一轮,要求发 tool_calls + if round == 1 && resourceOp && !nudged && len(tools) > 0 { + nudged = true + log.Warningf("MCP agent round1 returned no tool_calls for resource op; nudging model") + if answer != "" { + messages = append(messages, llmClient.NewAssistantMessage(answer)) + } + messages = append(messages, llmClient.NewUserMessage( + "请立刻调用合适的 climc_* 工具完成我的请求,不要只描述计划或编造查询结果。若要创建虚拟机,请从 climc_cloud_region_list 开始连续调用直到 climc_server_create。", + )) + continue + } + // 未走增量推送时的兜底(例如首轮 nudge 关闭了 optimisticStream) + if onStream != nil && !streamedAnswer && answer != "" { + if err := onStream(answer); err != nil { + return nil, err } } + finalAnswer.WriteString(answer) + return &api.MCPAgentResponse{ + Success: true, + Answer: finalAnswer.String(), + ToolCalls: toolCallRecords, + }, nil } - return nil - }) - if err != nil { - return nil, errors.Wrap(err, "phase 1 chat stream error") - } - - // 检查是否有工具调用 - if !hasToolCalls { - // 如果阶段一没有调用工具,直接返回结果 - return &api.MCPAgentResponse{ - Success: true, - Answer: accumulatedContent.String(), - ToolCalls: toolCallRecords, - }, nil - } - - // Convert accumulated tool calls to ILLMToolCall - var toolCalls []ILLMToolCall - // Find max index - maxIdx := -1 - for idx := range accToolCalls { - if idx > maxIdx { - maxIdx = idx + toolCalls := make([]ILLMToolCall, 0) + maxIdx := -1 + for idx := range accToolCalls { + if idx > maxIdx { + maxIdx = idx + } } - } - - for i := 0; i <= maxIdx; i++ { - if atc, ok := accToolCalls[i]; ok { - var args map[string]interface{} + for i := 0; i <= maxIdx; i++ { + atc, ok := accToolCalls[i] + if !ok { + continue + } + args := make(map[string]interface{}) rawArgs := atc.RawArguments.String() if len(rawArgs) > 0 { if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { log.Errorf("Failed to unmarshal arguments for tool %s: %v. Raw: %s", atc.Name, err, rawArgs) args = make(map[string]interface{}) } - } else { - args = make(map[string]interface{}) } - toolCalls = append(toolCalls, &SLLMToolCall{ Id: atc.Id, Function: SLLMFunctionCall{ @@ -646,51 +708,75 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden }, }) } - } - log.Infof("Got %d tool calls from Phase 1", len(toolCalls)) + log.Infof("Round %d got %d tool calls", round, len(toolCalls)) - toolCallRecords, toolMessages, err := processToolCalls(ctx, toolCalls, accumulatedReasoning.String(), accumulatedContent.String(), mcpClient, llmClient) - if err != nil { - return nil, errors.Wrap(err, "process tool calls") + records, toolMessages, err := processToolCalls(ctx, toolCalls, accumulatedReasoning.String(), accumulatedContent.String(), mcpClient, llmClient, onStream, labels) + if err != nil { + return nil, errors.Wrap(err, "process tool calls") + } + toolCallRecords = append(toolCallRecords, records...) + messages = append(messages, toolMessages...) } - // 将工具调用相关的消息加入历史 - messages = append(messages, toolMessages...) - - log.Infof("Phase 2: Streaming Response...") - - var finalAnswer strings.Builder - - err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error { - content := chunk.GetContent() - if len(content) > 0 { - // 聚合最终答案 - finalAnswer.WriteString(content) - - // 实时流式输出 + // 工具轮次用尽后,再给模型一轮纯文本总结(不再传 tools),避免只回“达到上限”而不解释最后一次工具错误 + log.Infof("MCP agent tool rounds exhausted (%d); requesting final summary without tools", maxRounds) + messages = append(messages, llmClient.NewUserMessage( + "工具调用轮次已用尽。请根据上述工具返回结果,用中文向用户总结成功或失败原因;若创建失败请说明关键错误(如 sched_fail)与建议,不要再调用工具。", + )) + var summary strings.Builder + err = llmClient.ChatStream(ctx, mcp, messages, nil, func(chunk ILLMChatResponse) error { + if content := chunk.GetContent(); len(content) > 0 { + summary.WriteString(content) if onStream != nil { - if err := onStream(content); err != nil { - return err - } + return onStream(content) } } return nil }) - if err != nil { - return nil, errors.Wrap(err, "phase 2 stream error") + log.Warningf("MCP agent final summary failed: %v", err) + msg := fmt.Sprintf("已达到最大工具调用轮次(%d),请根据已有结果继续或缩小请求范围。", maxRounds) + if onStream != nil { + _ = onStream(msg) + } + return &api.MCPAgentResponse{ + Success: true, + Answer: msg, + ToolCalls: toolCallRecords, + }, nil + } + answer := strings.TrimSpace(summary.String()) + if answer == "" { + answer = fmt.Sprintf("已达到最大工具调用轮次(%d),请根据已有结果继续或缩小请求范围。", maxRounds) + if onStream != nil { + _ = onStream(answer) + } } - return &api.MCPAgentResponse{ Success: true, - Answer: finalAnswer.String(), + Answer: answer, ToolCalls: toolCallRecords, }, nil } -// buildSystemPrompt 构建系统提示词 +func looksLikeResourceOperation(msg string) bool { + m := strings.ToLower(msg) + keys := []string{ + "创建", "查询", "列出", "列表", "启动", "停止", "重启", "删除", "销毁", + "虚拟机", "主机", "镜像", "网络", "区域", "套餐", "规格", "密码", + "create", "list", "start", "stop", "restart", "delete", "server", "vm", + } + for _, k := range keys { + if strings.Contains(m, k) { + return true + } + } + return false +} + +// buildSystemPrompt 构建系统提示词(平台名来自 BaseOptions.PlatformName,支持热更新) func buildSystemPrompt() string { - return api.MCP_AGENT_SYSTEM_PROMPT + return fmt.Sprintf(api.MCP_AGENT_SYSTEM_PROMPT, options.ResolvedPlatformName()) } func processHistoryMessages( @@ -744,6 +830,8 @@ func processToolCalls( reasoningContent, content string, mcpClient *utils.MCPClient, llmClient ILLMClient, + onStream func(string) error, + labels *progressLabelCache, ) ([]api.MCPAgentToolCallRecord, []ILLMChatMessage, error) { toolCallRecords := make([]api.MCPAgentToolCallRecord, 0) messagesToAdd := make([]ILLMChatMessage, 0) @@ -775,6 +863,15 @@ func processToolCalls( Result: resultText, }) + labels.rememberFromTool(toolName, resultText) + + // 向用户流式展示资源选择/查询摘要,而不是工具名 + if onStream != nil { + if progress := summarizeToolProgress(toolName, arguments, resultText, labels); progress != "" { + _ = onStream(progress) + } + } + // 将工具执行结果加入历史 messagesToAdd = append(messagesToAdd, llmClient.NewToolMessage(tc.GetId(), toolName, resultText)) } diff --git a/pkg/llm/models/mcp_agent_progress.go b/pkg/llm/models/mcp_agent_progress.go new file mode 100644 index 0000000000..575e45976e --- /dev/null +++ b/pkg/llm/models/mcp_agent_progress.go @@ -0,0 +1,624 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +import ( + "encoding/json" + "fmt" + "strings" +) + +// progressLabelCache 缓存 list 结果中的 id→可读名称,供后续进度文案使用。 +type progressLabelCache struct { + regions map[string]string +} + +func newProgressLabelCache() *progressLabelCache { + return &progressLabelCache{regions: make(map[string]string)} +} + +func (c *progressLabelCache) rememberFromTool(toolName, resultText string) { + if c == nil || isToolResultError(resultText) { + return + } + name := strings.TrimPrefix(toolName, "climc_") + if strings.Contains(name, "cloud_region_list") { + c.rememberRegions(resultText) + } +} + +func (c *progressLabelCache) rememberRegions(resultText string) { + if c.regions == nil { + c.regions = make(map[string]string) + } + items, _ := extractListItems(resultText) + for _, item := range items { + id := jsonString(item, "id") + name := jsonString(item, "name") + if id == "" || name == "" { + continue + } + ext := jsonString(item, "external_id") + label := name + if ext != "" && !strings.EqualFold(ext, name) { + label = fmt.Sprintf("%s(%s)", name, ext) + } + c.regions[id] = label + c.regions[name] = label + if ext != "" { + c.regions[ext] = label + } + } +} + +func (c *progressLabelCache) regionLabel(idOrName string) string { + if idOrName == "" { + return "" + } + if c != nil && c.regions != nil { + if v := c.regions[idOrName]; v != "" { + return v + } + } + return idOrName +} + +// summarizeToolProgress 将工具调用结果整理成面向用户的进度文案(逐项展示资源,而非工具名)。 +func summarizeToolProgress(toolName string, args map[string]interface{}, resultText string, labels *progressLabelCache) string { + name := strings.TrimPrefix(toolName, "climc_") + if isToolResultError(resultText) { + return fmt.Sprintf("✗ %s失败:%s\n", progressLabel(name), truncateRunes(stripMCPHint(resultText), 180)) + } + + switch { + case name == "docs_search" || strings.HasSuffix(name, "docs_search"): + return formatResourceListProgress("文档", resultText, []string{"name", "title", "path"}) + + case name == "docs_get" || strings.HasSuffix(name, "docs_get"): + path := firstArg(args, "path", "PATH") + if path != "" { + return fmt.Sprintf("✓ 已阅读文档:%s\n", path) + } + return "✓ 已阅读文档\n" + + case strings.Contains(name, "cloud_region_capability"): + id := firstArg(args, "id", "ID", "name") + region := labels.regionLabel(id) + types := extractStorageTypeHints(resultText) + if region != "" && types != "" { + return fmt.Sprintf("✓ 区域 %s 可用磁盘类型:%s\n", region, types) + } + if types != "" { + return fmt.Sprintf("✓ 可用磁盘类型:%s\n", types) + } + return fmt.Sprintf("✓ 已查询区域能力%s\n", paren(region)) + + case strings.Contains(name, "cloud_region_list"): + return formatResourceListProgress("区域", resultText, []string{"name", "external_id", "id"}) + + case strings.Contains(name, "cached_image_list"), strings.Contains(name, "image_list"): + return formatResourceListProgress("镜像", resultText, []string{"name", "os_type", "os_distribution", "id"}) + + case strings.Contains(name, "server_sku_list"): + return formatResourceListProgress("套餐", resultText, []string{"name", "instance_type_category", "cpu_core_count", "memory_size_mb", "id"}) + + case strings.Contains(name, "network_list"): + return formatResourceListProgress("网络", resultText, []string{"name", "guest_ip_prefix", "vpc", "id"}) + + case strings.Contains(name, "vpc_list"): + return formatResourceListProgress("VPC", resultText, []string{"name", "cidr_block", "id"}) + + case strings.Contains(name, "storage_list"): + return formatResourceListProgress("存储", resultText, []string{"name", "storage_type", "capacity", "id"}) + + case strings.Contains(name, "server_list"): + return formatResourceListProgress("虚拟机", resultText, []string{"name", "status", "id"}) + + case strings.Contains(name, "server_create"): + return formatServerCreateProgress(args, resultText, labels) + + case strings.Contains(name, "server_show"): + return formatSingleResourceProgress("虚拟机详情", resultText, []string{"name", "status", "id"}) + + case strings.HasPrefix(name, "server_"): + id := firstArg(args, "id", "ID", "name") + action := strings.TrimPrefix(name, "server_") + return fmt.Sprintf("✓ 虚拟机%s%s\n", actionLabel(action), paren(id)) + + default: + return formatGenericProgress(name, args, resultText) + } +} + +func isToolResultError(resultText string) bool { + s := strings.TrimSpace(resultText) + return strings.Contains(s, "调用失败") || + strings.Contains(s, "返回错误") || + strings.HasPrefix(s, "工具 ") && strings.Contains(s, "失败") +} + +func progressLabel(toolName string) string { + switch { + case strings.Contains(toolName, "cloud_region_list"): + return "查询区域" + case strings.Contains(toolName, "cloud_region_capability"): + return "查询区域能力" + case strings.Contains(toolName, "cached_image"): + return "查询镜像" + case strings.Contains(toolName, "image_list"): + return "查询镜像" + case strings.Contains(toolName, "server_sku"): + return "查询套餐" + case strings.Contains(toolName, "network_list"): + return "查询网络" + case strings.Contains(toolName, "vpc_list"): + return "查询 VPC" + case strings.Contains(toolName, "server_create"): + return "创建虚拟机" + default: + return toolName + } +} + +func actionLabel(action string) string { + switch action { + case "start": + return "已启动" + case "stop": + return "已停止" + case "restart": + return "已重启" + case "delete": + return "已删除" + case "set_password", "set-password": + return "已重置密码" + default: + return "操作完成" + } +} + +func formatServerCreateProgress(args map[string]interface{}, resultText string, labels *progressLabelCache) string { + var b strings.Builder + b.WriteString("✓ 选用资源创建虚拟机:") + parts := make([]string, 0, 6) + if v := firstArg(args, "name", "NAME"); v != "" { + if isTruthy(args["generate-name"]) || isTruthy(args["generate_name"]) || isTruthy(args["GenerateName"]) { + parts = append(parts, "名称模板="+v+"(自动去重)") + } else { + parts = append(parts, "名称="+v) + } + } + if v := firstArg(args, "hypervisor"); v != "" { + parts = append(parts, "平台="+v) + } + if v := firstArg(args, "prefer-region", "prefer_region", "region"); v != "" { + parts = append(parts, "区域="+labels.regionLabel(v)) + } + if v := firstArg(args, "instance-type", "instance_type", "sku"); v != "" { + parts = append(parts, "规格="+v) + } + if v := firstArg(args, "ncpu"); v != "" { + parts = append(parts, "CPU="+v) + } + if v := firstArg(args, "mem-spec", "mem_spec"); v != "" { + parts = append(parts, "内存="+v) + } + if disks := argStringSlice(args, "disk"); len(disks) > 0 { + parts = append(parts, "磁盘="+truncateRunes(disks[0], 80)) + } + if nets := argStringSlice(args, "net"); len(nets) > 0 { + parts = append(parts, "网络="+strings.Join(nets, ",")) + } else { + parts = append(parts, "网络=自动调度") + } + b.WriteString(strings.Join(parts, ",")) + b.WriteByte('\n') + + body := stripMCPHint(resultText) + if obj := parseJSONObject(body); obj != nil { + status := jsonString(obj, "final_status") + sid := jsonString(obj, "server_id") + sname := "" + if sid == "" { + if srv, ok := obj["server"].(map[string]interface{}); ok { + sid = jsonString(srv, "id") + sname = jsonString(srv, "name") + if status == "" { + status = jsonString(srv, "status") + } + } + } else if srv, ok := obj["server"].(map[string]interface{}); ok { + sname = jsonString(srv, "name") + } + if waitErr := jsonString(obj, "wait_error"); waitErr != "" { + b.WriteString(fmt.Sprintf(" 创建未完成:%s\n", truncateRunes(waitErr, 160))) + if hint := jsonString(obj, "hint"); hint != "" { + b.WriteString(fmt.Sprintf(" 提示:%s\n", truncateRunes(hint, 160))) + } + return b.String() + } + if sid != "" || status != "" || sname != "" { + b.WriteString(" 结果:") + bits := make([]string, 0, 3) + if sname != "" { + bits = append(bits, "名称="+sname) + } + if sid != "" { + bits = append(bits, "id="+sid) + } + if status != "" { + bits = append(bits, "状态="+status) + } + b.WriteString(strings.Join(bits, ",")) + b.WriteByte('\n') + } + } + return b.String() +} + +func isTruthy(v interface{}) bool { + switch x := v.(type) { + case bool: + return x + case string: + s := strings.ToLower(strings.TrimSpace(x)) + return s == "true" || s == "1" || s == "yes" || s == "on" + case float64: + return x != 0 + case int: + return x != 0 + default: + return false + } +} + +func formatResourceListProgress(kind, resultText string, fields []string) string { + items, total := extractListItems(resultText) + if len(items) == 0 { + if total == 0 { + return fmt.Sprintf("✓ 未找到可用%s\n", kind) + } + return fmt.Sprintf("✓ 已查询%s(共 %d 条)\n", kind, total) + } + if total <= 0 { + total = len(items) + } + labels := make([]string, 0, 5) + for i, item := range items { + if i >= 5 { + break + } + labels = append(labels, formatItemLabel(item, fields)) + } + more := "" + if total > len(labels) { + more = fmt.Sprintf("等共 %d 个", total) + } else { + more = fmt.Sprintf("共 %d 个", total) + } + return fmt.Sprintf("✓ 已找到%s:%s(%s)\n", kind, strings.Join(labels, "、"), more) +} + +func formatSingleResourceProgress(kind, resultText string, fields []string) string { + obj := parseJSONObject(stripMCPHint(resultText)) + if obj == nil { + return fmt.Sprintf("✓ 已获取%s\n", kind) + } + return fmt.Sprintf("✓ %s:%s\n", kind, formatItemLabel(obj, fields)) +} + +func formatGenericProgress(toolName string, args map[string]interface{}, resultText string) string { + id := firstArg(args, "id", "ID", "name", "NAME") + items, total := extractListItems(resultText) + if len(items) > 0 { + return formatResourceListProgress(progressLabel(toolName), resultText, []string{"name", "id"}) + } + if id != "" { + return fmt.Sprintf("✓ %s完成%s\n", progressLabel(toolName), paren(id)) + } + _ = resultText + if total > 0 { + return fmt.Sprintf("✓ %s完成(%d 条)\n", progressLabel(toolName), total) + } + return fmt.Sprintf("✓ %s完成\n", progressLabel(toolName)) +} + +func formatItemLabel(item map[string]interface{}, fields []string) string { + parts := make([]string, 0, 3) + seen := map[string]bool{} + for _, f := range fields { + v := jsonString(item, f) + if v == "" || seen[v] { + continue + } + seen[v] = true + parts = append(parts, v) + if len(parts) >= 2 { + break + } + } + if len(parts) == 0 { + return "(未命名)" + } + if len(parts) == 1 { + return parts[0] + } + return fmt.Sprintf("%s(%s)", parts[0], parts[1]) +} + +func extractListItems(resultText string) ([]map[string]interface{}, int) { + body := stripMCPHint(resultText) + obj := parseJSONObject(body) + if obj != nil { + total := jsonInt(obj, "total") + if total <= 0 { + total = jsonInt(obj, "count") + } + for _, key := range []string{"data", "hits"} { + data, ok := obj[key].([]interface{}) + if !ok { + continue + } + items := make([]map[string]interface{}, 0, len(data)) + for _, d := range data { + if m, ok := d.(map[string]interface{}); ok { + items = append(items, m) + } + } + if total <= 0 { + total = len(items) + } + return items, total + } + // 单对象结果 + if jsonString(obj, "id") != "" || jsonString(obj, "name") != "" { + return []map[string]interface{}{obj}, 1 + } + } + if arr := parseJSONArray(body); len(arr) > 0 { + items := make([]map[string]interface{}, 0, len(arr)) + for _, d := range arr { + if m, ok := d.(map[string]interface{}); ok { + items = append(items, m) + } + } + return items, len(items) + } + return nil, 0 +} + +func extractStorageTypeHints(resultText string) string { + obj := parseJSONObject(stripMCPHint(resultText)) + if obj == nil { + return "" + } + for _, key := range []string{"storage_types2", "StorageTypes2"} { + raw, ok := obj[key] + if !ok { + continue + } + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + set := make([]string, 0, 8) + seen := map[string]bool{} + for _, v := range m { + arr, ok := v.([]interface{}) + if !ok { + continue + } + for _, x := range arr { + s, ok := x.(string) + if !ok || s == "" || seen[s] { + continue + } + seen[s] = true + if i := strings.Index(s, "/"); i > 0 { + s = s[:i] + } + set = append(set, s) + if len(set) >= 6 { + return strings.Join(set, "、") + } + } + } + if len(set) > 0 { + return strings.Join(set, "、") + } + } + return "" +} + +func stripMCPHint(s string) string { + s = strings.TrimSpace(s) + if i := strings.Index(s, "[MCP下一步]"); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + return s +} + +func parseJSONObject(s string) map[string]interface{} { + s = strings.TrimSpace(s) + if s == "" || s[0] != '{' { + // 可能前后有非 JSON 文本,尝试截取第一个对象 + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start < 0 || end <= start { + return nil + } + s = s[start : end+1] + } + var obj map[string]interface{} + if err := json.Unmarshal([]byte(s), &obj); err != nil { + return nil + } + return obj +} + +func parseJSONArray(s string) []interface{} { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + if s[0] != '[' { + start := strings.Index(s, "[") + end := strings.LastIndex(s, "]") + if start < 0 || end <= start { + return nil + } + s = s[start : end+1] + } + var arr []interface{} + if err := json.Unmarshal([]byte(s), &arr); err != nil { + return nil + } + return arr +} + +func firstArg(args map[string]interface{}, keys ...string) string { + if args == nil { + return "" + } + normalize := func(k string) string { + return strings.ReplaceAll(strings.ToLower(k), "_", "-") + } + for _, key := range keys { + if v, ok := args[key]; ok { + if s := stringifyArg(v); s != "" { + return s + } + } + want := normalize(key) + for k, v := range args { + if normalize(k) == want { + if s := stringifyArg(v); s != "" { + return s + } + } + } + } + return "" +} + +func argStringSlice(args map[string]interface{}, key string) []string { + if args == nil { + return nil + } + v, ok := args[key] + if !ok { + alt := strings.ReplaceAll(key, "-", "_") + v, ok = args[alt] + if !ok { + return nil + } + } + switch x := v.(type) { + case []string: + return x + case []interface{}: + out := make([]string, 0, len(x)) + for _, item := range x { + if s := stringifyArg(item); s != "" { + out = append(out, s) + } + } + return out + case string: + if x != "" { + return []string{x} + } + } + return nil +} + +func stringifyArg(v interface{}) string { + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + case float64: + if x == float64(int64(x)) { + return fmt.Sprintf("%d", int64(x)) + } + return fmt.Sprintf("%v", x) + case int: + return fmt.Sprintf("%d", x) + case int64: + return fmt.Sprintf("%d", x) + case bool: + return fmt.Sprintf("%v", x) + case []interface{}: + if len(x) == 0 { + return "" + } + return stringifyArg(x[0]) + case []string: + if len(x) == 0 { + return "" + } + return x[0] + default: + return strings.TrimSpace(fmt.Sprint(x)) + } +} + +func jsonString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + v, ok := m[key] + if !ok { + return "" + } + return stringifyArg(v) +} + +func jsonInt(m map[string]interface{}, key string) int { + if m == nil { + return 0 + } + v, ok := m[key] + if !ok { + return 0 + } + switch x := v.(type) { + case float64: + return int(x) + case int: + return x + case int64: + return int(x) + case json.Number: + n, _ := x.Int64() + return int(n) + default: + return 0 + } +} + +func paren(s string) string { + if s == "" { + return "" + } + return "(" + s + ")" +} + +func truncateRunes(s string, max int) string { + rs := []rune(strings.TrimSpace(s)) + if max <= 0 || len(rs) <= max { + return string(rs) + } + return string(rs[:max]) + "…" +} diff --git a/pkg/llm/models/mcp_agent_progress_test.go b/pkg/llm/models/mcp_agent_progress_test.go new file mode 100644 index 0000000000..0b12ad463a --- /dev/null +++ b/pkg/llm/models/mcp_agent_progress_test.go @@ -0,0 +1,69 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +import ( + "strings" + "testing" +) + +func TestSummarizeToolProgressRegionList(t *testing.T) { + result := `{ + "total": 2, + "data": [ + {"id": "r1", "name": "北京", "external_id": "cn-beijing"}, + {"id": "r2", "name": "上海", "external_id": "cn-shanghai"} + ] +} +[MCP下一步] ignore` + got := summarizeToolProgress("climc_cloud_region_list", nil, result, nil) + if !strings.Contains(got, "已找到区域") || !strings.Contains(got, "北京") { + t.Fatalf("got %q", got) + } + if strings.Contains(got, "正在调用工具") { + t.Fatalf("should not mention tool name style: %q", got) + } +} + +func TestSummarizeToolProgressCreate(t *testing.T) { + args := map[string]interface{}{ + "name": "ubuntu-22-04", + "hypervisor": "aliyun", + "prefer-region": "r1", + "instance-type": "ecs.t6-c1m1.large", + "disk": []interface{}{"size=30g,image=img1,backend=cloud_essd"}, + } + result := `{"server_id":"s1","final_status":"running","server":{"id":"s1","status":"running"}}` + got := summarizeToolProgress("climc_server_create", args, result, nil) + if !strings.Contains(got, "选用资源创建虚拟机") || !strings.Contains(got, "ubuntu-22-04") { + t.Fatalf("got %q", got) + } + if !strings.Contains(got, "状态=running") { + t.Fatalf("expected final status in %q", got) + } +} + +func TestSummarizeToolProgressCapability(t *testing.T) { + labels := newProgressLabelCache() + labels.regions["reg-1"] = "华东1(杭州)" + result := `{"storage_types2":{"aliyun":["cloud_essd/ssd","cloud_ssd/ssd"]}}` + got := summarizeToolProgress("climc_cloud_region_capability", map[string]interface{}{"id": "reg-1"}, result, labels) + if !strings.Contains(got, "cloud_essd") || !strings.Contains(got, "华东1(杭州)") { + t.Fatalf("got %q", got) + } + if strings.Contains(got, "reg-1") { + t.Fatalf("should show region name not id: %q", got) + } +} diff --git a/pkg/llm/options/option.go b/pkg/llm/options/option.go index 6647591ffb..abe17270cd 100644 --- a/pkg/llm/options/option.go +++ b/pkg/llm/options/option.go @@ -14,7 +14,11 @@ package options -import common_options "yunion.io/x/onecloud/pkg/cloudcommon/options" +import ( + "strings" + + common_options "yunion.io/x/onecloud/pkg/cloudcommon/options" +) type LLMOptions struct { common_options.CommonOptions @@ -49,10 +53,11 @@ type LLMOptions struct { // MCP Agent 配置 MCPServerURL string `help:"MCP Server URL" default:"http://default-mcp-server:30876"` - MCPAgentTimeout int `help:"MCP Agent request timeout in seconds" default:"120"` + MCPAgentTimeout int `help:"MCP Agent request timeout in seconds" default:"180"` MCPAgentUserCharLimit int `help:"MCP Agent user char limit" default:"3200"` MCPAgentAssistantCharLimit int `help:"MCP Agent assistant char limit" default:"6400"` + MCPAgentMaxToolRounds int `help:"Max MCP tool-call rounds per chat request" default:"16"` // LLM model catalog (browsable curated entries). Value can be either an // http(s) URL or a local file path; sources without an http:// or https:// @@ -73,3 +78,28 @@ type LLMOptions struct { var ( Options LLMOptions ) + +const DefaultPlatformName = "Cloudpods" + +// ResolvedPlatformName 返回配置中的平台展示名,空则回退 DefaultPlatformName。 +func ResolvedPlatformName() string { + name := strings.TrimSpace(Options.PlatformName) + if name == "" { + return DefaultPlatformName + } + return name +} + +func OnOptionsChange(oldO, newO interface{}) bool { + oldOpts := oldO.(*LLMOptions) + newOpts := newO.(*LLMOptions) + + changed := false + if common_options.OnCommonOptionsChange(&oldOpts.CommonOptions, &newOpts.CommonOptions) { + changed = true + } + if common_options.OnDBOptionsChange(&oldOpts.DBOptions, &newOpts.DBOptions) { + changed = true + } + return changed +} diff --git a/pkg/llm/service/service.go b/pkg/llm/service/service.go index cbfad6f885..8d77ed9b94 100644 --- a/pkg/llm/service/service.go +++ b/pkg/llm/service/service.go @@ -42,6 +42,7 @@ func StartService() { app_common.InitAuth(commonOpts, func() { log.Infof("Auth complete!!") }) + common_options.StartOptionManager(opts, opts.ConfigSyncPeriodSeconds, api.SERVICE_TYPE, api.SERVICE_VERSION, options.OnOptionsChange) app := app_common.InitApp(&opts.BaseOptions, false) diff --git a/pkg/llm/utils/mcp_client.go b/pkg/llm/utils/mcp_client.go index 32da17f4b6..fb07c799a7 100644 --- a/pkg/llm/utils/mcp_client.go +++ b/pkg/llm/utils/mcp_client.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "strings" "sync" @@ -32,6 +33,8 @@ import ( "yunion.io/x/log" "yunion.io/x/pkg/errors" + "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/llm/options" "yunion.io/x/onecloud/pkg/mcclient" "yunion.io/x/onecloud/pkg/mcclient/auth" ) @@ -60,21 +63,48 @@ type MCPClient struct { messageID int64 mu sync.Mutex initialized bool + closed atomic.Bool userCred mcclient.TokenCredential + // requestTimeout 单次 JSON-RPC(含 tools/call)等待 SSE 回包的上限。 + // 须覆盖 climc_server_create 的 forecast+等待(ServerCreateWaitSeconds),且小于整段 chat 超时。 + requestTimeout time.Duration + pendingReqs map[int64]chan *rawMCPResponse reqMu sync.Mutex } -// NewMCPClient 创建一个新的 MCP 客户端 +// NewMCPClient 创建一个新的 MCP 客户端。 +// timeout 为单次 RPC 等待 SSE 响应的超时;SSE 长连接本身不设整体 Timeout,避免读 body 被提前掐断。 func NewMCPClient(serverURL string, timeout time.Duration, userCred mcclient.TokenCredential) *MCPClient { + if timeout <= 0 { + timeout = time.Duration(options.Options.MCPAgentTimeout) * time.Second + } + if timeout <= 0 { + timeout = 3 * time.Minute + } return &MCPClient{ serverURL: strings.TrimSuffix(serverURL, "/"), client: &http.Client{ - Timeout: timeout, + Timeout: 0, + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext, + ResponseHeaderTimeout: 60 * time.Second, + }, }, - userCred: userCred, - pendingReqs: make(map[int64]chan *rawMCPResponse), + requestTimeout: timeout, + userCred: userCred, + pendingReqs: make(map[int64]chan *rawMCPResponse), + } +} + +// setAuthHeaders 将当前用户 token 写入请求头,供 mcp-server SSE/message 鉴权。 +func (c *MCPClient) setAuthHeaders(req *http.Request) { + if c.userCred == nil { + return + } + if tok := strings.TrimSpace(c.userCred.GetTokenString()); tok != "" { + req.Header.Set(identity.AUTH_TOKEN_HEADER, tok) } } @@ -88,6 +118,7 @@ func (c *MCPClient) connectSSE(ctx context.Context) error { } req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") + c.setAuthHeaders(req) resp, err := c.client.Do(req) if err != nil { @@ -95,8 +126,8 @@ func (c *MCPClient) connectSSE(ctx context.Context) error { } if resp.StatusCode != http.StatusOK { - resp.Body.Close() body, _ := io.ReadAll(resp.Body) + resp.Body.Close() return errors.Errorf("SSE connection failed with status %d: %s", resp.StatusCode, string(body)) } @@ -125,7 +156,8 @@ func (c *MCPClient) connectSSE(ctx context.Context) error { if err != nil { if !foundSession { initErr = err - } else { + } else if !isExpectedSSEClose(err) && !c.closed.Load() { + // 主动 Close / 对端正常结束时不刷告警 log.Warningf("SSE connection closed: %v", err) } return @@ -212,7 +244,7 @@ func (c *MCPClient) Initialize(ctx context.Context) error { ProtocolVersion: "2024-11-05", Capabilities: mcp.ClientCapabilities{}, ClientInfo: mcp.Implementation{ - Name: "cloudpods-mcp-agent", + Name: fmt.Sprintf("%s-mcp-agent", options.ResolvedPlatformName()), Version: "1.0.0", }, } @@ -318,7 +350,22 @@ func (c *MCPClient) sendRequest(ctx context.Context, req mcp.JSONRPCRequest) (*r return &mcpResp, nil } - // 如果响应为空,等待 SSE 推送 + // 如果响应为空,等待 SSE 推送(/message 常返回空 body,结果走 SSE) + wait := c.requestTimeout + if wait <= 0 { + wait = 3 * time.Minute + } + if deadline, ok := ctx.Deadline(); ok { + remain := time.Until(deadline) + if remain <= 0 { + return nil, ctx.Err() + } + if remain < wait { + wait = remain + } + } + timer := time.NewTimer(wait) + defer timer.Stop() select { case mcpResp := <-respChan: log.Debugf("MCP response (SSE): ID=%v", mcpResp.ID) @@ -328,8 +375,8 @@ func (c *MCPClient) sendRequest(ctx context.Context, req mcp.JSONRPCRequest) (*r return mcpResp, nil case <-ctx.Done(): return nil, ctx.Err() - case <-time.After(30 * time.Second): - return nil, errors.Error("timeout waiting for SSE response") + case <-timer.C: + return nil, errors.Errorf("timeout waiting for SSE response after %s", wait) } } @@ -423,14 +470,29 @@ func FormatToolResult(toolName string, result *mcp.CallToolResult, err error) st return GetToolResultText(result) } +// isExpectedSSEClose 判断是否为正常关闭(主动 Close / EOF / 连接已关) +func isExpectedSSEClose(err error) bool { + if err == nil { + return true + } + if errors.Cause(err) == io.EOF || errors.Cause(err) == net.ErrClosed { + return true + } + msg := err.Error() + return strings.Contains(msg, "use of closed network connection") || + strings.Contains(msg, "closed network connection") || + strings.Contains(msg, "http: read on closed response body") +} + // Close 关闭客户端连接 func (c *MCPClient) Close() error { c.mu.Lock() defer c.mu.Unlock() + c.closed.Store(true) c.initialized = false c.sessionURL = "" if c.sseBody != nil { - c.sseBody.Close() + _ = c.sseBody.Close() c.sseBody = nil } return nil diff --git a/pkg/mcclient/options/base.go b/pkg/mcclient/options/base.go index 95fdf856b9..ee60e035ba 100644 --- a/pkg/mcclient/options/base.go +++ b/pkg/mcclient/options/base.go @@ -198,27 +198,29 @@ const ( ListOrderDesc = "desc" ) +// BaseListOptions 通用列表参数。 +// 字段上的 mcp:"true" 表示该参数应对 AI/MCP 暴露(见 pkg/mcp-server/climcgen)。 type BaseListOptions struct { - Limit *int `default:"20" help:"Page limit"` - Offset *int `default:"0" help:"Page offset"` - OrderBy []string `help:"Name of the field to be ordered by"` - Order string `help:"List order" choices:"desc|asc"` - Details *bool `help:"Show more details" default:"false"` + Limit *int `default:"20" help:"Page limit" mcp:"true"` + Offset *int `default:"0" help:"Page offset" mcp:"true"` + OrderBy []string `help:"Name of the field to be ordered by" mcp:"true"` + Order string `help:"List order" choices:"desc|asc" mcp:"true"` + Details *bool `help:"Show more details" default:"false" mcp:"true"` ShowFailReason *bool `help:"show fail reason fields"` - Search string `help:"Filter results by a simple keyword search"` + Search string `help:"Filter results by a simple keyword search" mcp:"true"` Meta *bool `help:"Piggyback metadata information" json:"with_meta" token:"meta"` - Filter []string `help:"Filters"` + Filter []string `help:"Filters" mcp:"true"` JointFilter []string `help:"Filters with joint table col; joint_tbl.related_key(origin_key).filter_col.filter_cond(filters)"` FilterAny *bool `help:"If true, match if any of the filters matches; otherwise, match if all of the filters match"` - Admin *bool `help:"Is an admin call?"` - Tenant string `help:"Tenant ID or Name" alias:"project"` + Admin *bool `help:"Is an admin call?" mcp:"true"` + Tenant string `help:"Tenant ID or Name" alias:"project" mcp:"true"` ProjectDomain string `help:"Project domain filter"` User string `help:"User ID or Name"` - Field []string `help:"Show only specified fields"` - Scope string `help:"resource scope" choices:"system|domain|project|user"` + Field []string `help:"Show only specified fields" mcp:"true"` + Scope string `help:"resource scope" choices:"system|domain|project|user|max" mcp:"true"` - System *bool `help:"Show system resource"` + System *bool `help:"Show system resource" mcp:"true"` PendingDelete *bool `help:"Show only pending deleted resources"` PendingDeleteAll *bool `help:"Show also pending-deleted resources" json:"-"` DeleteAll *bool `help:"Show also deleted resources" json:"-"` @@ -245,7 +247,7 @@ type BaseListOptions struct { Manager []string `help:"List objects belonging to the cloud provider" json:"manager,omitempty"` Account string `help:"List objects belonging to the cloud account" json:"account,omitempty"` - Provider []string `help:"List objects from the provider" choices:"OneCloud|VMware|Aliyun|Apsara|Qcloud|Azure|Aws|Huawei|OpenStack|Ucloud|RockBase|VolcEngine|ZStack|Google|Ctyun|Cloudpods|Nutanix|BingoCloud|IncloudSphere|JDcloud|Proxmox|Ceph|CephFS|Ecloud|HCSO|HCS|HCSOP|H3C|S3|RemoteFile|Ksyun|Baidu|QingCloud|OracleCloud|SangFor|ZettaKit|UIS|CNWare" json:"provider,omitempty"` + Provider []string `help:"List objects from the provider;公有云创建时必传,例如 Aliyun、Aws、Huawei" choices:"OneCloud|VMware|Aliyun|Apsara|Qcloud|Azure|Aws|Huawei|OpenStack|Ucloud|RockBase|VolcEngine|ZStack|Google|Ctyun|Cloudpods|Nutanix|BingoCloud|IncloudSphere|JDcloud|Proxmox|Ceph|CephFS|Ecloud|HCSO|HCS|HCSOP|H3C|S3|RemoteFile|Ksyun|Baidu|QingCloud|OracleCloud|SangFor|ZettaKit|UIS|CNWare" json:"provider,omitempty" mcp:"true"` Brand []string `help:"List objects belonging to a special brand"` CloudEnv string `help:"Cloud environment" choices:"public|private|onpremise|private_or_onpremise" json:"cloud_env,omitempty"` PublicCloud *bool `help:"List objects belonging to public cloud" json:"public_cloud"` @@ -263,7 +265,7 @@ type BaseListOptions struct { Id []string `help:"filter by id"` // Name []string `help:"fitler by name"` - Status []string `help:"filter by status"` + Status []string `help:"filter by status" mcp:"true"` SummaryStats bool `help:"show summary stats" json:"summary_stats"` } diff --git a/pkg/mcclient/options/compute/cachedimage.go b/pkg/mcclient/options/compute/cachedimage.go index 9704d03938..88cd8bb825 100644 --- a/pkg/mcclient/options/compute/cachedimage.go +++ b/pkg/mcclient/options/compute/cachedimage.go @@ -21,14 +21,17 @@ import ( ) type CachedImageListOptions struct { + _ struct{} `mcp-desc:"【创建流程中的中间步骤】公有云/非KVM 选镜像。必须带 provider(如 [\"Aliyun\"]),region 必须传 climc_cloud_region_list 返回的 id(UUID),禁止传 cn-shanghai 这类云厂商 region code。不要用 ISO。查完后继续 network/sku,最后 climc_server_create"` + options.BaseListOptions - ImageType string `help:"image type" choices:"system|customized|shared|market"` + ImageType string `help:"image type;公有云系统盘常用 system" choices:"system|customized|shared|market" mcp:"true"` - Region string `help:"show images cached at cloud region"` - Zone string `help:"show images cached at zone"` + // Region 序列化为 cloudregion_id;优先传 cloudregion UUID + Region string `help:"cloudregion id(推荐)或 name;不要传 cn-shanghai 这类外部 region code" json:"cloudregion_id" mcp:"true"` + Zone string `help:"show images cached at zone" mcp:"true"` - HostSchedtagId string `help:"filter cached image with host schedtag"` - Valid *bool `help:"valid cachedimage"` + HostSchedtagId string `help:"filter cached image with host schedtag" mcp:"true"` + Valid *bool `help:"valid cachedimage" mcp:"true"` } func (opts *CachedImageListOptions) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/compute/cloudaccounts.go b/pkg/mcclient/options/compute/cloudaccounts.go index 6b038be8da..bd99700d59 100644 --- a/pkg/mcclient/options/compute/cloudaccounts.go +++ b/pkg/mcclient/options/compute/cloudaccounts.go @@ -26,12 +26,14 @@ import ( ) type CloudaccountListOptions struct { - baseoptions.BaseListOptions - Capability []string `help:"capability filter" choices:"project|compute|network|loadbalancer|objectstore|rds|cache|event|tablestore"` + _ struct{} `mcp-desc:"列出云账号。可用 search/provider/status 等过滤;详情用 climc_cloud_account_show,同步用 climc_cloud_account_sync"` - ReadOnly *bool `help:"filter read only account" negative:"no-read-only"` + baseoptions.BaseListOptions + Capability []string `help:"capability filter" choices:"project|compute|network|loadbalancer|objectstore|rds|cache|event|tablestore" mcp:"true"` + + ReadOnly *bool `help:"filter read only account" negative:"no-read-only" mcp:"true"` //DistinctField string `help:"distinct field"` - ProxySetting string `help:"Proxy setting id or name"` + ProxySetting string `help:"Proxy setting id or name" mcp:"true"` // 按宿主机数量排序 OrderByHostCount string // 按虚拟机数量排序 @@ -509,6 +511,13 @@ func (opts *SCloudAccountIdOptions) Params() (jsonutils.JSONObject, error) { return nil, nil } +// CloudaccountShowOptions 单独包装,避免 SCloudAccountIdOptions 被 delete/enable 等复用时误注册。 +type CloudaccountShowOptions struct { + _ struct{} `mcp-desc:"查询云账号详情(含 sync_status、provider、余额等)。ID 可用 climc_cloud_account_list 返回的 id/name"` + + SCloudAccountIdOptions +} + type SVMwareCloudAccountUpdateCredentialOptions struct { SCloudAccountIdOptions SUserPasswordCredential @@ -1182,9 +1191,18 @@ func (opts *CloudaccountUpdateCredentialOptions) Params() (jsonutils.JSONObject, } type CloudaccountSyncOptions struct { + _ struct{} `mcp-desc:"同步云账号资源(异步拉取公有云/私有云库存)。ID 用 climc_cloud_account_list 返回的 id/name;常用 force=true 强制同步,可用 region/resources 限定范围。调用后可用 climc_cloud_account_show 查看 sync_status"` + SCloudAccountIdOptions - api.SyncRangeInput + Force bool `help:"Force sync" json:"force" mcp:"true"` + FullSync bool `help:"Full sync" json:"full_sync" mcp:"true"` + DeepSync bool `help:"Deep sync" json:"deep_sync" mcp:"true"` + Xor bool `help:"Incremental xor sync mode" json:"xor" mcp:"true"` + Region []string `help:"Only sync specified regions" json:"region" mcp:"true"` + Zone []string `help:"Only sync specified zones" json:"zone" mcp:"true"` + Host []string `help:"Only sync specified hosts" json:"host" mcp:"true"` + Resources []string `help:"Resource types to sync" json:"resources" mcp:"true" choices:"project|compute|network|eip|loadbalancer|objectstore|rds|cache|event|cloudid|dnszone|public_ip|intervpcnetwork|saml_auth|quota|nat|nas|waf|mongodb|es|kafka|app|cdn|container|ipv6_gateway|tablestore|modelarts|vpcpeer|misc|image"` } func (opts *CloudaccountSyncOptions) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/compute/dbinstances.go b/pkg/mcclient/options/compute/dbinstances.go index 173f00ce2d..84f9cfa20f 100644 --- a/pkg/mcclient/options/compute/dbinstances.go +++ b/pkg/mcclient/options/compute/dbinstances.go @@ -73,10 +73,12 @@ func (opts *DBInstanceCreateOptions) Params() (jsonutils.JSONObject, error) { } type DBInstanceListOptions struct { + _ struct{} `mcp-desc:"列出 RDS(dbinstance)。可用 search/provider/region 等过滤;详情用 climc_dbinstance_show"` + options.BaseListOptions - BillingType string `help:"billing type" choices:"postpaid|prepaid"` - IpAddr []string - SecgroupId string + BillingType string `help:"billing type" choices:"postpaid|prepaid" mcp:"true"` + IpAddr []string `mcp:"true"` + SecgroupId string `mcp:"true"` } func (opts *DBInstanceListOptions) Params() (jsonutils.JSONObject, error) { @@ -95,6 +97,13 @@ func (opts *DBInstanceIdOptions) Params() (jsonutils.JSONObject, error) { return nil, nil } +// DBInstanceShowOptions 单独包装,避免 IdOptions 被 reboot/sync 等复用时误注册。 +type DBInstanceShowOptions struct { + _ struct{} `mcp-desc:"查询 RDS(dbinstance)详情。ID 可用 climc_dbinstance_list 返回的 id/name"` + + DBInstanceIdOptions +} + type DBInstanceRenewOptions struct { DBInstanceIdOptions DURATION string `help:"Duration of renew, ADMIN only command"` diff --git a/pkg/mcclient/options/compute/disks.go b/pkg/mcclient/options/compute/disks.go index 5f1ae5f4c6..fe5bc3acb3 100644 --- a/pkg/mcclient/options/compute/disks.go +++ b/pkg/mcclient/options/compute/disks.go @@ -92,6 +92,28 @@ func (o *DiskIdOptions) Params() (jsonutils.JSONObject, error) { return nil, nil } +// DiskShowOptions 单独包装,避免 DiskIdOptions 被其它 Perform 复用时误注册。 +type DiskShowOptions struct { + _ struct{} `mcp-desc:"查询单块硬盘详情。ID 可用 climc_disk_list 返回的 id/name"` + + DiskIdOptions +} + +type DiskResizeOptions struct { + _ struct{} `mcp-desc:"硬盘扩容。DISK 为硬盘 id/name,SIZE 为目标容量(如 100G);扩容前可用 climc_disk_list / climc_disk_show 确认"` + + DISK string `help:"ID or name of disk"` + SIZE string `help:"Size of disk"` +} + +type DiskDeleteOptions struct { + _ struct{} `mcp-desc:"删除硬盘。若尚不知 id,先用 climc_disk_list 定位;删除前确认未被虚机占用或已卸载"` + + ID []string `help:"ID of disks to delete" metavar:"DISK"` + OverridePendingDelete bool `help:"Delete disk directly instead of pending delete" short-token:"f" mcp:"true"` + DeleteSnapshots bool `help:"Delete disk snapshots before delete disk" mcp:"true"` +} + type DiskMigrateOptions struct { DiskIdOptions @@ -177,24 +199,26 @@ func (o *DiskRebuildOptions) Params() (jsonutils.JSONObject, error) { } type DiskListOptions struct { + _ struct{} `mcp-desc:"列出硬盘。可用 search/server-id/unused 等过滤;查详情用 climc_disk_show,扩容用 climc_disk_resize,删除用 climc_disk_delete"` + options.BaseListOptions - Unused *bool `help:"Show unused disks"` - Share *bool `help:"Show Share storage disks"` - Local *bool `help:"Show Local storage disks"` - ServerId []string `help:"Guest ID or name"` - GuestStatus string `help:"Guest Status"` + Unused *bool `help:"Show unused disks" mcp:"true"` + Share *bool `help:"Show Share storage disks" mcp:"true"` + Local *bool `help:"Show Local storage disks" mcp:"true"` + ServerId []string `help:"Guest ID or name" mcp:"true"` + GuestStatus string `help:"Guest Status" mcp:"true"` OrderByServer string `help:"Order By Server"` - Storage string `help:"Storage ID or name"` - Type string `help:"Disk type" choices:"sys|data|swap|volume"` - CloudType string `help:"Public cloud or private cloud" choices:"Public|Private"` + Storage string `help:"Storage ID or name" mcp:"true"` + Type string `help:"Disk type" choices:"sys|data|swap|volume" mcp:"true"` + CloudType string `help:"Public cloud or private cloud" choices:"Public|Private" mcp:"true"` OrderByGuestCount string `help:"Order By Guest Count"` - BillingType string `help:"billing type" choices:"postpaid|prepaid"` + BillingType string `help:"billing type" choices:"postpaid|prepaid" mcp:"true"` SnapshotpolicyId string `help:"snapshotpolicy id"` - StorageHostId string `help:"filter disk by host"` + StorageHostId string `help:"filter disk by host" mcp:"true"` BindingServerSnapshotpolicy *bool `help:"filter disk by binding server snapshotpolicy" negative:"no-binding-server-snapshotpolicy"` BindingSnapshotpolicy *bool `help:"filter disk by binding snapshotpolicy" negative:"no-binding-snapshotpolicy"` } diff --git a/pkg/mcclient/options/compute/eips.go b/pkg/mcclient/options/compute/eips.go index 6023eabaa4..d835bc55e2 100644 --- a/pkg/mcclient/options/compute/eips.go +++ b/pkg/mcclient/options/compute/eips.go @@ -21,18 +21,20 @@ import ( ) type ElasticipListOptions struct { - Region string `help:"List eips in cloudregion"` + _ struct{} `mcp-desc:"列出弹性公网 IP(EIP)。可用 search/region/usable 过滤;详情 climc_eip_show,绑虚机 climc_server_associate_eip"` - Usable *bool `help:"List all zones that is usable"` - UsableEipForAssociateType string `help:"With associate id filter which eip can associate" choices:"server|natgateway|loadbalancer"` - UsableEipForAssociateId string `help:"With associate type filter which eip can associate"` + Region string `help:"List eips in cloudregion" mcp:"true"` + + Usable *bool `help:"List all zones that is usable" mcp:"true"` + UsableEipForAssociateType string `help:"With associate id filter which eip can associate" choices:"server|natgateway|loadbalancer" mcp:"true"` + UsableEipForAssociateId string `help:"With associate type filter which eip can associate" mcp:"true"` OrderByIp string - AssociateId []string - AssociateType []string + AssociateId []string `mcp:"true"` + AssociateType []string `mcp:"true"` AssociateName []string - IsAssociated *bool + IsAssociated *bool `mcp:"true"` - IpAddr []string + IpAddr []string `mcp:"true"` options.BaseListOptions } @@ -40,6 +42,13 @@ func (opts *ElasticipListOptions) Params() (jsonutils.JSONObject, error) { return options.ListStructToParams(opts) } +// EipShowOptions 单独包装,避免 BaseShowOptions 被其它资源复用时误注册。 +type EipShowOptions struct { + _ struct{} `mcp-desc:"查询 EIP 详情。ID 可用 climc_eip_list 返回的 id/name"` + + options.BaseShowOptions +} + type EipCreateOptions struct { options.BaseCreateOptions Manager *string `help:"cloud provider"` diff --git a/pkg/mcclient/options/compute/elasticache.go b/pkg/mcclient/options/compute/elasticache.go index 222213fdb7..4752f5b560 100644 --- a/pkg/mcclient/options/compute/elasticache.go +++ b/pkg/mcclient/options/compute/elasticache.go @@ -24,9 +24,11 @@ import ( ) type ElasticCacheListOptions struct { + _ struct{} `mcp-desc:"列出 Redis/弹性缓存(elastic-cache)。可用 search 等过滤;详情用 climc_elastic_cache_show"` + options.BaseListOptions - SecgroupId string + SecgroupId string `mcp:"true"` } func (opts *ElasticCacheListOptions) Params() (jsonutils.JSONObject, error) { @@ -45,6 +47,13 @@ func (opts *ElasticCacheIdOption) Params() (jsonutils.JSONObject, error) { return nil, nil } +// ElasticCacheShowOptions 单独包装,避免 IdOption 被 restart/delete 等复用时误注册。 +type ElasticCacheShowOptions struct { + _ struct{} `mcp-desc:"查询 Redis/弹性缓存详情。ID 可用 climc_elastic_cache_list 返回的 id/name"` + + ElasticCacheIdOption +} + type ElasticCacheCreateOptions struct { NAME string Manager string diff --git a/pkg/mcclient/options/compute/host.go b/pkg/mcclient/options/compute/host.go index 878bd438f7..24ef79751a 100644 --- a/pkg/mcclient/options/compute/host.go +++ b/pkg/mcclient/options/compute/host.go @@ -22,29 +22,29 @@ import ( ) type HostListOptions struct { - Schedtag string `help:"List hosts in schedtag"` - Zone string `help:"List hosts in zone"` - Region string `help:"List hosts in region"` - Wire string `help:"List hosts in wire"` - Image string `help:"List hosts cached images" json:"cachedimage"` - Storage string `help:"List hosts attached to storages"` - Baremetal string `help:"List hosts that is managed by baremetal system" choices:"true|false"` + Schedtag string `help:"List hosts in schedtag" mcp:"true"` + Zone string `help:"List hosts in zone" mcp:"true"` + Region string `help:"List hosts in region" mcp:"true"` + Wire string `help:"List hosts in wire" mcp:"true"` + Image string `help:"List hosts cached images" json:"cachedimage" mcp:"true"` + Storage string `help:"List hosts attached to storages" mcp:"true"` + Baremetal string `help:"List hosts that is managed by baremetal system" choices:"true|false" mcp:"true"` Empty bool `help:"show empty host" json:"-"` Occupied bool `help:"show occupid host" json:"-"` Enabled bool `help:"Show enabled host only" json:"-"` Disabled bool `help:"Show disabled host only" json:"-"` - HostType string `help:"Host type filter" choices:"baremetal|hypervisor|esxi|container|hyperv|aliyun|azure|qcloud|aws|huawei|ucloud|google|ctyun"` + HostType string `help:"Host type filter" choices:"baremetal|hypervisor|esxi|container|hyperv|aliyun|azure|qcloud|aws|huawei|ucloud|google|ctyun" mcp:"true"` AnyMac string `help:"Mac matches one of the host's interface"` - AnyIp []string `help:"IP matches one of the host's interface"` + AnyIp []string `help:"IP matches one of the host's interface" mcp:"true"` HostStorageType []string `help:"List host in host_storage_type"` - IsBaremetal *bool `help:"filter host list by is_baremetal=true|false"` + IsBaremetal *bool `help:"filter host list by is_baremetal=true|false" mcp:"true"` - ResourceType string `help:"Resource type" choices:"shared|prepaid|dedicated"` + ResourceType string `help:"Resource type" choices:"shared|prepaid|dedicated" mcp:"true"` - Usable *bool `help:"List all zones that is usable"` + Usable *bool `help:"List all zones that is usable" mcp:"true"` - Hypervisor string `help:"filter hosts by hypervisor"` + Hypervisor string `help:"filter hosts by hypervisor" mcp:"true"` StorageNotAttached bool `help:"List hosts not attach specified storage"` @@ -105,12 +105,21 @@ func (opts *HostListOptions) Params() (jsonutils.JSONObject, error) { return params, nil } +// HostListForMcpOptions 单独包装,避免 HostListOptions 被 host-node-count 等复用时误注册。 +type HostListForMcpOptions struct { + _ struct{} `mcp-desc:"列出宿主机。可用 zone/region/host-type/search 过滤;详情用 climc_host_show"` + + HostListOptions +} + type HostShowOptions struct { + _ struct{} `mcp-desc:"查询宿主机详情。ID 可用 climc_host_list 返回的 id/name"` + options.BaseShowOptions - ShowMetadata bool `help:"Show host metadata in details"` - ShowNicInfo bool `help:"Show host nic_info in details"` - ShowSysInfo bool `help:"Show host sys_info in details"` - ShowAll bool `help:"Show all of host details" short-token:"a"` + ShowMetadata bool `help:"Show host metadata in details" mcp:"true"` + ShowNicInfo bool `help:"Show host nic_info in details" mcp:"true"` + ShowSysInfo bool `help:"Show host sys_info in details" mcp:"true"` + ShowAll bool `help:"Show all of host details" short-token:"a" mcp:"true"` } func (o *HostShowOptions) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/compute/kafka.go b/pkg/mcclient/options/compute/kafka.go index 1a6828fc90..3d0dda1af0 100644 --- a/pkg/mcclient/options/compute/kafka.go +++ b/pkg/mcclient/options/compute/kafka.go @@ -21,9 +21,18 @@ import ( ) type KafkaListOptions struct { + _ struct{} `mcp-desc:"列出 Kafka 实例。可用 search 等过滤;详情用 climc_kafka_show"` + options.BaseListOptions } +// KafkaShowOptions 单独包装,避免 IdOption 被 delete/syncstatus 复用时误注册。 +type KafkaShowOptions struct { + _ struct{} `mcp-desc:"查询 Kafka 实例详情。ID 可用 climc_kafka_list 返回的 id/name"` + + KafkaIdOption +} + func (opts *KafkaListOptions) Params() (jsonutils.JSONObject, error) { return options.ListStructToParams(opts) } diff --git a/pkg/mcclient/options/compute/keypairs.go b/pkg/mcclient/options/compute/keypairs.go index 335b0bcb63..f04c8cce4a 100644 --- a/pkg/mcclient/options/compute/keypairs.go +++ b/pkg/mcclient/options/compute/keypairs.go @@ -21,8 +21,10 @@ import ( ) type KeypairList struct { + _ struct{} `mcp-desc:"列出 SSH 密钥对(keypair)。可用 search/scheme 过滤"` + options.BaseListOptions - Scheme string `help:"Scheme of keypair, default is RSA" choices:"RSA|DSA|ECDSA|ED25519"` + Scheme string `help:"Scheme of keypair, default is RSA" choices:"RSA|DSA|ECDSA|ED25519" mcp:"true"` } func (self *KeypairList) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/compute/mongodb.go b/pkg/mcclient/options/compute/mongodb.go index c638dd707c..41278ac0e6 100644 --- a/pkg/mcclient/options/compute/mongodb.go +++ b/pkg/mcclient/options/compute/mongodb.go @@ -21,9 +21,18 @@ import ( ) type MongoDBListOptions struct { + _ struct{} `mcp-desc:"列出 MongoDB 实例。可用 search 等过滤;详情用 climc_mongodb_show"` + options.BaseListOptions } +// MongoDBShowOptions 单独包装,避免复用 BaseIdOptions 误注册。 +type MongoDBShowOptions struct { + _ struct{} `mcp-desc:"查询 MongoDB 实例详情。ID 可用 climc_mongodb_list 返回的 id/name"` + + options.BaseIdOptions +} + func (opts *MongoDBListOptions) Params() (jsonutils.JSONObject, error) { return options.ListStructToParams(opts) } diff --git a/pkg/mcclient/options/compute/network.go b/pkg/mcclient/options/compute/network.go index 8c8b2e966f..533374ea18 100644 --- a/pkg/mcclient/options/compute/network.go +++ b/pkg/mcclient/options/compute/network.go @@ -24,22 +24,24 @@ import ( ) type NetworkListOptions struct { + _ struct{} `mcp-desc:"【创建流程中的中间步骤】本工具不能完成创建。公有云须带 provider(如 [\"Aliyun\"])及 region;查完网络后凑齐同云镜像与规格,立刻 climc_server_create。严禁只调用本工具后就停止"` + options.BaseListOptions - Ip string `help:"search networks that contain this IP"` - ZoneIds []string `help:"search networks in zones"` + Ip string `help:"search networks that contain this IP" mcp:"true"` + ZoneIds []string `help:"search networks in zones" mcp:"true"` Wire string `help:"search networks belongs to a wire" json:"-"` - Host string `help:"search networks attached to a host"` - Vpc string `help:"search networks belongs to a VPC"` - Region string `help:"search networks belongs to a CloudRegion" json:"cloudregion"` + Host string `help:"search networks attached to a host" mcp:"true"` + Vpc string `help:"search networks belongs to a VPC" mcp:"true"` + Region string `help:"search networks belongs to a CloudRegion" json:"cloudregion" mcp:"true"` City string `help:"search networks belongs to a city"` - Usable *bool `help:"search usable networks"` - ServerType string `help:"search networks belongs to a ServerType" choices:"baremetal|container|eip|guest|ipmi|pxe|hostlocal"` + Usable *bool `help:"search usable networks" mcp:"true"` + ServerType string `help:"search networks belongs to a ServerType" choices:"baremetal|container|eip|guest|ipmi|pxe|hostlocal" mcp:"true"` Schedtag string `help:"filter networks by schedtag"` HostSchedtagId string `help:"filter by host schedtag"` - IsAutoAlloc *bool `help:"search network with is_auto_alloc"` + IsAutoAlloc *bool `help:"search network with is_auto_alloc" mcp:"true"` IsClassic *bool `help:"search classic on-premise network"` // Status string `help:"filter by network status"` diff --git a/pkg/mcclient/options/compute/secgroup.go b/pkg/mcclient/options/compute/secgroup.go index 6bb3824158..773235e8bf 100644 --- a/pkg/mcclient/options/compute/secgroup.go +++ b/pkg/mcclient/options/compute/secgroup.go @@ -28,18 +28,20 @@ import ( ) type SecgroupListOptions struct { + _ struct{} `mcp-desc:"列出安全组。可用 search/server/vpc-id 等过滤;详情 climc_secgroup_show,规则 climc_secgroup_rule_list"` + baseoptions.BaseListOptions - Equals string `help:"Secgroup ID or Name, filter secgroups whose rules equals the specified one"` - Server string `help:"Filter secgroups bound to specified server"` - Ip string `help:"Filter secgroup by ip"` - Ports string `help:"Filter secgroup by ports"` - Direction string `help:"Filter secgroup by ports" choices:"all|in|out"` - DBInstance string `help:"Filter secgroups bound to specified rds" json:"dbinstance"` - Cloudregion string `help:"Filter secgroups by region"` - VpcId string - Cloudaccount string `help:"Filter secgroups by account"` - LoadbalancerId string + Equals string `help:"Secgroup ID or Name, filter secgroups whose rules equals the specified one" mcp:"true"` + Server string `help:"Filter secgroups bound to specified server" mcp:"true"` + Ip string `help:"Filter secgroup by ip" mcp:"true"` + Ports string `help:"Filter secgroup by ports" mcp:"true"` + Direction string `help:"Filter secgroup by ports" choices:"all|in|out" mcp:"true"` + DBInstance string `help:"Filter secgroups bound to specified rds" json:"dbinstance" mcp:"true"` + Cloudregion string `help:"Filter secgroups by region" mcp:"true"` + VpcId string `mcp:"true"` + Cloudaccount string `help:"Filter secgroups by account" mcp:"true"` + LoadbalancerId string `mcp:"true"` } func (opts *SecgroupListOptions) Params() (jsonutils.JSONObject, error) { @@ -47,10 +49,12 @@ func (opts *SecgroupListOptions) Params() (jsonutils.JSONObject, error) { } type SecgroupCreateOptions struct { + _ struct{} `mcp-desc:"创建安全组。NAME 必填;可传 rules(安全规则字符串数组)与 vpc-id。创建后可用 climc_secgroup_rule_create 继续加规则"` + baseoptions.BaseCreateOptions - VpcId string - Tags []string - Rules []string `help:"security rule to create"` + VpcId string `mcp:"true"` + Tags []string `mcp:"true"` + Rules []string `help:"security rule to create" mcp:"true"` } func (opts *SecgroupCreateOptions) Params() (jsonutils.JSONObject, error) { @@ -95,6 +99,19 @@ func (opts *SecgroupIdOptions) Params() (jsonutils.JSONObject, error) { return nil, nil } +// SecgroupShowOptions / SecgroupDeleteOptions 单独包装,避免与其它 Perform 复用 IdOptions 时注册。 +type SecgroupShowOptions struct { + _ struct{} `mcp-desc:"查询安全组详情。ID 可用 climc_secgroup_list 返回的 id/name"` + + SecgroupIdOptions +} + +type SecgroupDeleteOptions struct { + _ struct{} `mcp-desc:"删除安全组。若尚不知 id,先用 climc_secgroup_list 定位;确认无虚机绑定后再删"` + + SecgroupIdOptions +} + type SecgroupMergeOptions struct { SecgroupIdOptions SECGROUPS []string `help:"source IDs or Names of secgroup"` diff --git a/pkg/mcclient/options/compute/secgrouprule.go b/pkg/mcclient/options/compute/secgrouprule.go index 1cde0e9f6e..56d14d9c3a 100644 --- a/pkg/mcclient/options/compute/secgrouprule.go +++ b/pkg/mcclient/options/compute/secgrouprule.go @@ -23,15 +23,17 @@ import ( ) type SecGroupRulesListOptions struct { + _ struct{} `mcp-desc:"列出安全组规则。建议传 secgroup(安全组 id/name);详情 climc_secgroup_rule_show,创建 climc_secgroup_rule_create,删除 climc_secgroup_rule_delete"` + options.BaseListOptions - Secgroup string `help:"Secgroup ID or Name"` - SecgroupName string `help:"Search rules by fuzzy secgroup name"` - Projects []string `help:"Filter rules by project"` - Direction string `help:"filter Direction of rule" choices:"in|out"` - Protocol string `help:"filter Protocol of rule" choices:"any|tcp|udp|icmp"` - Action string `help:"filter Actin of rule" choices:"allow|deny"` - Ports string `help:"filter Ports of rule"` - Ip string `help:"filter cidr of rule"` + Secgroup string `help:"Secgroup ID or Name" mcp:"true"` + SecgroupName string `help:"Search rules by fuzzy secgroup name" mcp:"true"` + Projects []string `help:"Filter rules by project" mcp:"true"` + Direction string `help:"filter Direction of rule" choices:"in|out" mcp:"true"` + Protocol string `help:"filter Protocol of rule" choices:"any|tcp|udp|icmp" mcp:"true"` + Action string `help:"filter Actin of rule" choices:"allow|deny" mcp:"true"` + Ports string `help:"filter Ports of rule" mcp:"true"` + Ip string `help:"filter cidr of rule" mcp:"true"` } func (opts *SecGroupRulesListOptions) Params() (jsonutils.JSONObject, error) { @@ -39,10 +41,25 @@ func (opts *SecGroupRulesListOptions) Params() (jsonutils.JSONObject, error) { } type SecGroupRulesCreateOptions struct { + _ struct{} `mcp-desc:"创建安全组规则。SECGROUP 为安全组 id/name,RULE 为规则字符串(如 in:allow tcp 22);可用 climc_secgroup_list 定位安全组"` + SECGROUP string `help:"Secgroup ID or Name" metavar:"Secgroup"` RULE string `json:"-"` - Priority int64 `help:"priority of Rule" default:"50"` - Desc string `help:"Description" json:"description"` + Priority int64 `help:"priority of Rule" default:"50" mcp:"true"` + Desc string `help:"Description" json:"description" mcp:"true"` +} + +// SecGroupRuleShowOptions / SecGroupRuleDeleteOptions 单独包装,避免复用 BaseShow/BaseId 误注册。 +type SecGroupRuleShowOptions struct { + _ struct{} `mcp-desc:"查询单条安全组规则详情。ID 可用 climc_secgroup_rule_list 返回的 id"` + + options.BaseShowOptions +} + +type SecGroupRuleDeleteOptions struct { + _ struct{} `mcp-desc:"删除安全组规则。若尚不知 id,先用 climc_secgroup_rule_list(建议带 secgroup)定位"` + + options.BaseIdOptions } func (opts *SecGroupRulesCreateOptions) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/compute/servers.go b/pkg/mcclient/options/compute/servers.go index b8f8361559..4e9257fd43 100644 --- a/pkg/mcclient/options/compute/servers.go +++ b/pkg/mcclient/options/compute/servers.go @@ -38,26 +38,28 @@ import ( var ErrEmtptyUpdate = errors.Error("No valid update data") type ServerListOptions struct { - Zone string `help:"Zone ID or Name"` + _ struct{} `mcp-desc:"当用户要求启动/停止/重启/删除/重置密码时:先用本工具定位目标,拿到返回结果中的 id 后,必须立刻继续调用对应操作工具完成操作,不要只查询就结束"` + + Zone string `help:"Zone ID or Name" mcp:"true"` Wire string `help:"Wire ID or Name"` - Network string `help:"Network ID or Name"` + Network string `help:"Network ID or Name" mcp:"true"` Disk string `help:"Disk ID or Name"` - Host string `help:"Host ID or Name"` + Host string `help:"Host ID or Name" mcp:"true"` Baremetal *bool `help:"Show baremetal servers"` Gpu *bool `help:"Show gpu servers"` Secgroup string `help:"Secgroup ID or Name"` AdminSecgroup string `help:"AdminSecgroup ID or Name"` - Hypervisor string `help:"Show server of hypervisor" choices:"kvm|esxi|pod|baremetal|aliyun|azure|aws|huawei|ucloud|volcengine|zstack|openstack|google|ctyun|incloudsphere|nutanix|bingocloud|cloudpods|ecloud|jdcloud|remotefile|h3c|hcs|hcso|hcsop|proxmox|ksyun|baidu|cucloud|qingcloud|sangfor|zettakit|uis|cnware"` - Region string `help:"Show servers in cloudregion"` + Hypervisor string `help:"Show server of hypervisor" choices:"kvm|esxi|pod|baremetal|aliyun|azure|aws|huawei|ucloud|volcengine|zstack|openstack|google|ctyun|incloudsphere|nutanix|bingocloud|cloudpods|ecloud|jdcloud|remotefile|h3c|hcs|hcso|hcsop|proxmox|ksyun|baidu|cucloud|qingcloud|sangfor|zettakit|uis|cnware" mcp:"true"` + Region string `help:"Show servers in cloudregion" mcp:"true"` WithEip *bool `help:"Show Servers with EIP"` WithoutEip *bool `help:"Show Servers without EIP"` - OsType string `help:"OS Type" choices:"linux|windows|vmware"` - Vpc []string `help:"Vpc id or name"` + OsType string `help:"OS Type" choices:"linux|windows|vmware" mcp:"true"` + Vpc []string `help:"Vpc id or name" mcp:"true"` UsableServerForEip string `help:"Eip id or name"` WithoutUserMeta *bool `help:"Show Servers without user metadata"` EipAssociable *bool `help:"Show Servers can associate with eip"` HostSn string `help:"Host SN"` - IpAddr string `help:"Fileter by ip"` + IpAddr string `help:"Fileter by ip" mcp:"true"` IpAddrs []string `help:"Fileter by ips"` OrderByDisk string `help:"Order by disk size" choices:"asc|desc"` @@ -129,6 +131,8 @@ func (o *ServerConvertToKvmOptions) Params() (jsonutils.JSONObject, error) { } type ServerStartOptions struct { + _ struct{} `mcp-desc:"用户要求启动时必须调用本工具真正执行,仅调用 climc_server_list 查询不算完成。若尚不知 id,先用 climc_server_list(可用 search)定位,拿到 id 后立即调用本工具,不要只查询就结束"` + ServerIdsOptions QemuVersion string `help:"prefer qemu version" json:"qemu_version"` @@ -139,6 +143,16 @@ func (o *ServerStartOptions) Params() (jsonutils.JSONObject, error) { return jsonutils.Marshal(o), nil } +// ServerStartRescueOptions 与启动参数相同,单独包装以免继承 server-start 的 mcp-desc。 +type ServerStartRescueOptions struct { + ServerStartOptions +} + +// ServerStopRescueOptions 与启动参数相同,单独包装以免继承 server-start 的 mcp-desc。 +type ServerStopRescueOptions struct { + ServerStartOptions +} + type ServerIdsOptions struct { ID []string `help:"ID of servers to operate" metavar:"SERVER" json:"-"` } @@ -195,6 +209,8 @@ func (o *ServerCreateBackupOptions) Params() (jsonutils.JSONObject, error) { } type ServerShowOptions struct { + _ struct{} `mcp-desc:"查询单台虚拟机详情。ID 可用 climc_server_list 返回的 id/name;查看状态、配置、IP 等信息时使用"` + options.BaseShowOptions `id->help:"ID or name of the server"` } @@ -250,22 +266,22 @@ func ParseServerDeployInfoList(list []string) ([]*computeapi.DeployConfig, error } type ServerCreateCommonConfig struct { - Manager string `help:"Preferred cloudprovider where virtual server should bd created" json:"prefer_manager"` - Region string `help:"Preferred region where virtual server should be created" json:"prefer_region"` - Zone string `help:"Preferred zone where virtual server should be created" json:"prefer_zone"` + Manager string `help:"Preferred cloudprovider where virtual server should bd created" json:"prefer_manager" mcp:"true"` + Region string `help:"Preferred region where virtual server should be created" json:"prefer_region" mcp:"true"` + Zone string `help:"Preferred zone where virtual server should be created" json:"prefer_zone" mcp:"true"` Zones []string `help:"Preferred zones where virtual server should be created" json:"prefer_zones"` Wire string `help:"Preferred wire where virtual server should be created" json:"prefer_wire"` - Host string `help:"Preferred host where virtual server should be created" json:"prefer_host"` + Host string `help:"Preferred host where virtual server should be created" json:"prefer_host" mcp:"true"` ResourceType string `help:"Resource type" choices:"shared|prepaid|dedicated"` Schedtag []string `help:"Schedule policy, key = aggregate name, value = require|exclude|prefer|avoid" metavar:""` - Net []string `help:"Network descriptions" metavar:"NETWORK"` + Net []string `help:"Network descriptions;可省略,省略时 MCP 自动用 random(等价 nets:[{exit:false}])走自动调度" metavar:"NETWORK" mcp:"true"` NetPortMapping []string `help:"Network port mapping, e.g. 'index=0,port=80,host_port=8080,protocol=,host_port_range=-,remote_ips=x.x.x.x|y.y.y.y'" short-token:"p"` NetSchedtag []string `help:"Network schedtag description, e.g. '0::'"` IsolatedDevice []string `help:"Isolated device model or ID" metavar:"ISOLATED_DEVICE"` - Project string `help:"'Owner project ID or Name" json:"tenant"` + Project string `help:"'Owner project ID or Name" json:"tenant" mcp:"true"` User string `help:"Owner user ID or Name"` - Count int `help:"Create multiple simultaneously" default:"1"` + Count int `help:"Create multiple simultaneously" default:"1" mcp:"true"` Disk []string `help:" Disk descriptions size: 500M, 10G @@ -278,16 +294,17 @@ type ServerCreateCommonConfig struct { medium: rotate, ssd, hybrid disk_type: sys, data mountpoint: /, /opt - storage_type: local, rbd, nas, nfs + storage_type/backend: local, rbd, nas, nfs;公有云用 cloud-region-capability 的 storage_types2(如 cloud_essd) snapshot_id: use snapshot-list get snapshot id disk_id: use disk-list get disk id storage_id: use storage-list get storage id - image_id: use image-list get image id + image_id: use image-list/cached-image-list get image id for example: --disk 'image_id=c2be02a4-7ff2-43e6-8a00-a489e04d2d6f,size=10G,driver=ide,storage_type=rbd,auto_delete=true' + --disk 'size=40g,image=,backend=cloud_essd' --disk 'size=500M' --disk 'snpahost_id=1ceb8c6d-6571-451d-8957-4bd3a871af85' - " nargs:"+"` + " nargs:"+" mcp:"true"` DiskSchedtag []string `help:"Disk schedtag description, e.g. '0::'"` } @@ -372,7 +389,7 @@ func (o ServerCreateCommonConfig) Data() (*computeapi.ServerConfigs, error) { type ServerConfigs struct { ServerCreateCommonConfig - Hypervisor string `help:"Hypervisor type" choices:"kvm|pod|esxi|baremetal|container|aliyun|azure|qcloud|aws|huawei|openstack|ucloud|volcengine|zstack|google|ctyun|incloudsphere|bingocloud|cloudpods|ecloud|jdcloud|remotefile|h3c|hcs|hcso|hcsop|proxmox|sangfor|zettakit|uis"` + Hypervisor string `help:"Hypervisor type" choices:"kvm|pod|esxi|baremetal|container|aliyun|azure|qcloud|aws|huawei|openstack|ucloud|volcengine|zstack|google|ctyun|incloudsphere|bingocloud|cloudpods|ecloud|jdcloud|remotefile|h3c|hcs|hcso|hcsop|proxmox|sangfor|zettakit|uis" mcp:"true"` Backup bool `help:"Create server with backup server"` BackupHost string `help:"Perfered host where virtual backup server should be created"` AutoSwitchToBackupOnHostDown bool `help:"Auto switch to backup server on host down"` @@ -449,30 +466,32 @@ type ServerCreateFromInstanceSnapshot struct { } type ServerCreateOptions struct { + _ struct{} `mcp-desc:"【创建虚拟机的最终动作】自动 scheduler-forecast→创建→等待 running/ready。最少:name、disk(image+backend)、规格。超时返回 wait_pending+server_id,用 climc_server_show 续查,勿重复创建"` + ServerCreateOptionalOptions - NAME string `help:"Name of server" json:"-"` + NAME string `help:"虚拟机名称模板;默认配合 generate-name 自动去重" json:"-" mcp:"required"` } type ServerCreateOptionalOptions struct { ServerConfigs - MemSpec string `help:"Memory size Or Instance Type" metavar:"MEMSPEC" json:"-"` + MemSpec string `help:"Memory size Or Instance Type" metavar:"MEMSPEC" json:"-" mcp:"true"` CpuSockets int `help:"Cpu sockets"` EnableMemclean bool `help:"clean guest memory after guest exit" json:"enable_memclean"` EnableTpm bool `help:"enable tpm device" json:"enable_tpm"` - Keypair string `help:"SSH Keypair"` - Password string `help:"Default user password"` - LoginAccount string `help:"Guest login account"` - Iso string `help:"ISO image ID" metavar:"IMAGE_ID" json:"cdrom"` + Keypair string `help:"SSH Keypair" mcp:"true"` + Password string `help:"Default user password" mcp:"true"` + LoginAccount string `help:"Guest login account" mcp:"true"` + Iso string `help:"ISO image ID" metavar:"IMAGE_ID" json:"cdrom" mcp:"true"` IsoBootIndex *int8 `help:"Iso bootindex" metavar:"IMAGE_BOOT_INDEX" json:"cdrom_boot_index"` - VcpuCount int `help:"#CPU cores of VM server, default 1" default:"1" metavar:"" json:"vcpu_count" token:"ncpu"` + VcpuCount int `help:"#CPU cores of VM server, default 1" default:"1" metavar:"" json:"vcpu_count" token:"ncpu" mcp:"true"` ExtraCpuCount int `help:"Extra allocate cpu count" json:"extra_cpu_count"` - InstanceType string `help:"instance flavor"` + InstanceType string `help:"instance flavor" mcp:"true"` Vga string `help:"VGA driver" choices:"std|vmware|cirrus|qxl|virtio"` Vdi string `help:"VDI protocool" choices:"vnc|spice"` - Bios string `help:"BIOS" choices:"BIOS|UEFI"` + Bios string `help:"BIOS" choices:"BIOS|UEFI" mcp:"true"` Machine string `help:"Machine type" choices:"pc|q35"` Desc string `help:"Description" metavar:"" json:"description"` Boot string `help:"Boot device" metavar:"" choices:"disk|cdrom" json:"-"` @@ -480,20 +499,20 @@ type ServerCreateOptionalOptions struct { NoAccountInit *bool `help:"Not reset account password"` AllowDelete *bool `help:"Unlock server to allow deleting" json:"-"` ShutdownBehavior string `help:"Behavior after VM server shutdown" metavar:"" choices:"stop|terminate|stop_release_gpu"` - AutoStart bool `help:"Auto start server after it is created"` + AutoStart bool `help:"Auto start server after it is created" mcp:"true"` Deploy []string `help:"Specify deploy files in virtual server file system" json:"-"` DeployTelegraf bool `help:"Deploy telegraf agent if guest os is supported"` Group []string `help:"Group ID or Name of virtual server"` System bool `help:"Create a system VM, sysadmin ONLY option" json:"is_system"` TaskNotify *bool `help:"Setup task notify" json:"-"` FakeCreate *bool `help:"Fake create server"` - DryRun *bool `help:"Dry run to test scheduler" json:"-"` + DryRun *bool `help:"Dry run to validate create params (not preschedule);MCP 创建会自动调 scheduler-forecast 预调度,一般无需手动传" json:"-" mcp:"true"` UserDataFile string `help:"user_data file path" json:"-"` InstanceSnapshot string `help:"instance snapshot" json:"instance_snapshot"` Secgroups []string `help:"secgroups" json:"secgroups"` NetworkTags []string `help:"GCP network tags, google only; when set, secgroups can be omitted" json:"network_tags"` - OsType string `help:"os type, e.g. Linux, Windows, etc."` + OsType string `help:"os type, e.g. Linux, Windows, etc." mcp:"true"` Duration string `help:"valid duration of the server, e.g. 1H, 1D, 1W, 1M, 1Y, ADMIN ONLY option"` AutoRenew bool `help:"auto renew for prepaid server"` @@ -508,7 +527,7 @@ type ServerCreateOptionalOptions struct { KickstartMaxRetries int `help:"Kickstart max retries" default:"3" json:"-"` KickstartTimeoutMinutes int `help:"Kickstart timeout in minutes" default:"60" json:"-"` - GenerateName bool `help:"name is generated by pattern" json:"-"` + GenerateName bool `help:"name is generated by pattern" json:"-" mcp:"true"` EipBw int `help:"allocate EIP with bandwidth in MB when server is created" json:"eip_bw,omitzero"` EipTxBw int `help:"allocate EIP with上行带宽 in MB when server is created" json:"eip_tx_bw,omitzero"` @@ -520,7 +539,7 @@ type ServerCreateOptionalOptions struct { PublicIpBw int `help:"associate public ip with bandwidth in MB where server is created" json:"public_ip_bw,omitzero"` PublicIpChargeType string `help:"newly allocated public ip charge type" choices:"traffic|bandwidth" json:"public_ip_charge_type,omitempty"` - GuestImageID string `help:"create from guest image, need to specify the guest image id"` + GuestImageID string `help:"create from guest image, need to specify the guest image id" mcp:"true"` EncryptKey string `help:"encryption key"` Tags []string `help:"tags in the form of key=value"` @@ -760,9 +779,11 @@ func (opts *ServerCreateOptions) Params() (*computeapi.ServerCreateInput, error) } type ServerStopOptions struct { + _ struct{} `mcp-desc:"用户要求停止时必须调用本工具真正执行,仅调用 climc_server_list 查询不算完成。若尚不知 id,先用 climc_server_list(可用 search)定位,拿到 id 后立即调用本工具,不要只查询就结束"` + ID []string `help:"ID or Name of server" json:"-"` - Force *bool `help:"Stop server forcefully" json:"is_force"` - StopCharging *bool `help:"Stop charging when server stop"` + Force *bool `help:"Stop server forcefully" json:"is_force" mcp:"true"` + StopCharging *bool `help:"Stop charging when server stop" mcp:"true"` } func (o *ServerStopOptions) GetIds() []string { @@ -821,12 +842,14 @@ func (opts *ServerUpdateOptions) Params() (jsonutils.JSONObject, error) { } type ServerDeleteOptions struct { + _ struct{} `mcp-desc:"用户要求删除时必须调用本工具真正执行,仅调用 climc_server_list 查询不算完成。若尚不知 id,先用 climc_server_list(可用 search)定位,拿到 id 后立即调用本工具,不要只查询就结束"` + ServerIdsOptions - OverridePendingDelete *bool `help:"Delete server directly instead of pending delete" short-token:"f"` - DeleteSnapshots *bool `help:"Delete server snapshots"` - DeleteDisks *bool `help:"Delete server disks"` - DeleteEip *bool `help:"Delete eip"` - DeleteBastionServer *bool `help:"Remove from bastion host"` + OverridePendingDelete *bool `help:"Delete server directly instead of pending delete" short-token:"f" mcp:"true"` + DeleteSnapshots *bool `help:"Delete server snapshots" mcp:"true"` + DeleteDisks *bool `help:"Delete server disks" mcp:"true"` + DeleteEip *bool `help:"Delete eip" mcp:"true"` + DeleteBastionServer *bool `help:"Remove from bastion host" mcp:"true"` } func (o *ServerDeleteOptions) QueryParams() (jsonutils.JSONObject, error) { @@ -999,9 +1022,11 @@ func (o *ServerKickstartCompleteOptions) Params() (jsonutils.JSONObject, error) } type ServerMonitorOptions struct { + _ struct{} `mcp-desc:"【QEMU Monitor,不是监控指标】向虚机发送 HMP/QMP。查 CPU/内存等指标请用 climc_monitor_unifiedmonitor_query"` + ServerIdOptions - Qmp bool `help:"Use qmp protocol, default is hmp"` + Qmp bool `help:"Use qmp protocol, default is hmp" mcp:"true"` COMMAND string `help:"Qemu Monitor command to send"` } @@ -1058,12 +1083,14 @@ func (o *ServerQgaGetNetwork) Params() (jsonutils.JSONObject, error) { } type ServerSetPasswordOptions struct { + _ struct{} `mcp-desc:"用户要求重置密码时必须调用本工具真正执行,仅调用 climc_server_list 查询不算完成。若尚不知 id,先用 climc_server_list(可用 search)定位,拿到 id 后立即调用本工具,不要只查询就结束"` + ServerIdOptions - Username string `help:"Which user to set password" json:"username"` - Password string `help:"Password content" json:"password"` - ResetPassword bool `help:"Force reset password"` - AutoStart bool `help:"Auto start server after reset password"` + Username string `help:"Which user to set password" json:"username" mcp:"true"` + Password string `help:"Password content" json:"password" mcp:"required"` + ResetPassword bool `help:"Force reset password" mcp:"true"` + AutoStart bool `help:"Auto start server after reset password" mcp:"true"` } func (o *ServerSetPasswordOptions) Params() (jsonutils.JSONObject, error) { @@ -1151,14 +1178,16 @@ func (o *ServerChangeOwnerOptions) Params() (jsonutils.JSONObject, error) { } type ServerRebuildRootOptions struct { + _ struct{} `mcp-desc:"重装系统盘。ID 为虚机 id;可选 image、password、auto-start。先 climc_server_list / climc_image_list 定位"` + ID string `help:"Server to rebuild root" json:"-"` - ImageId string `help:"New root Image template ID" json:"image_id" token:"image"` - Keypair string `help:"ssh Keypair used for login"` + ImageId string `help:"New root Image template ID" json:"image_id" token:"image" mcp:"true"` + Keypair string `help:"ssh Keypair used for login" mcp:"true"` Password string `help:"Default user password"` - LoginAccount string `help:"Guest login account"` - NoAccountInit *bool `help:"Not reset account password"` - AutoStart *bool `help:"Auto start server after it is created"` - AllDisks *bool `help:"Rebuild all disks including data disks"` + LoginAccount string `help:"Guest login account" mcp:"true"` + NoAccountInit *bool `help:"Not reset account password" mcp:"true"` + AutoStart *bool `help:"Auto start server after it is created" mcp:"true"` + AllDisks *bool `help:"Rebuild all disks including data disks" mcp:"true"` UserData string `hlep:"user data scripts"` } @@ -1185,16 +1214,18 @@ func (o *ServerRebuildRootOptions) Description() string { } type ServerChangeConfigOptions struct { + _ struct{} `mcp-desc:"调整虚机配置(CPU/内存/套餐)。ID 用 climc_server_list;传 ncpu/vmem 或 instance-type。改配前确认状态允许"` + ServerIdOptions - VcpuCount *int `help:"New number of Virtual CPU cores" json:"vcpu_count" token:"ncpu"` - ExtraCpuCount *int `help:"Extra allocate cpu count" json:"extra_cpu_count"` - CpuSockets *int `help:"Cpu sockets"` - VmemSize string `help:"New memory size" json:"vmem_size" token:"vmem"` - Disk []string `help:"Data disk description, from the 1st data disk to the last one, empty string if no change for this data disk"` + VcpuCount *int `help:"New number of Virtual CPU cores" json:"vcpu_count" token:"ncpu" mcp:"true"` + ExtraCpuCount *int `help:"Extra allocate cpu count" json:"extra_cpu_count" mcp:"true"` + CpuSockets *int `help:"Cpu sockets" mcp:"true"` + VmemSize string `help:"New memory size" json:"vmem_size" token:"vmem" mcp:"true"` + Disk []string `help:"Data disk description, from the 1st data disk to the last one, empty string if no change for this data disk" mcp:"true"` - InstanceType string `help:"Instance Type, e.g. S2.SMALL2 for qcloud"` + InstanceType string `help:"Instance Type, e.g. S2.SMALL2 for qcloud" mcp:"true"` - ForceStop *bool `help:"Force stop the server before changing config" json:"force_stop"` + ForceStop *bool `help:"Force stop the server before changing config" json:"force_stop" mcp:"true"` ResetTrafficLimits []string `help:"reset traffic limits, mac,rx,tx"` SetTrafficLimits []string `help:"set traffic limits, mac,rx,tx"` @@ -1301,8 +1332,10 @@ func (o *ServerResetOptions) Params() (jsonutils.JSONObject, error) { } type ServerRestartOptions struct { + _ struct{} `mcp-desc:"用户要求重启时必须调用本工具真正执行,仅调用 climc_server_list 查询不算完成。若尚不知 id,先用 climc_server_list(可用 search)定位,拿到 id 后立即调用本工具,不要只查询就结束"` + ID []string `help:"ID of servers to operate" metavar:"SERVER" json:"-"` - IsForce *bool `help:"Force reset or not; default false" json:"is_force"` + IsForce *bool `help:"Force reset or not; default false" json:"is_force" mcp:"true"` } func (o *ServerRestartOptions) GetIds() []string { @@ -1408,8 +1441,10 @@ func (opts *ServerBatchMetadataOptions) Params() (jsonutils.JSONObject, error) { } type ServerAssociateEipOptions struct { + _ struct{} `mcp-desc:"将 EIP 绑定到虚机。需虚机 ID 与 EIP(climc_eip_list 的 id/name)"` + ServerIdOptions - EIP string `help:"ID or name of EIP to associate"` + EIP string `help:"ID or name of EIP to associate" mcp:"required"` } func (o *ServerAssociateEipOptions) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/compute/serverskus.go b/pkg/mcclient/options/compute/serverskus.go index bd990bebad..d528836345 100644 --- a/pkg/mcclient/options/compute/serverskus.go +++ b/pkg/mcclient/options/compute/serverskus.go @@ -15,25 +15,34 @@ package compute import ( + "fmt" + "regexp" + "strconv" + "strings" + "yunion.io/x/jsonutils" baseoptions "yunion.io/x/onecloud/pkg/mcclient/options" ) type ServerSkusListOptions struct { + _ struct{} `mcp-desc:"【创建流程中间步骤】查规格。口语 2c2g 用 spec;公有云须 provider+cloudregion。取 name 作 instance-type 后立刻 climc_server_create"` + baseoptions.BaseListOptions - Cloudregion string `help:"region Id or name"` - Usable bool `help:"Filter usable sku"` - Zone string `help:"zone Id or name"` - City *string `help:"city name,eg. BeiJing"` - Cpu *int `help:"Cpu core count" json:"cpu_core_count"` - Mem *int `help:"Memory size in MB" json:"memory_size_mb"` - Name string `help:"Name of Sku"` - PostpaidStatus string `help:"Postpaid status" choices:"soldout|available"` - PrepaidStatus string `help:"Prepaid status" choices:"soldout|available"` - CpuArch string `help:"Cpu Arch" choices:"x86|arm"` - Enabled *bool `help:"Filter enabled skus"` - Distinct bool `help:"distinct sku by name"` + Cloudregion string `help:"region Id or name" mcp:"true"` + Usable bool `help:"Filter usable sku" mcp:"true"` + Zone string `help:"zone Id or name" mcp:"true"` + City *string `help:"city name,eg. BeiJing"` + // Spec 口语规格,如 2c2g / 2核2G / 4C8G;会解析为 cpu_core_count + memory_size_mb + Spec string `help:"Human spec like 2c2g / 2核2G / 4C8G; expands to cpu+mem(MB)" json:"-" mcp:"true"` + Cpu *int `help:"Cpu core count;用户说2核时传2。也可改用 --spec 2c2g" json:"cpu_core_count" mcp:"true"` + Mem *int `help:"Memory size in MB;2G=2048。也可改用 --spec 2c2g" json:"memory_size_mb" mcp:"true"` + Name string `help:"Name of Sku" mcp:"true"` + PostpaidStatus string `help:"Postpaid status;创建优先 available" choices:"soldout|available" mcp:"true"` + PrepaidStatus string `help:"Prepaid status" choices:"soldout|available"` + CpuArch string `help:"Cpu Arch" choices:"x86|arm" mcp:"true"` + Enabled *bool `help:"Filter enabled skus" mcp:"true"` + Distinct bool `help:"distinct sku by name"` OrderByTotalGuestCount string } @@ -42,9 +51,70 @@ func (opts *ServerSkusListOptions) GetId() string { } func (opts *ServerSkusListOptions) Params() (jsonutils.JSONObject, error) { + if err := opts.applySpec(); err != nil { + return nil, err + } return baseoptions.ListStructToParams(opts) } +func (opts *ServerSkusListOptions) applySpec() error { + spec := strings.TrimSpace(opts.Spec) + if spec == "" { + return nil + } + cpu, memMB, err := ParseSkuSpec(spec) + if err != nil { + return err + } + if opts.Cpu == nil { + opts.Cpu = &cpu + } + if opts.Mem == nil { + opts.Mem = &memMB + } + return nil +} + +// skuSpecPatterns 支持:2c2g、2C2G、4c8g、2核2G、2核2g、2vcpu2gb、2c/2g、2x2g +var skuSpecPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)^\s*(\d+)\s*[cC]\s*[xX/]?\s*(\d+)\s*([gGmM])b?\s*$`), + regexp.MustCompile(`(?i)^\s*(\d+)\s*[xX/]\s*(\d+)\s*([gGmM])b?\s*$`), + regexp.MustCompile(`(?i)^\s*(\d+)\s*核\s*(\d+)\s*([gGmM])b?\s*$`), + regexp.MustCompile(`(?i)^\s*(\d+)\s*v?cpu\s*[xX/]?\s*(\d+)\s*([gGmM])b?\s*$`), +} + +// ParseSkuSpec 将口语规格解析为 CPU 核数与内存 MB。 +func ParseSkuSpec(spec string) (cpu int, memMB int, err error) { + s := strings.TrimSpace(spec) + if s == "" { + return 0, 0, fmt.Errorf("empty sku spec") + } + for _, re := range skuSpecPatterns { + m := re.FindStringSubmatch(s) + if m == nil { + continue + } + cpu, err = strconv.Atoi(m[1]) + if err != nil || cpu <= 0 { + return 0, 0, fmt.Errorf("invalid cpu in spec %q", spec) + } + mem, err := strconv.Atoi(m[2]) + if err != nil || mem <= 0 { + return 0, 0, fmt.Errorf("invalid memory in spec %q", spec) + } + switch strings.ToLower(m[3]) { + case "g": + memMB = mem * 1024 + case "m": + memMB = mem + default: + return 0, 0, fmt.Errorf("unsupported memory unit in spec %q", spec) + } + return cpu, memMB, nil + } + return 0, 0, fmt.Errorf("unrecognized sku spec %q, expect like 2c2g or 2核2G", spec) +} + type ServerSkusIdOptions struct { ID string `help:"ID or Name of SKU to show"` } diff --git a/pkg/mcclient/options/compute/serverskus_spec_test.go b/pkg/mcclient/options/compute/serverskus_spec_test.go new file mode 100644 index 0000000000..87be2f66d0 --- /dev/null +++ b/pkg/mcclient/options/compute/serverskus_spec_test.go @@ -0,0 +1,69 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compute + +import "testing" + +func TestParseSkuSpec(t *testing.T) { + cases := []struct { + in string + cpu int + memMB int + wantErr bool + }{ + {"2c2g", 2, 2048, false}, + {"2C2G", 2, 2048, false}, + {"4c8g", 4, 8192, false}, + {"2核2G", 2, 2048, false}, + {"2核2g", 2, 2048, false}, + {" 4核 16G ", 4, 16384, false}, + {"2c/2g", 2, 2048, false}, + {"2x2g", 2, 2048, false}, + {"2vcpu2gb", 2, 2048, false}, + {"2c2048m", 2, 2048, false}, + {"2g", 0, 0, true}, + {"abc", 0, 0, true}, + {"", 0, 0, true}, + } + for _, c := range cases { + cpu, mem, err := ParseSkuSpec(c.in) + if c.wantErr { + if err == nil { + t.Errorf("ParseSkuSpec(%q) expected error", c.in) + } + continue + } + if err != nil { + t.Errorf("ParseSkuSpec(%q) unexpected err: %v", c.in, err) + continue + } + if cpu != c.cpu || mem != c.memMB { + t.Errorf("ParseSkuSpec(%q)=%d,%d want %d,%d", c.in, cpu, mem, c.cpu, c.memMB) + } + } +} + +func TestServerSkusListOptionsApplySpec(t *testing.T) { + opts := &ServerSkusListOptions{Spec: "2核2G"} + params, err := opts.Params() + if err != nil { + t.Fatal(err) + } + cpu, _ := params.Int("cpu_core_count") + mem, _ := params.Int("memory_size_mb") + if cpu != 2 || mem != 2048 { + t.Fatalf("params cpu=%d mem=%d want 2/2048; raw=%s", cpu, mem, params.String()) + } +} diff --git a/pkg/mcclient/options/compute/storage.go b/pkg/mcclient/options/compute/storage.go index 486f4141f4..86367e1e3b 100644 --- a/pkg/mcclient/options/compute/storage.go +++ b/pkg/mcclient/options/compute/storage.go @@ -24,18 +24,20 @@ import ( ) type StorageListOptions struct { + _ struct{} `mcp-desc:"【创建流程中的中间步骤】本工具不能完成创建。创建场景下查完存储后立刻 climc_server_create。严禁只调用本工具后就停止"` + options.BaseListOptions Share *bool `help:"Share storage list"` Local *bool `help:"Local storage list"` - Usable *bool `help:"Usable storage list"` - Zone string `help:"List storages in zone" json:"-"` - Region string `help:"List storages in region"` + Usable *bool `help:"Usable storage list" mcp:"true"` + Zone string `help:"List storages in zone" json:"-" mcp:"true"` + Region string `help:"List storages in region" mcp:"true"` Schedtag string `help:"filter storage by schedtag"` - HostId string `help:"filter storages which attached the specified host"` + HostId string `help:"filter storages which attached the specified host" mcp:"true"` HostSchedtagId string `help:"filter storage by host schedtag"` - ImageId string `help:"filter storage by image"` + ImageId string `help:"filter storage by image" mcp:"true"` IsBaremetal *bool `help:"Baremetal storage list"` } diff --git a/pkg/mcclient/options/compute/vpc.go b/pkg/mcclient/options/compute/vpc.go index e3f32992e9..c4a130b3d2 100644 --- a/pkg/mcclient/options/compute/vpc.go +++ b/pkg/mcclient/options/compute/vpc.go @@ -23,15 +23,17 @@ import ( ) type VpcListOptions struct { + _ struct{} `mcp-desc:"【创建流程中的中间步骤】本工具不能完成创建。创建场景下查完 VPC 后继续 climc_network_list,最后 climc_server_create。严禁只调用本工具后就停止"` + baseoptions.BaseListOptions - Usable *bool `help:"Filter usable vpcs"` - Region string `help:"ID or Name of region" json:"-"` + Usable *bool `help:"Filter usable vpcs" mcp:"true"` + Region string `help:"ID or Name of region" json:"-" mcp:"true"` Globalvpc string `help:"Filter by globalvpc"` DnsZoneId string `help:"Filter by DnsZone"` InterVpcNetworkId string `help:"Filter by InterVpcNetwork"` ExternalAccessMode string `help:"Filter by external access mode" choices:"distgw|eip|eip-distgw"` - ZoneId string `help:"Filter by zone which has networks"` + ZoneId string `help:"Filter by zone which has networks" mcp:"true"` UsableForInterVpcNetworkId string `help:"Filter usable vpcs for inter vpc network"` OrderByWireCount string CidrBlock string `help:"IPv4 cidr block"` diff --git a/pkg/mcclient/options/glance/image.go b/pkg/mcclient/options/glance/image.go index 4948ba14e1..9bb12a1a3e 100644 --- a/pkg/mcclient/options/glance/image.go +++ b/pkg/mcclient/options/glance/image.go @@ -21,20 +21,22 @@ import ( ) type ImageListOptions struct { + _ struct{} `mcp-desc:"【创建流程中的中间步骤】用于查询 Glance/本地 KVM 可用镜像。公有云或非 KVM 请用 climc_cached_image_list。创建场景下查完镜像后继续查网络/规格并调用 climc_server_create。严禁只调用本工具后就停止"` + options.BaseListOptions - IsPublic string `help:"filter images public or not(True, False or None)" choices:"true|false"` - IsStandard string `help:"filter images standard or non-standard" choices:"true|false"` + IsPublic string `help:"filter images public or not(True, False or None)" choices:"true|false" mcp:"true"` + IsStandard string `help:"filter images standard or non-standard" choices:"true|false" mcp:"true"` Protected string `help:"filter images by protected" choices:"true|false"` - IsUefi bool `help:"list uefi image"` - Format []string `help:"Disk formats"` + IsUefi bool `help:"list uefi image" mcp:"true"` + Format []string `help:"Disk formats" mcp:"true"` SubFormats []string `help:"Sub formats"` - Name string `help:"Name filter"` - OsType []string `help:"Type of OS filter e.g. 'Windows, Linux, Freebsd, Android, macOS, VMWare'"` + Name string `help:"Name filter" mcp:"true"` + OsType []string `help:"Type of OS filter e.g. 'Windows, Linux, Freebsd, Android, macOS, VMWare'" mcp:"true"` OsTypePreciseMatch bool `help:"OS precise match"` - OsArch []string `help:"Type of OS arch filter e.g. 'x86, arm, arm64, x86_64'"` + OsArch []string `help:"Type of OS arch filter e.g. 'x86, arm, arm64, x86_64'" mcp:"true"` OsArchPreciseMatch bool `help:"OS arch precise match"` - Distribution []string `help:"Distribution filter, e.g. 'CentOS, Ubuntu, Debian, Windows'"` + Distribution []string `help:"Distribution filter, e.g. 'CentOS, Ubuntu, Debian, Windows'" mcp:"true"` DistributionPreciseMatch bool `help:"Distribution precise match"` } diff --git a/pkg/mcclient/options/identity/domain.go b/pkg/mcclient/options/identity/domain.go index c0d0897fae..c01f05bbcb 100644 --- a/pkg/mcclient/options/identity/domain.go +++ b/pkg/mcclient/options/identity/domain.go @@ -22,9 +22,11 @@ import ( ) type DomainListOptions struct { + _ struct{} `mcp-desc:"列出域(domain)。可用 search 等过滤;详情 climc_domain_show,创建 climc_domain_create,删除 climc_domain_delete"` + options.BaseListOptions - IdpId string `help:"filter by idp_id"` - IdpEntityId string `help:"filter by idp_entity_id"` + IdpId string `help:"filter by idp_id" mcp:"true"` + IdpEntityId string `help:"filter by idp_entity_id" mcp:"true"` } func (opts *DomainListOptions) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/identity/project.go b/pkg/mcclient/options/identity/project.go index 645a78701d..17b42753ff 100644 --- a/pkg/mcclient/options/identity/project.go +++ b/pkg/mcclient/options/identity/project.go @@ -23,14 +23,16 @@ import ( ) type ProjectListOptions struct { + _ struct{} `mcp-desc:"列出项目(project/tenant)。可用 search/domain 等过滤;详情 climc_project_show,创建 climc_project_create,删除 climc_project_delete"` + options.BaseListOptions - UserId string `help:"filter by user id"` - GroupId string `help:"filter by group id"` - IdpId string `help:"filter by idp id"` - AdminId []string + UserId string `help:"filter by user id" mcp:"true"` + GroupId string `help:"filter by group id" mcp:"true"` + IdpId string `help:"filter by idp id" mcp:"true"` + AdminId []string `mcp:"true"` - OrderByDomain string `help:"order by domain name" choices:"asc|desc"` + OrderByDomain string `help:"order by domain name" choices:"asc|desc" mcp:"true"` } func (opts *ProjectListOptions) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/identity/users.go b/pkg/mcclient/options/identity/users.go index 3755e3a4cf..16027204a4 100644 --- a/pkg/mcclient/options/identity/users.go +++ b/pkg/mcclient/options/identity/users.go @@ -21,14 +21,16 @@ import ( ) type UserListOptions struct { + _ struct{} `mcp-desc:"列出用户。可用 search/name/domain 等过滤;详情 climc_user_show,创建 climc_user_create,删除 climc_user_delete"` + options.BaseListOptions - Name string `help:"Filter by name"` - OrderByDomain string `help:"order by domain name" choices:"asc|desc"` - Role string `help:"Filter by role"` - RoleAssignmentDomainId string `help:"filter role assignment domain"` - RoleAssignmentProjectId string `help:"filter role assignment project"` - IdpId string `help:"filter by idp_id"` - IdpEntityId string `help:"filter by idp_entity_id"` + Name string `help:"Filter by name" mcp:"true"` + OrderByDomain string `help:"order by domain name" choices:"asc|desc" mcp:"true"` + Role string `help:"Filter by role" mcp:"true"` + RoleAssignmentDomainId string `help:"filter role assignment domain" mcp:"true"` + RoleAssignmentProjectId string `help:"filter role assignment project" mcp:"true"` + IdpId string `help:"filter by idp_id" mcp:"true"` + IdpEntityId string `help:"filter by idp_entity_id" mcp:"true"` } func (opts *UserListOptions) Params() (jsonutils.JSONObject, error) { diff --git a/pkg/mcclient/options/monitor/unifiedmonitor.go b/pkg/mcclient/options/monitor/unifiedmonitor.go index 53516d3d11..b1d5b02181 100644 --- a/pkg/mcclient/options/monitor/unifiedmonitor.go +++ b/pkg/mcclient/options/monitor/unifiedmonitor.go @@ -58,9 +58,9 @@ func (o *ResourceMetricsOptions) GetInput() (*api.ResourceMetricsQueryInput, err } type MeasurementsQueryOptions struct { - Scope string `json:"scope"` - ProjectDomainId string `json:"project_domin_id"` - ProjectId string `json:"project_id"` + Scope string `json:"scope" mcp:"true"` + ProjectDomainId string `json:"project_domin_id" mcp:"true"` + ProjectId string `json:"project_id" mcp:"true"` } func (o MeasurementsQueryOptions) Params() (jsonutils.JSONObject, error) { @@ -82,19 +82,21 @@ func (o DatabasesQueryOptions) Property() string { } type MetricQueryOptions struct { + _ struct{} `mcp-desc:"查询云平台监控指标时序(unifiedmonitor,非 QEMU)。必填 MEASUREMENT+FIELD;过滤用 from/to/interval/tags。不要用 climc_server_monitor"` + MeasurementsQueryOptions MEASUREMENT string `help:"metric measurement. e.g.: cpu, vm_cpu, vm_mem, disk..."` FIELD string `help:"metric field. e.g.: usage_active, free..."` - Interval string `help:"metric interval. e.g.: 5m, 1h"` - From string `help:"start time(RFC3339 format). e.g.: 2023-12-06T21:54:42.123Z"` - To string `help:"end time(RFC3339 format). e.g.: 2023-12-18T21:54:42.123Z"` - Tags []string `help:"filter tags. e.g.: vm_name=vm1"` - GroupBy []string `help:"group by tag"` - UseMean bool `help:"calcuate mean result for field"` - SkipCheckSeries bool `help:"skip checking series: not fetch extra tags from region service"` - Reducer string `help:"series result reducer. e.g.: sum, percentile(95)"` + Interval string `help:"metric interval. e.g.: 5m, 1h" mcp:"true"` + From string `help:"start time(RFC3339 format). e.g.: 2023-12-06T21:54:42.123Z" mcp:"true"` + To string `help:"end time(RFC3339 format). e.g.: 2023-12-18T21:54:42.123Z" mcp:"true"` + Tags []string `help:"filter tags. e.g.: vm_name=vm1" mcp:"true"` + GroupBy []string `help:"group by tag" mcp:"true"` + UseMean bool `help:"calcuate mean result for field" mcp:"true"` + SkipCheckSeries bool `help:"skip checking series: not fetch extra tags from region service" mcp:"true"` + Reducer string `help:"series result reducer. e.g.: sum, percentile(95)" mcp:"true"` } func (o MetricQueryOptions) GetQueryInput() (*api.MetricQueryInput, error) { diff --git a/pkg/mcp-server/adapters/adapter.go b/pkg/mcp-server/adapters/adapter.go new file mode 100644 index 0000000000..72f7308ea3 --- /dev/null +++ b/pkg/mcp-server/adapters/adapter.go @@ -0,0 +1,81 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adapters + +import ( + "context" + + "yunion.io/x/log" + + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/cloudcommon/policy" + "yunion.io/x/onecloud/pkg/mcclient" + "yunion.io/x/onecloud/pkg/mcclient/auth" + "yunion.io/x/onecloud/pkg/mcp-server/options" +) + +// CloudpodsAdapter 负责 Cloudpods 认证并创建 mcclient.ClientSession,供 climc 工具执行使用。 +type CloudpodsAdapter struct { + client *mcclient.Client +} + +// NewCloudpodsAdapter 创建一个新的 Cloudpods 适配器实例 +func NewCloudpodsAdapter() *CloudpodsAdapter { + client := mcclient.NewClient( + options.Options.AuthURL, + options.Options.Timeout, + false, + true, + "", + "", + ) + return &CloudpodsAdapter{client: client} +} + +func (a *CloudpodsAdapter) authenticate(ak string, sk string) (mcclient.TokenCredential, error) { + return a.client.AuthenticateByAccessKey(ak, sk, "") +} + +// GetSession 获取 Cloudpods API 会话 +func (a *CloudpodsAdapter) GetSession(ctx context.Context, ak string, sk string) (*mcclient.ClientSession, error) { + if ak == "" && sk == "" { + ak, sk = GetAKSKFromContext(ctx) + } + var userCred mcclient.TokenCredential + if auth.IsAuthed() { + userCred = policy.FetchUserCredential(ctx) + if userCred != nil { + log.Debugf("GetSession with userCred from context") + } else { + token, err := a.authenticate(ak, sk) + if err != nil { + return nil, err + } + userCred = token + } + return auth.GetSession(ctx, userCred, ""), nil + } + token, err := a.authenticate(ak, sk) + if err != nil { + return nil, err + } + return a.client.NewSession( + context.Background(), + "", + "", + api.EndpointInterfaceApigateway, + token, + ), nil +} diff --git a/pkg/mcp-server/adapters/cloudpods_adapter.go b/pkg/mcp-server/adapters/cloudpods_adapter.go deleted file mode 100644 index 576bfe92c9..0000000000 --- a/pkg/mcp-server/adapters/cloudpods_adapter.go +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package adapters - -import ( - "context" - - "yunion.io/x/log" - - api "yunion.io/x/onecloud/pkg/apis/identity" - "yunion.io/x/onecloud/pkg/cloudcommon/policy" - "yunion.io/x/onecloud/pkg/mcclient" - "yunion.io/x/onecloud/pkg/mcclient/auth" - "yunion.io/x/onecloud/pkg/mcp-server/options" -) - -// Context key 类型,用于从 HTTP Header 传入的 AK/SK 存入 context(供 Cursor/Claude 等客户端使用) -type headerCredKey string - -const ( - ContextKeyAK headerCredKey = "mcp_header_ak" - ContextKeySK headerCredKey = "mcp_header_sk" -) - -// GetAKSKFromContext 从 context 中读取连接时通过 Header 传入的 AK/SK(未设置时返回空字符串) -func GetAKSKFromContext(ctx context.Context) (ak, sk string) { - if v := ctx.Value(ContextKeyAK); v != nil { - if s, ok := v.(string); ok { - ak = s - } - } - if v := ctx.Value(ContextKeySK); v != nil { - if s, ok := v.(string); ok { - sk = s - } - } - return ak, sk -} - -// CloudpodsAdapter 是与 Cloudpods API 交互的适配器,负责认证和资源管理 -type CloudpodsAdapter struct { - client *mcclient.Client - session *mcclient.ClientSession -} - -type CloudRegion struct { - RegionId string `json:"region_id"` -} - -// NewCloudpodsAdapter 创建一个新的 Cloudpods 适配器实例 -func NewCloudpodsAdapter() *CloudpodsAdapter { - - client := mcclient.NewClient( - options.Options.AuthURL, - options.Options.Timeout, - false, - true, - "", - "", - ) - - return &CloudpodsAdapter{ - client: client, - } -} - -// authenticate 实现 Cloudpods 的认证逻辑,例如获取访问令牌 -func (a *CloudpodsAdapter) authenticate(ak string, sk string) (mcclient.TokenCredential, error) { - if a.session != nil { - return a.session.GetToken(), nil - } - - token, err := a.client.AuthenticateByAccessKey(ak, sk, "") - if err != nil { - return nil, err - } - - return token, nil -} - -func (a *CloudpodsAdapter) getSession(ctx context.Context, ak string, sk string) (*mcclient.ClientSession, error) { - // 若工具未传入 ak/sk,则使用连接时 Header 中的 AK/SK(与 Cursor/Claude 配置一致) - if ak == "" && sk == "" { - ak, sk = GetAKSKFromContext(ctx) - } - var userCred mcclient.TokenCredential - if auth.IsAuthed() { - userCred = policy.FetchUserCredential(ctx) - if userCred != nil { - log.Infof("getSessionWithUserCred: %v", userCred) - } else { - log.Infof("No userCred in context, will use ak/sk for authentication") - token, err := a.authenticate(ak, sk) - if err != nil { - return nil, err - } - userCred = token - } - a.session = auth.GetSession(ctx, userCred, "") - } else { - token, err := a.authenticate(ak, sk) - if err != nil { - return nil, err - } - a.session = a.client.NewSession( - context.Background(), - "", - "", - api.EndpointInterfaceApigateway, - token, - ) - } - return a.session, nil -} diff --git a/pkg/mcp-server/adapters/credentials.go b/pkg/mcp-server/adapters/credentials.go new file mode 100644 index 0000000000..d3a89222aa --- /dev/null +++ b/pkg/mcp-server/adapters/credentials.go @@ -0,0 +1,58 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adapters + +import ( + "context" + "fmt" + + "yunion.io/x/onecloud/pkg/cloudcommon/policy" + "yunion.io/x/onecloud/pkg/mcclient/auth" +) + +// Context key 类型,用于从 HTTP Header 传入的 AK/SK 存入 context(供 Cursor/Claude 等客户端使用) +type headerCredKey string + +const ( + ContextKeyAK headerCredKey = "mcp_header_ak" + ContextKeySK headerCredKey = "mcp_header_sk" +) + +// GetAKSKFromContext 从 context 中读取连接时通过 Header 传入的 AK/SK(未设置时返回空字符串) +func GetAKSKFromContext(ctx context.Context) (ak, sk string) { + if v := ctx.Value(ContextKeyAK); v != nil { + if s, ok := v.(string); ok { + ak = s + } + } + if v := ctx.Value(ContextKeySK); v != nil { + if s, ok := v.(string); ok { + sk = s + } + } + return ak, sk +} + +// HasRequestCredentials 判断请求上下文是否已带用户凭据(Token 或 AK/SK)。 +func HasRequestCredentials(ctx context.Context) bool { + if auth.IsAuthed() && policy.FetchUserCredential(ctx) != nil { + return true + } + ak, sk := GetAKSKFromContext(ctx) + return ak != "" && sk != "" +} + +// ErrAuthenticationRequired tools/call 无凭据时的明确错误。 +var ErrAuthenticationRequired = fmt.Errorf("authentication required: provide X-Auth-Token, AK/SK headers, or X-API-Key on tools/call") diff --git a/pkg/mcp-server/adapters/resource_operation_adapter.go b/pkg/mcp-server/adapters/resource_operation_adapter.go deleted file mode 100644 index 5642b02c4c..0000000000 --- a/pkg/mcp-server/adapters/resource_operation_adapter.go +++ /dev/null @@ -1,635 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package adapters - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "yunion.io/x/jsonutils" - - "yunion.io/x/onecloud/pkg/mcclient/modules/compute" - "yunion.io/x/onecloud/pkg/mcclient/modules/monitor" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// StartServer 启动 Cloudpods 中的服务器 -func (a *CloudpodsAdapter) StartServer(ctx context.Context, serverId string, req models.ServerStartRequest, ak string, sk string) (*models.ServerOperationResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造启动参数 - params := jsonutils.NewDict() - - // 如果需要自动续费预付费实例,则设置相应参数 - if req.AutoPrepaid { - params.Set("auto_prepaid", jsonutils.NewBool(true)) - } - - // 如果指定了 QEMU 版本,则设置相应参数 - if req.QemuVersion != "" { - params.Set("qemu_version", jsonutils.NewString(req.QemuVersion)) - } - - // 调用 Cloudpods API 启动服务器 - result, err := compute.Servers.PerformAction(session, serverId, "start", params) - if err != nil { - return nil, fmt.Errorf("failed to start server: %w", err) - } - - // 构造响应数据 - response := &models.ServerOperationResponse{ - Operation: "start", - } - - // 尝试将结果解析到响应结构体中 - if err := result.Unmarshal(response); err != nil { - // 如果解析失败,则尝试获取任务 ID - taskId, _ := result.GetString("task_id") - response.TaskId = taskId - // 如果任务 ID 不为空,则认为操作成功 - response.Success = taskId != "" - } - - return response, nil -} - -// StopServer 停止 Cloudpods 中的服务器 -func (a *CloudpodsAdapter) StopServer(ctx context.Context, serverId string, req models.ServerStopRequest, ak string, sk string) (*models.ServerOperationResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造停止参数 - params := jsonutils.NewDict() - - // 如果需要强制停止,则设置相应参数 - if req.IsForce { - params.Set("is_force", jsonutils.NewBool(true)) - } - - // 如果需要停止计费,则设置相应参数 - if req.StopCharging { - params.Set("stop_charging", jsonutils.NewBool(true)) - } - - // 如果设置了超时时间,则设置相应参数 - if req.TimeoutSecs > 0 { - params.Set("timeout_secs", jsonutils.NewInt(req.TimeoutSecs)) - } - - // 调用 Cloudpods API 停止服务器 - result, err := compute.Servers.PerformAction(session, serverId, "stop", params) - if err != nil { - return nil, fmt.Errorf("failed to stop server: %w", err) - } - - // 构造响应数据 - response := &models.ServerOperationResponse{ - Operation: "stop", - } - - // 尝试将结果解析到响应结构体中 - if err := result.Unmarshal(response); err != nil { - // 如果解析失败,则尝试获取任务 ID - taskId, _ := result.GetString("task_id") - response.TaskId = taskId - // 如果任务 ID 不为空,则认为操作成功 - response.Success = taskId != "" - } - - return response, nil -} - -// RestartServer 重启 Cloudpods 中的服务器 -func (a *CloudpodsAdapter) RestartServer(ctx context.Context, serverId string, req models.ServerRestartRequest, ak string, sk string) (*models.ServerOperationResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造重启参数 - params := jsonutils.NewDict() - - // 如果需要强制重启,则设置相应参数 - if req.IsForce { - params.Set("is_force", jsonutils.NewBool(true)) - } - - // 调用 Cloudpods API 重启服务器 - result, err := compute.Servers.PerformAction(session, serverId, "restart", params) - if err != nil { - return nil, fmt.Errorf("failed to restart server: %w", err) - } - - // 构造响应数据 - response := &models.ServerOperationResponse{ - Operation: "restart", - } - - // 尝试将结果解析到响应结构体中 - if err := result.Unmarshal(response); err != nil { - // 如果解析失败,则尝试获取任务 ID - taskId, _ := result.GetString("task_id") - response.TaskId = taskId - // 如果任务 ID 不为空,则认为操作成功 - response.Success = taskId != "" - } - - return response, nil -} - -// ResetServerPassword 重置 Cloudpods 中服务器的密码 -func (a *CloudpodsAdapter) ResetServerPassword(ctx context.Context, serverId string, req models.ServerResetPasswordRequest, ak string, sk string) (*models.ServerOperationResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造密码重置参数 - params := jsonutils.NewDict() - // 设置新密码 - params.Set("password", jsonutils.NewString(req.Password)) - - if req.ResetPassword { - params.Set("reset_password", jsonutils.NewBool(true)) - } - - if req.AutoStart { - params.Set("auto_start", jsonutils.NewBool(true)) - } - - if req.Username != "" { - params.Set("username", jsonutils.NewString(req.Username)) - } - - // 调用 Cloudpods API 重置服务器密码 - result, err := compute.Servers.PerformAction(session, serverId, "reset-password", params) - if err != nil { - return nil, fmt.Errorf("failed to reset server password: %w", err) - } - - // 构造响应数据 - response := &models.ServerOperationResponse{ - Operation: "reset-password", - } - - // 尝试将结果解析到响应结构体中 - if err := result.Unmarshal(response); err != nil { - // 如果解析失败,则尝试获取任务 ID - taskId, _ := result.GetString("task_id") - response.TaskId = taskId - // 如果任务 ID 不为空,则认为操作成功 - response.Success = taskId != "" - } - - return response, nil -} - -// DeleteServer 删除 Cloudpods 中的服务器 -func (a *CloudpodsAdapter) DeleteServer(ctx context.Context, serverId string, req models.ServerDeleteRequest, ak string, sk string) (*models.ServerOperationResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造删除参数 - params := jsonutils.NewDict() - // 如果需要覆盖待删除状态,则设置相应参数 - if req.OverridePendingDelete { - params.Set("override_pending_delete", jsonutils.NewBool(true)) - } - // 如果需要彻底删除,则设置相应参数 - if req.Purge { - params.Set("purge", jsonutils.NewBool(true)) - } - // 如果需要删除快照,则设置相应参数 - if req.DeleteSnapshots { - params.Set("delete_snapshots", jsonutils.NewBool(true)) - } - // 如果需要删除弹性 IP,则设置相应参数 - if req.DeleteEip { - params.Set("delete_eip", jsonutils.NewBool(true)) - } - // 如果需要删除磁盘,则设置相应参数 - if req.DeleteDisks { - params.Set("delete_disks", jsonutils.NewBool(true)) - } - - // 调用 Cloudpods API 删除服务器 - result, err := compute.Servers.Delete(session, serverId, params) - if err != nil { - return nil, fmt.Errorf("failed to delete server: %w", err) - } - - // 构造响应数据 - response := &models.ServerOperationResponse{ - Operation: "delete", - } - - // 尝试将结果解析到响应结构体中 - if err := result.Unmarshal(response); err != nil { - // 如果解析失败,则尝试获取任务 ID - taskId, _ := result.GetString("task_id") - response.TaskId = taskId - // 如果任务 ID 不为空,则认为操作成功 - response.Success = taskId != "" - } - - return response, nil -} - -// CreateServer 在 Cloudpods 中创建服务器 -func (a *CloudpodsAdapter) CreateServer(ctx context.Context, req models.CreateServerRequest, ak string, sk string) (*models.CreateServerResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造创建服务器的参数 - params := jsonutils.NewDict() - // 设置服务器名称 - params.Set("name", jsonutils.NewString(req.Name)) - // 设置 CPU 核心数 - params.Set("vcpu_count", jsonutils.NewInt(req.VcpuCount)) - // 设置内存大小 - params.Set("vmem_size", jsonutils.NewInt(req.VmemSize)) - - // 如果创建数量大于1,则设置相应参数 - if req.Count > 1 { - params.Set("count", jsonutils.NewInt(int64(req.Count))) - } - - // 如果需要自动启动,则设置相应参数 - if req.AutoStart { - params.Set("auto_start", jsonutils.NewBool(req.AutoStart)) - } - - // 如果设置了密码,则设置相应参数 - if req.Password != "" { - params.Set("password", jsonutils.NewString(req.Password)) - } - - // 如果设置了计费类型,则设置相应参数 - if req.BillingType != "" { - params.Set("billing_type", jsonutils.NewString(req.BillingType)) - } - - // 如果设置了计费时长,则设置相应参数 - if req.Duration != "" { - params.Set("duration", jsonutils.NewString(req.Duration)) - } - - // 如果设置了描述,则设置相应参数 - if req.Description != "" { - params.Set("description", jsonutils.NewString(req.Description)) - } - - // 如果设置了主机名,则设置相应参数 - if req.Hostname != "" { - params.Set("hostname", jsonutils.NewString(req.Hostname)) - } - - // 如果设置了虚拟化类型,则设置相应参数 - if req.Hypervisor != "" { - params.Set("hypervisor", jsonutils.NewString(req.Hypervisor)) - } - - // 如果设置了用户数据,则设置相应参数 - if req.UserData != "" { - params.Set("user_data", jsonutils.NewString(req.UserData)) - } - - // 如果设置了密钥对 ID,则设置相应参数 - if req.KeypairId != "" { - params.Set("keypair_id", jsonutils.NewString(req.KeypairId)) - } - - // 如果设置了项目 ID,则设置相应参数 - if req.ProjectId != "" { - params.Set("project_id", jsonutils.NewString(req.ProjectId)) - } - - // 如果设置了可用区 ID,则设置相应参数 - if req.ZoneId != "" { - params.Set("prefer_zone_id", jsonutils.NewString(req.ZoneId)) - } - - // 如果设置了区域 ID,则设置相应参数 - if req.RegionId != "" { - params.Set("prefer_region_id", jsonutils.NewString(req.RegionId)) - } - - // 如果需要禁用删除,则设置相应参数 - if req.DisableDelete { - params.Set("disable_delete", jsonutils.NewBool(req.DisableDelete)) - } - - // 如果设置了启动顺序,则设置相应参数 - if req.BootOrder != "" { - params.Set("boot_order", jsonutils.NewString(req.BootOrder)) - } - - // 如果设置了元数据,则设置相应参数 - if len(req.Metadata) > 0 { - metaDict := jsonutils.NewDict() - for k, v := range req.Metadata { - metaDict.Set(k, jsonutils.NewString(v)) - } - params.Set("__meta__", metaDict) - } - - // 构造磁盘参数 - disks := jsonutils.NewArray() - - // 如果设置了镜像 ID,则构造系统磁盘参数 - if req.ImageId != "" { - diskDict := jsonutils.NewDict() - diskDict.Set("image_id", jsonutils.NewString(req.ImageId)) - diskDict.Set("disk_type", jsonutils.NewString("sys")) - if req.DiskSize > 0 { - diskDict.Set("size", jsonutils.NewInt(req.DiskSize)) - } - disks.Add(diskDict) - } - - // 构造数据磁盘参数 - for _, disk := range req.DataDisks { - diskDict := jsonutils.NewDict() - if disk.ImageId != "" { - diskDict.Set("image_id", jsonutils.NewString(disk.ImageId)) - } - if disk.Size > 0 { - diskDict.Set("size", jsonutils.NewInt(disk.Size)) - } - diskDict.Set("disk_type", jsonutils.NewString(disk.DiskType)) - disks.Add(diskDict) - } - - // 如果有磁盘参数,则设置相应参数 - if disks.Length() > 0 { - params.Set("disks", disks) - } - - // 如果设置了网络 ID,则构造网络参数 - if req.NetworkId != "" { - networks := jsonutils.NewArray() - netDict := jsonutils.NewDict() - netDict.Set("network", jsonutils.NewString(req.NetworkId)) - networks.Add(netDict) - params.Set("nets", networks) - } - - // 如果设置了安全组 ID,则设置相应参数 - if req.SecgroupId != "" { - params.Set("secgrp_id", jsonutils.NewString(req.SecgroupId)) - } - - // 如果设置了安全组列表,则设置相应参数 - if len(req.Secgroups) > 0 { - secgroups := jsonutils.NewArray() - for _, sg := range req.Secgroups { - secgroups.Add(jsonutils.NewString(sg)) - } - params.Set("secgroups", secgroups) - } - - // 如果设置了服务器规格 ID,则设置相应参数 - if req.ServerskuId != "" { - params.Set("instance_type", jsonutils.NewString(req.ServerskuId)) - } - - // 调用 Cloudpods API 创建服务器 - result, err := compute.Servers.Create(session, params) - if err != nil { - return nil, fmt.Errorf("failed to create server: %w", err) - } - - // 构造响应数据 - response := &models.CreateServerResponse{} - if err := result.Unmarshal(response); err != nil { - return nil, fmt.Errorf("failed to unmarshal create server response: %w", err) - } - - return response, nil -} - -// GetServerMonitor 获取 Cloudpods 中服务器的监控数据 -func (a *CloudpodsAdapter) GetServerMonitor(ctx context.Context, serverId string, startTime, endTime int64, metrics []string, ak string, sk string) (*models.MonitorResponse, error) { - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - params := jsonutils.NewDict() - - metricQuery := jsonutils.NewArray() - - for _, metric := range metrics { - - modelDict := jsonutils.NewDict() - - modelDict.Set("database", jsonutils.NewString("telegraf")) - modelDict.Set("measurement", jsonutils.NewString("vm_cpu")) - - switch metric { - case "cpu_usage": - modelDict.Set("measurement", jsonutils.NewString("vm_cpu")) - case "mem_usage": - modelDict.Set("measurement", jsonutils.NewString("vm_mem")) - case "disk_usage": - modelDict.Set("measurement", jsonutils.NewString("vm_disk")) - case "net_bps_rx", "net_bps_tx": - modelDict.Set("measurement", jsonutils.NewString("vm_netio")) - } - - tagsArray := jsonutils.NewArray() - tagDict := jsonutils.NewDict() - tagDict.Set("key", jsonutils.NewString("vm_id")) - tagDict.Set("operator", jsonutils.NewString("=")) - tagDict.Set("value", jsonutils.NewString(serverId)) - tagsArray.Add(tagDict) - modelDict.Set("tags", tagsArray) - - queryDict := jsonutils.NewDict() - queryDict.Set("model", modelDict) - - if startTime > 0 { - queryDict.Set("from", jsonutils.NewString(fmt.Sprintf("%d", startTime))) - } - if endTime > 0 { - queryDict.Set("to", jsonutils.NewString(fmt.Sprintf("%d", endTime))) - } - - metricQuery.Add(queryDict) - } - - params.Set("metric_query", metricQuery) - params.Set("scope", jsonutils.NewString("system")) - - params.Set("interval", jsonutils.NewString("60s")) - - result, err := monitor.UnifiedMonitorManager.PerformAction(session, "query", "", params) - if err != nil { - return nil, fmt.Errorf("failed to get server monitor data: %w", err) - } - - response := &models.MonitorResponse{ - Status: 200, - Data: models.MonitorResponseData{ - Metrics: []models.MetricData{}, - }, - } - - unifiedmonitor, err := result.Get("unifiedmonitor") - if err != nil { - return nil, fmt.Errorf("failed to get unifiedmonitor data: %w", err) - } - - series, err := unifiedmonitor.Get("Series") - if err != nil { - return nil, fmt.Errorf("failed to get series data: %w", err) - } - - seriesArray, ok := series.(*jsonutils.JSONArray) - if !ok { - return nil, fmt.Errorf("invalid series data format") - } - - for i := 0; i < seriesArray.Length(); i++ { - seriesObj, err := seriesArray.GetAt(i) - if err != nil { - continue - } - - name, _ := seriesObj.GetString("name") - - metricData := models.MetricData{ - Metric: name, - Unit: "%", - Values: []models.MetricValue{}, - } - - if strings.Contains(name, "net_bps") { - metricData.Unit = "bps" - } else if strings.Contains(name, "disk_io") { - metricData.Unit = "iops" - } - - points, err := seriesObj.Get("points") - if err != nil { - continue - } - - pointsArray, ok := points.(*jsonutils.JSONArray) - if !ok { - continue - } - - for j := 0; j < pointsArray.Length(); j++ { - pointObj, err := pointsArray.GetAt(j) - if err != nil { - continue - } - - pointArray, ok := pointObj.(*jsonutils.JSONArray) - if !ok || pointArray.Length() < 2 { - continue - } - - timestamp, err := pointArray.GetAt(0) - if err != nil { - continue - } - - value, err := pointArray.GetAt(1) - if err != nil { - continue - } - - timestampStr, _ := timestamp.GetString() - valueStr, _ := value.GetString() - - timestampInt, _ := strconv.ParseInt(timestampStr, 10, 64) - valueFloat, _ := strconv.ParseFloat(valueStr, 64) - - metricData.Values = append(metricData.Values, models.MetricValue{ - Timestamp: timestampInt, - Value: valueFloat, - }) - } - - response.Data.Metrics = append(response.Data.Metrics, metricData) - } - - return response, nil -} - -// GetServerStats 获取 Cloudpods 中服务器的实时统计数据 -func (a *CloudpodsAdapter) GetServerStats(ctx context.Context, serverId string, ak string, sk string) (*models.ServerStatsResponse, error) { - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - params := jsonutils.NewDict() - result, err := compute.Servers.GetSpecific(session, serverId, "stats", params) - if err != nil { - return nil, fmt.Errorf("failed to get server stats: %w", err) - } - - statsData := models.ServerStatsData{} - - cpuUsed, _ := result.Float("cpu_used") - statsData.CPUUsage = cpuUsed * 100 - - memSize, _ := result.Int("mem_size") - memUsed, _ := result.Int("mem_used") - if memSize > 0 { - statsData.MemUsage = float64(memUsed) / float64(memSize) * 100 - } - - diskSize, _ := result.Int("disk_size") - diskUsed, _ := result.Int("disk_used") - if diskSize > 0 { - statsData.DiskUsage = float64(diskUsed) / float64(diskSize) * 100 - } - - netInRate, _ := result.Float("net_in_rate") - netOutRate, _ := result.Float("net_out_rate") - statsData.NetBpsRx = int64(netInRate) - statsData.NetBpsTx = int64(netOutRate) - - statsData.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") - - response := &models.ServerStatsResponse{ - Status: 200, - Data: statsData, - } - - return response, nil -} diff --git a/pkg/mcp-server/adapters/resource_query_adapter.go b/pkg/mcp-server/adapters/resource_query_adapter.go deleted file mode 100644 index 74d82ad94f..0000000000 --- a/pkg/mcp-server/adapters/resource_query_adapter.go +++ /dev/null @@ -1,500 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package adapters - -import ( - "context" - "fmt" - - "yunion.io/x/jsonutils" - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcclient/modules/compute" - "yunion.io/x/onecloud/pkg/mcclient/modules/image" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// ListCloudRegions 查询 Cloudpods 中的区域列表 -func (a CloudpodsAdapter) ListCloudRegions(ctx context.Context, limit int, offset int, search string, provider string, ak string, sk string) (*models.CloudregionListResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造查询参数 - params := jsonutils.NewDict() - params.Set("scope", jsonutils.NewString("max")) - if limit > 0 { - // 设置查询结果数量限制 - params.Set("limit", jsonutils.NewInt(int64(limit))) - } - if offset > 0 { - // 设置查询偏移量 - params.Set("offset", jsonutils.NewInt(int64(offset))) - } - if search != "" { - // 设置搜索关键字 - params.Set("search", jsonutils.NewString(search)) - } - if provider != "" { - // 设置云提供商过滤条件 - providers := jsonutils.NewArray() - providers.Add(jsonutils.NewString(provider)) - params.Set("providers", providers) - } - - // 调用 Cloudpods API 查询区域列表 - result, err := compute.Cloudregions.List(session, params) - if err != nil { - return nil, err - } - - // 构造响应数据 - response := &models.CloudregionListResponse{ - Limit: int64(limit), - Offset: int64(offset), - Cloudregions: make([]models.CloudregionDetails, 0), - Total: int64(result.Total), - } - // 遍历查询结果,将数据转换为响应格式 - for _, data := range result.Data { - region := models.CloudregionDetails{} - if err := data.Unmarshal(®ion); err != nil { - // 如果数据转换失败,记录警告日志并跳过该条数据 - log.Warningf("Failed to unmarshal cloudregion details: %s", err) - continue - } - response.Cloudregions = append(response.Cloudregions, region) - } - - return response, nil -} - -// ListVPCs 查询 Cloudpods 中的 VPC 列表 -func (a *CloudpodsAdapter) ListVPCs(ctx context.Context, limit int, offset int, search string, cloudregionId string, ak string, sk string) (*models.VpcListResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造查询参数 - params := jsonutils.NewDict() - params.Set("scope", jsonutils.NewString("max")) - if limit > 0 { - // 设置查询结果数量限制 - params.Set("limit", jsonutils.NewInt(int64(limit))) - } - if offset > 0 { - // 设置查询偏移量 - params.Set("offset", jsonutils.NewInt(int64(offset))) - } - if search != "" { - // 设置搜索关键字 - params.Set("search", jsonutils.NewString(search)) - } - if cloudregionId != "" { - // 设置云区域 ID 过滤条件 - cloudregionIds := jsonutils.NewArray() - cloudregionIds.Add(jsonutils.NewString(cloudregionId)) - params.Set("cloudregion_id", cloudregionIds) - } - - // 调用 Cloudpods API 查询 VPC 列表 - result, err := compute.Vpcs.List(session, params) - if err != nil { - return nil, fmt.Errorf("failed to list vpcs: %w", err) - } - - // 构造响应数据 - response := &models.VpcListResponse{ - Limit: int64(limit), - Offset: int64(offset), - Vpcs: make([]models.VpcDetails, 0), - Total: int64(result.Total), - } - // 遍历查询结果,将数据转换为响应格式 - for _, data := range result.Data { - vpc := models.VpcDetails{} - if err := data.Unmarshal(&vpc); err != nil { - // 如果数据转换失败,记录警告日志并跳过该条数据 - log.Warningf("Failed to unmarshal vpc details: %s", err) - continue - } - response.Vpcs = append(response.Vpcs, vpc) - } - - return response, nil -} - -// ListNetworks 查询 Cloudpods 中的网络列表 -func (a *CloudpodsAdapter) ListNetworks(ctx context.Context, limit int, offset int, search string, vpcId string, ak string, sk string) (*models.NetworkListResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造查询参数 - params := jsonutils.NewDict() - params.Set("scope", jsonutils.NewString("max")) - if limit > 0 { - // 设置查询结果数量限制 - params.Set("limit", jsonutils.NewInt(int64(limit))) - } - if offset > 0 { - // 设置查询偏移量 - params.Set("offset", jsonutils.NewInt(int64(offset))) - } - if search != "" { - // 设置搜索关键字 - params.Set("search", jsonutils.NewString(search)) - } - if vpcId != "" { - // 设置 VPC ID 过滤条件 - //vpcIds := jsonutils.NewArray() - //vpcIds.Add(jsonutils.NewString(vpcId)) - //params.Set("vpc_id", vpcIds) - params.Set("vpc_id", jsonutils.NewString(vpcId)) - } - - // 调用 Cloudpods API 查询网络列表 - result, err := compute.Networks.List(session, params) - if err != nil { - return nil, fmt.Errorf("failed to list networks: %w", err) - } - - // 构造响应数据 - response := &models.NetworkListResponse{ - Limit: int64(limit), - Offset: int64(offset), - Networks: make([]models.NetworkDetails, 0), - Total: int64(result.Total), - } - // 遍历查询结果,将数据转换为响应格式 - for _, data := range result.Data { - network := models.NetworkDetails{} - if err := data.Unmarshal(&network); err != nil { - // 如果数据转换失败,记录警告日志并跳过该条数据 - log.Warningf("Failed to unmarshal network details: %s", err) - continue - } - response.Networks = append(response.Networks, network) - } - - return response, nil -} - -// ListImages 查询 Cloudpods 中的镜像列表 -func (a *CloudpodsAdapter) ListImages(ctx context.Context, limit int, offset int, search string, osTypes []string, ak string, sk string) (*models.ImageListResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造查询参数 - params := jsonutils.NewDict() - params.Set("scope", jsonutils.NewString("max")) - if limit > 0 { - // 设置查询结果数量限制 - params.Set("limit", jsonutils.NewInt(int64(limit))) - } - if offset > 0 { - // 设置查询偏移量 - params.Set("offset", jsonutils.NewInt(int64(offset))) - } - if search != "" { - // 设置搜索关键字 - params.Set("search", jsonutils.NewString(search)) - } - if len(osTypes) > 0 { - // 设置操作系统类型过滤条件 - osTypesArray := jsonutils.NewArray() - for _, osType := range osTypes { - osTypesArray.Add(jsonutils.NewString(osType)) - } - params.Set("os_types", osTypesArray) - } - - // 调用 Cloudpods API 查询镜像列表 - result, err := image.Images.List(session, params) - if err != nil { - return nil, fmt.Errorf("failed to list images: %w", err) - } - - // 构造响应数据 - response := &models.ImageListResponse{ - Limit: int64(limit), - Offset: int64(offset), - Images: make([]models.ImageDetails, 0), - Total: int64(result.Total), - } - // 遍历查询结果,将数据转换为响应格式 - for _, data := range result.Data { - image := models.ImageDetails{} - if err := data.Unmarshal(&image); err != nil { - // 如果数据转换失败,记录警告日志并跳过该条数据 - log.Warningf("Failed to unmarshal image details: %s", err) - continue - } - response.Images = append(response.Images, image) - } - - return response, nil -} - -// ListServerSkus 查询 Cloudpods 中的服务器规格列表 -func (a *CloudpodsAdapter) ListServerSkus(ctx context.Context, limit int, offset int, search string, cloudregionIds []string, zoneIds []string, cpuCoreCount []string, memorySizeMB []string, providers []string, cpuArch []string, ak string, sk string) (*models.ServerSkuListResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造查询参数 - params := jsonutils.NewDict() - params.Set("scope", jsonutils.NewString("max")) - if limit > 0 { - // 设置查询结果数量限制 - params.Set("limit", jsonutils.NewInt(int64(limit))) - } - if offset > 0 { - // 设置查询偏移量 - params.Set("offset", jsonutils.NewInt(int64(offset))) - } - if search != "" { - // 设置搜索关键字 - params.Set("search", jsonutils.NewString(search)) - } - if len(cloudregionIds) > 0 { - // 设置云区域 ID 过滤条件 - cloudregionIdArray := jsonutils.NewArray() - for _, id := range cloudregionIds { - cloudregionIdArray.Add(jsonutils.NewString(id)) - } - params.Set("cloudregion_id", cloudregionIdArray) - } - if len(zoneIds) > 0 { - // 设置可用区 ID 过滤条件 - zoneIdArray := jsonutils.NewArray() - for _, id := range zoneIds { - zoneIdArray.Add(jsonutils.NewString(id)) - } - params.Set("zone_ids", zoneIdArray) - } - if len(cpuCoreCount) > 0 { - // 设置 CPU 核心数过滤条件 - cpuCoreArray := jsonutils.NewArray() - for _, count := range cpuCoreCount { - cpuCoreArray.Add(jsonutils.NewString(count)) - } - params.Set("cpu_core_count", cpuCoreArray) - } - if len(memorySizeMB) > 0 { - // 设置内存大小过滤条件 - memoryArray := jsonutils.NewArray() - for _, size := range memorySizeMB { - memoryArray.Add(jsonutils.NewString(size)) - } - params.Set("memory_size_mb", memoryArray) - } - if len(providers) > 0 { - // 设置提供商过滤条件 - providerArray := jsonutils.NewArray() - for _, provider := range providers { - providerArray.Add(jsonutils.NewString(provider)) - } - params.Set("providers", providerArray) - } - if len(cpuArch) > 0 { - // 设置 CPU 架构过滤条件 - cpuArchArray := jsonutils.NewArray() - for _, arch := range cpuArch { - cpuArchArray.Add(jsonutils.NewString(arch)) - } - params.Set("cpu_arch", cpuArchArray) - } - - // 调用 Cloudpods API 查询服务器规格列表 - result, err := compute.ServerSkus.List(session, params) - if err != nil { - return nil, fmt.Errorf("failed to list server skus: %w", err) - } - - // 构造响应数据 - response := &models.ServerSkuListResponse{ - Limit: int64(limit), - Offset: int64(offset), - Serverskus: make([]models.ServerSkuDetails, 0), - Total: int64(result.Total), - } - // 遍历查询结果,将数据转换为响应格式 - for _, data := range result.Data { - sku := models.ServerSkuDetails{} - if err := data.Unmarshal(&sku); err != nil { - // 如果数据转换失败,记录警告日志并跳过该条数据 - log.Warningf("Failed to unmarshal server sku details: %s", err) - continue - } - response.Serverskus = append(response.Serverskus, sku) - } - - return response, nil -} - -// ListStorages 查询 Cloudpods 中的存储列表 -func (a *CloudpodsAdapter) ListStorages(ctx context.Context, limit int, offset int, search string, cloudregionIds []string, zoneIds []string, providers []string, storageTypes []string, hostId string, ak string, sk string) (*models.StorageListResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造查询参数 - params := jsonutils.NewDict() - params.Set("scope", jsonutils.NewString("max")) - if limit > 0 { - // 设置查询结果数量限制 - params.Set("limit", jsonutils.NewInt(int64(limit))) - } - if offset > 0 { - // 设置查询偏移量 - params.Set("offset", jsonutils.NewInt(int64(offset))) - } - if search != "" { - // 设置搜索关键字 - params.Set("search", jsonutils.NewString(search)) - } - if len(cloudregionIds) > 0 { - // 设置云区域 ID 过滤条件 - cloudregionIdArray := jsonutils.NewArray() - for _, id := range cloudregionIds { - cloudregionIdArray.Add(jsonutils.NewString(id)) - } - params.Set("cloudregion_id", cloudregionIdArray) - } - if len(zoneIds) > 0 { - // 设置可用区 ID 过滤条件 - zoneIdArray := jsonutils.NewArray() - for _, id := range zoneIds { - zoneIdArray.Add(jsonutils.NewString(id)) - } - params.Set("zone_ids", zoneIdArray) - } - if len(providers) > 0 { - // 设置提供商过滤条件 - providerArray := jsonutils.NewArray() - for _, provider := range providers { - providerArray.Add(jsonutils.NewString(provider)) - } - params.Set("providers", providerArray) - } - if len(storageTypes) > 0 { - // 设置存储类型过滤条件 - for _, storageType := range storageTypes { - params.Set("storage_type", jsonutils.NewString(storageType)) - break - } - } - if hostId != "" { - // 设置主机 ID 过滤条件 - params.Set("host_id", jsonutils.NewString(hostId)) - } - - // 调用 Cloudpods API 查询存储列表 - result, err := compute.Storages.List(session, params) - if err != nil { - return nil, fmt.Errorf("failed to list storages: %w", err) - } - - // 构造响应数据 - response := &models.StorageListResponse{ - Limit: int64(limit), - Offset: int64(offset), - Storages: make([]models.StorageDetails, 0), - Total: int64(result.Total), - } - - // 遍历查询结果,将数据转换为响应格式 - for _, data := range result.Data { - storage := models.StorageDetails{} - if err := data.Unmarshal(&storage); err != nil { - // 如果数据转换失败,记录警告日志并跳过该条数据 - log.Warningf("Failed to unmarshal storage details: %s", err) - continue - } - response.Storages = append(response.Storages, storage) - } - - return response, nil -} - -// ListServers 查询 Cloudpods 中的服务器列表 -func (a *CloudpodsAdapter) ListServers(ctx context.Context, limit int, offset int, search string, status string, ak string, sk string) (*models.ServerListResponse, error) { - // 获取 Cloudpods 会话 - session, err := a.getSession(ctx, ak, sk) - if err != nil { - return nil, err - } - - // 构造查询参数 - params := jsonutils.NewDict() - params.Set("scope", jsonutils.NewString("max")) - if limit > 0 { - // 设置查询结果数量限制 - params.Set("limit", jsonutils.NewInt(int64(limit))) - } - if offset > 0 { - // 设置查询偏移量 - params.Set("offset", jsonutils.NewInt(int64(offset))) - } - if search != "" { - // 设置搜索关键字 - params.Set("search", jsonutils.NewString(search)) - } - if status != "" { - // 设置服务器状态过滤条件 - params.Set("status", jsonutils.NewString(status)) - } - - // 调用 Cloudpods API 查询服务器列表 - result, err := compute.Servers.List(session, params) - if err != nil { - return nil, fmt.Errorf("failed to list servers: %w", err) - } - - // 构造响应数据 - response := &models.ServerListResponse{ - Limit: int64(limit), - Offset: int64(offset), - Servers: make([]models.ServerDetails, 0), - Total: int64(result.Total), - } - - // 遍历查询结果,将数据转换为响应格式 - for _, data := range result.Data { - server := models.ServerDetails{} - if err := data.Unmarshal(&server); err != nil { - // 如果数据转换失败,记录警告日志并跳过该条数据 - log.Warningf("Failed to unmarshal server details: %s", err) - continue - } - response.Servers = append(response.Servers, server) - } - - return response, nil -} diff --git a/pkg/mcp-server/climcgen/create_flow.go b/pkg/mcp-server/climcgen/create_flow.go new file mode 100644 index 0000000000..931e39fb13 --- /dev/null +++ b/pkg/mcp-server/climcgen/create_flow.go @@ -0,0 +1,595 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "yunion.io/x/jsonutils" + "yunion.io/x/log" + "yunion.io/x/pkg/util/fileutils" + "yunion.io/x/pkg/util/regutils" + + "yunion.io/x/onecloud/cmd/climc/shell" + "yunion.io/x/onecloud/pkg/apis/compute" + schedapi "yunion.io/x/onecloud/pkg/apis/scheduler" + "yunion.io/x/onecloud/pkg/mcclient" + modules "yunion.io/x/onecloud/pkg/mcclient/modules/compute" + schedmodules "yunion.io/x/onecloud/pkg/mcclient/modules/scheduler" + computeoptions "yunion.io/x/onecloud/pkg/mcclient/options/compute" + "yunion.io/x/onecloud/pkg/mcp-server/options" +) + +const ( + serverCreatePollInterval = 5 * time.Second +) + +func cloneArgs(src map[string]interface{}) map[string]interface{} { + dst := make(map[string]interface{}, len(src)+1) + for k, v := range src { + dst[k] = v + } + return dst +} + +func argLookup(args map[string]interface{}, keys ...string) (interface{}, bool) { + normalize := func(k string) string { + return strings.ReplaceAll(k, "_", "-") + } + for _, key := range keys { + if v, ok := args[key]; ok { + return v, true + } + alt := strings.ReplaceAll(key, "-", "_") + if v, ok := args[alt]; ok { + return v, true + } + want := normalize(key) + for k, v := range args { + if normalize(k) == want { + return v, true + } + } + } + return nil, false +} + +func firstString(v interface{}) string { + parts := valueToArgvParts(v) + if len(parts) == 0 { + return "" + } + return parts[0] +} + +// mapCreateArgsToForecastArgs 将 server-create 参数映射为 scheduler-forecast CLI 参数。 +func mapCreateArgsToForecastArgs(args map[string]interface{}) map[string]interface{} { + out := map[string]interface{}{} + // SchedulerForecastOptions 嵌入 ServerConfigs,CLI token 与 create 侧大多一致 + copyKeys := []struct { + from []string + to string + }{ + {[]string{"disk"}, "disk"}, + {[]string{"net"}, "net"}, + {[]string{"region", "prefer-region"}, "region"}, + {[]string{"zone", "prefer-zone"}, "zone"}, + {[]string{"host", "prefer-host"}, "host"}, + {[]string{"manager", "prefer-manager"}, "manager"}, + {[]string{"hypervisor"}, "hypervisor"}, + {[]string{"project", "tenant"}, "project"}, + {[]string{"count"}, "count"}, + {[]string{"schedtag"}, "schedtag"}, + {[]string{"ncpu"}, "ncpu"}, + {[]string{"cdrom", "iso"}, "cdrom"}, + {[]string{"sku", "instance-type"}, "sku"}, + } + for _, item := range copyKeys { + if v, ok := argLookup(args, item.from...); ok { + out[item.to] = v + } + } + // 无 sku 时,把 mem-spec(如 2048M/2G)转成 forecast 的 --mem(MB) + if _, hasSku := out["sku"]; !hasSku { + if v, ok := argLookup(args, "mem-spec"); ok { + spec := firstString(v) + if regutils.MatchSize(spec) { + if mb, err := fileutils.GetSizeMb(spec, 'M', 1024); err == nil && mb > 0 { + out["mem"] = strconv.Itoa(mb) + } + } else if n, err := strconv.Atoi(strings.TrimSpace(spec)); err == nil && n > 0 { + out["mem"] = strconv.Itoa(n) + } + } + } + return out +} + +func findCommand(name string) (shell.CMD, bool) { + for _, cmd := range shell.CommandTable { + if cmd.Command == name { + return cmd, true + } + } + return shell.CMD{}, false +} + +func runSchedulerForecast(session *mcclient.ClientSession, createArgs map[string]interface{}) (jsonutils.JSONObject, error) { + cmd, ok := findCommand("scheduler-forecast") + if !ok { + return nil, fmt.Errorf("scheduler-forecast command not registered; import climc shell/scheduler") + } + forecastArgs := mapCreateArgsToForecastArgs(createArgs) + + parser, optPtr, err := newArgumentParser(cmd) + if err != nil { + return nil, err + } + argv := mcpArgsToArgv(parser, forecastArgs) + if err := parser.ParseArgs(argv, false); err != nil { + return nil, fmt.Errorf("parse scheduler-forecast args: %w (argv=%v mapped=%v)", err, argv, forecastArgs) + } + filled := parser.Options() + opts, ok := filled.(*computeoptions.SchedulerForecastOptions) + if !ok { + if o, ok2 := optPtr.(*computeoptions.SchedulerForecastOptions); ok2 { + opts = o + } else { + return nil, fmt.Errorf("unexpected scheduler-forecast options type %T", filled) + } + } + input, err := opts.Params(session) + if err != nil { + return nil, fmt.Errorf("build forecast input: %w", err) + } + prepareForecastInput(input) + return schedmodules.SchedManager.DoForecast(session, input.JSON(input)) +} + +// prepareForecastInput 修正公有云 forecast 入参,避免被当成 KVM 去跑 host_cpu/host_memory。 +// 公有云驱动 DoScheduleCPUFilter/MemoryFilter=false;但若 hypervisor/provider 对不上, +// GetHypervisorDriver() 为 nil,host_cpu 仍会执行,而阿里云宿主机是虚拟的,cpu total/free 常为 0。 +func prepareForecastInput(input *schedapi.ScheduleInput) { + if input == nil { + return + } + if input.ServerConfigs == nil { + input.ServerConfigs = &compute.ServerConfigs{} + } + hv := strings.ToLower(strings.TrimSpace(input.Hypervisor)) + if !isManagedHypervisor(hv) { + return + } + if prov := providerFromHypervisor(hv); prov != "" { + if input.Provider == "" || input.Provider == compute.CLOUD_PROVIDER_ONECLOUD { + input.Provider = prov + } + } + // 公有云按套餐调度:清掉 climc forecast Options 的默认 ncpu=1/mem=512, + // 防止 driver 解析失败时 host_cpu 用默认核数把所有宿主机滤掉。 + if strings.TrimSpace(input.InstanceType) != "" { + input.Ncpu = 0 + input.Memory = 0 + } +} + +func isManagedHypervisor(hv string) bool { + switch hv { + case compute.HYPERVISOR_KVM, compute.HYPERVISOR_BAREMETAL, compute.HYPERVISOR_POD, + "hypervisor", "": + return false + default: + return hv != "" + } +} + +func providerFromHypervisor(hv string) string { + switch strings.ToLower(hv) { + case compute.HYPERVISOR_ALIYUN: + return compute.CLOUD_PROVIDER_ALIYUN + case compute.HYPERVISOR_AWS: + return compute.CLOUD_PROVIDER_AWS + case compute.HYPERVISOR_AZURE: + return compute.CLOUD_PROVIDER_AZURE + case compute.HYPERVISOR_QCLOUD: + return compute.CLOUD_PROVIDER_QCLOUD + case compute.HYPERVISOR_HUAWEI: + return compute.CLOUD_PROVIDER_HUAWEI + case compute.HYPERVISOR_GOOGLE: + return compute.CLOUD_PROVIDER_GOOGLE + case compute.HYPERVISOR_OPENSTACK: + return compute.CLOUD_PROVIDER_OPENSTACK + default: + // 多数公有云 hypervisor 与 provider 仅大小写不同 + if hv == "" { + return "" + } + return strings.ToUpper(hv[:1]) + hv[1:] + } +} + +// readyForecastCandidates 返回 forecast 中 error 为空的候选(与调度历史 Result.candidates 成功语义一致)。 +func readyForecastCandidates(forecast jsonutils.JSONObject) []jsonutils.JSONObject { + if forecast == nil { + return nil + } + arr, err := forecast.GetArray("candidates") + if err != nil || len(arr) == 0 { + return nil + } + ready := make([]jsonutils.JSONObject, 0, len(arr)) + for _, c := range arr { + errStr, _ := c.GetString("error") + if strings.TrimSpace(errStr) != "" { + continue + } + ready = append(ready, c) + } + return ready +} + +// forecastSucceeded 判断预调度是否成功:优先看合格 candidates,兼容 can_create。 +func forecastSucceeded(forecast jsonutils.JSONObject) error { + if forecast == nil { + return fmt.Errorf("scheduler-forecast returned empty result") + } + ready := readyForecastCandidates(forecast) + reqCount, err := forecast.Int("req_count") + if err != nil || reqCount <= 0 { + reqCount = 1 + } + if int64(len(ready)) >= reqCount { + return nil + } + if jsonutils.QueryBoolean(forecast, "can_create", false) { + return nil + } + reasons, _ := forecast.Get("not_allow_reasons") + allow, _ := forecast.Int("allow_count") + return fmt.Errorf( + "scheduler-forecast not schedulable: ready_candidates=%d allow_count=%d req_count=%d can_create=false reasons=%s\nforecast=%s", + len(ready), allow, reqCount, reasons, forecast.String(), + ) +} + +func extractJSONObject(s string) json.RawMessage { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + var obj interface{} + if err := json.Unmarshal([]byte(s), &obj); err == nil { + b, _ := json.Marshal(obj) + return b + } + b, _ := json.Marshal(map[string]string{"raw": s}) + return b +} + +func extractServerID(createOut string) string { + createOut = strings.TrimSpace(createOut) + var obj map[string]interface{} + if err := json.Unmarshal([]byte(createOut), &obj); err != nil { + return "" + } + if id, ok := obj["id"].(string); ok { + return id + } + return "" +} + +func isTerminalSuccessStatus(status string) bool { + switch status { + case compute.VM_RUNNING, compute.VM_READY: + return true + default: + return false + } +} + +func isTerminalFailStatus(status string) bool { + switch status { + case compute.VM_CREATE_FAILED, + compute.VM_SCHEDULE_FAILED, + compute.VM_DEPLOY_FAILED, + compute.VM_START_FAILED, + compute.VM_DISK_FAILED, + compute.VM_NETWORK_FAILED, + compute.VM_DEVICE_FAILED, + compute.VM_UNKNOWN: + return true + default: + return strings.Contains(status, "fail") + } +} + +func waitServerRunningOrReady(ctx context.Context, session *mcclient.ClientSession, id string) (string, error) { + deadline := time.Now().Add(options.ServerCreateWaitDuration()) + var lastStatus string + for time.Now().Before(deadline) { + if err := ctx.Err(); err != nil { + return lastStatus, err + } + obj, err := modules.Servers.Get(session, id, nil) + if err != nil { + log.Warningf("wait server %s: get failed: %s", id, err) + select { + case <-ctx.Done(): + return lastStatus, ctx.Err() + case <-time.After(serverCreatePollInterval): + } + continue + } + lastStatus, _ = obj.GetString("status") + if isTerminalSuccessStatus(lastStatus) { + return lastStatus, nil + } + if isTerminalFailStatus(lastStatus) { + return lastStatus, fmt.Errorf("server %s entered failed status %q", id, lastStatus) + } + select { + case <-ctx.Done(): + return lastStatus, ctx.Err() + case <-time.After(serverCreatePollInterval): + } + } + return lastStatus, fmt.Errorf("timeout waiting server %s to become running/ready, last status=%q", id, lastStatus) +} + +func diskHasBackend(disk string) bool { + for _, part := range strings.Split(disk, ",") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + key := strings.ToLower(strings.TrimSpace(kv[0])) + if (key == "backend" || key == "storage_type") && strings.TrimSpace(kv[1]) != "" { + return true + } + } + return false +} + +func preferDiskBackend(types []string) string { + if len(types) == 0 { + return "" + } + prefer := []string{"cloud_essd", "cloud_ssd", "cloud_efficiency", "cloud", "local"} + lower := make([]string, len(types)) + for i, t := range types { + lower[i] = strings.ToLower(strings.TrimSpace(t)) + } + for _, p := range prefer { + for i, t := range lower { + if t == p { + return types[i] + } + } + } + return types[0] +} + +func storageTypesFromCapability(capa jsonutils.JSONObject, hypervisor string) []string { + if capa == nil { + return nil + } + hv := strings.ToLower(strings.TrimSpace(hypervisor)) + asStrings := func(obj jsonutils.JSONObject) []string { + if obj == nil { + return nil + } + if arr, ok := obj.(*jsonutils.JSONArray); ok { + out := arr.GetStringArray() + if len(out) > 0 { + return out + } + } + if s, err := obj.GetString(); err == nil && strings.TrimSpace(s) != "" { + return []string{s} + } + return nil + } + for _, key := range []string{"storage_types2", "StorageTypes2"} { + m, err := capa.GetMap(key) + if err != nil || len(m) == 0 { + continue + } + if hv != "" { + for k, v := range m { + if strings.ToLower(k) == hv { + if arr := asStrings(v); len(arr) > 0 { + return arr + } + } + } + } + for _, v := range m { + if arr := asStrings(v); len(arr) > 0 { + return arr + } + } + } + return nil +} + +// ensureDiskBackend 公有云 disk 缺 backend 时,按 prefer-region 拉 cloud-region-capability 自动补全。 +func ensureDiskBackend(session *mcclient.ClientSession, args map[string]interface{}) { + var hv string + if v, ok := argLookup(args, "hypervisor"); ok { + hv = firstString(v) + } + if !isManagedHypervisor(strings.ToLower(hv)) { + return + } + rawDisk, ok := argLookup(args, "disk") + if !ok { + return + } + disks := valueToArgvParts(rawDisk) + if len(disks) == 0 { + return + } + need := false + for _, d := range disks { + if !diskHasBackend(d) { + need = true + break + } + } + if !need { + return + } + var regionID string + if v, ok := argLookup(args, "prefer-region", "region"); ok { + regionID = firstString(v) + } + if regionID == "" { + log.Warningf("disk missing backend but prefer-region empty; skip auto-fill") + return + } + capa, err := modules.Cloudregions.GetSpecific(session, regionID, "capability", nil) + if err != nil { + log.Warningf("cloud-region-capability %s failed: %s", regionID, err) + return + } + backend := preferDiskBackend(storageTypesFromCapability(capa, hv)) + if backend == "" { + log.Warningf("cloud-region-capability %s has no storage_types2 for %s", regionID, hv) + return + } + for i, d := range disks { + if !diskHasBackend(d) { + disks[i] = strings.TrimSuffix(d, ",") + ",backend=" + backend + } + } + args["disk"] = disks + log.Infof("auto-filled disk backend=%s from region %s capability", backend, regionID) +} + +// ensureNetworkAutoSched:未指定网络时注入 CLI "random"(ParseNetworkConfig → Exit=false), +// 等价 API nets:[{"exit":false}],由调度器自动选网。 +func ensureNetworkAutoSched(args map[string]interface{}) { + raw, ok := argLookup(args, "net", "nets") + if ok { + parts := valueToArgvParts(raw) + if len(parts) > 0 { + return + } + } + args["net"] = []string{"random"} + delete(args, "nets") + log.Infof("auto-filled net=random (API equivalent nets:[{exit:false}]) for network auto schedule") +} + +// ensureGenerateName:默认开启 generate-name,用 NAME 作为模板自动去重,避免 DuplicateNameError。 +func ensureGenerateName(args map[string]interface{}) { + if _, ok := argLookup(args, "generate-name", "generate_name", "GenerateName"); ok { + return + } + args["generate-name"] = true + log.Infof("auto-enabled generate-name to avoid DuplicateNameError") +} + +// handleServerCreate:创建前 scheduler-forecast 预调度,创建后等待 running 或 ready(关机)。 +// dry-run 仅做参数校验,不是预调度。等待超时不视为失败:返回 server_id 供 agent 继续查询。 +func (t *ClimcTool) handleServerCreate(ctx context.Context, session *mcclient.ClientSession, args map[string]interface{}) (string, error) { + ensureDiskBackend(session, args) + ensureNetworkAutoSched(args) + ensureGenerateName(args) + + // 1) 预调度(scheduler-forecast) + forecast, err := runSchedulerForecast(session, args) + if err != nil { + return "", fmt.Errorf("scheduler-forecast failed: %w", err) + } + if err := forecastSucceeded(forecast); err != nil { + return "", err + } + forecastRaw := json.RawMessage(forecast.String()) + + // 2) 真实创建(去掉 dry-run,避免走 suggestion 旁路) + createArgs := cloneArgs(args) + delete(createArgs, "dry-run") + delete(createArgs, "dry_run") + delete(createArgs, "DryRun") + createOut, err := invokeCommand(t.cmd, session, createArgs) + if err != nil { + return "", fmt.Errorf("create failed after scheduler-forecast ok: %w\nforecast:\n%s\noutput:\n%s", err, forecast.String(), createOut) + } + + serverID := extractServerID(createOut) + result := map[string]interface{}{ + "preschedule": forecastRaw, + "server": json.RawMessage(extractJSONObject(createOut)), + } + if serverID == "" { + result["wait_error"] = "create succeeded but server id missing; skip wait" + b, _ := json.Marshal(result) + return string(b), nil + } + + // 3) 等待 running / ready + status, waitErr := waitServerRunningOrReady(ctx, session, serverID) + result["final_status"] = status + result["server_id"] = serverID + if waitErr != nil { + result["wait_error"] = waitErr.Error() + if obj, gerr := modules.Servers.Get(session, serverID, nil); gerr == nil { + result["server"] = json.RawMessage(obj.String()) + if status == compute.VM_SCHEDULE_FAILED || status == "sched_fail" { + if progress, _ := obj.GetString("progress"); progress != "" { + result["schedule_hint"] = progress + } + result["hint"] = "调度失败(sched_fail)。若未指定网络,应使用自动调度 net=[\"random\"](等价 nets:[{exit:false}]);也可检查 prefer-region、instance-type、disk.backend 是否与区域能力匹配后重试。" + } + } + // 超时/取消:创建已成功,返回 server_id 让 agent 用 climc_server_show 继续查,不把整次 tool 判失败 + if isWaitTimeoutOrCanceled(waitErr) { + result["wait_pending"] = true + if result["hint"] == nil { + result["hint"] = "创建已提交但尚未进入 running/ready。请用 climc_server_show 查询 status,勿重复创建。" + } + b, _ := json.Marshal(result) + return string(b), nil + } + b, _ := json.Marshal(result) + return string(b), waitErr + } + + if obj, gerr := modules.Servers.Get(session, serverID, nil); gerr == nil { + result["server"] = json.RawMessage(obj.String()) + } + b, err := json.Marshal(result) + if err != nil { + return createOut, nil + } + return string(b), nil +} + +func isWaitTimeoutOrCanceled(err error) bool { + if err == nil { + return false + } + if err == context.Canceled || err == context.DeadlineExceeded { + return true + } + msg := err.Error() + return strings.Contains(msg, "timeout waiting server") || strings.Contains(msg, "context canceled") || strings.Contains(msg, "context deadline") +} diff --git a/pkg/mcp-server/climcgen/create_flow_test.go b/pkg/mcp-server/climcgen/create_flow_test.go new file mode 100644 index 0000000000..67521e3583 --- /dev/null +++ b/pkg/mcp-server/climcgen/create_flow_test.go @@ -0,0 +1,174 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "strings" + "testing" + + "yunion.io/x/jsonutils" + + _ "yunion.io/x/onecloud/cmd/climc/shell/scheduler" + "yunion.io/x/onecloud/pkg/apis/compute" + schedapi "yunion.io/x/onecloud/pkg/apis/scheduler" +) + +func TestMapCreateArgsToForecastArgs(t *testing.T) { + got := mapCreateArgsToForecastArgs(map[string]interface{}{ + "name": "mcp-test", + "disk": []interface{}{"size=40g,image=img-1"}, + "net": []interface{}{"net-1"}, + "prefer-region": "reg-1", + "hypervisor": "aliyun", + "ncpu": 4, + "instance-type": "ecs.c7t.xlarge", + "mem-spec": "8G", // 有 sku 时不应再映射 mem + "dry-run": true, + }) + if got["region"] != "reg-1" { + t.Fatalf("region=%v", got["region"]) + } + if got["sku"] != "ecs.c7t.xlarge" { + t.Fatalf("sku=%v", got["sku"]) + } + if _, ok := got["mem"]; ok { + t.Fatalf("mem should be omitted when sku present, got %v", got["mem"]) + } + if _, ok := got["dry-run"]; ok { + t.Fatalf("dry-run must not be forwarded to forecast") + } + + got = mapCreateArgsToForecastArgs(map[string]interface{}{ + "ncpu": 2, + "mem-spec": "2048M", + "disk": []string{"size=30g,image=x"}, + "net": []string{"n1"}, + "region": "cn-beijing", + }) + if got["mem"] != "2048" { + t.Fatalf("mem=%v want 2048", got["mem"]) + } + if got["region"] != "cn-beijing" { + t.Fatalf("region=%v", got["region"]) + } +} + +func TestSchedulerForecastCommandRegistered(t *testing.T) { + cmd, ok := findCommand("scheduler-forecast") + if !ok { + t.Fatal("scheduler-forecast not in CommandTable; imports.go must blank-import shell/scheduler") + } + if cmd.Options == nil { + t.Fatal("scheduler-forecast Options is nil") + } +} + +func TestPrepareForecastInputManagedCloud(t *testing.T) { + input := &schedapi.ScheduleInput{} + input.ServerConfigs = &compute.ServerConfigs{ + Hypervisor: compute.HYPERVISOR_ALIYUN, + InstanceType: "ecs.c7t.xlarge", + Provider: compute.CLOUD_PROVIDER_ONECLOUD, // 错误默认 + } + input.Ncpu = 2 + input.Memory = 2048 + prepareForecastInput(input) + if input.Provider != compute.CLOUD_PROVIDER_ALIYUN { + t.Fatalf("provider=%q want Aliyun", input.Provider) + } + if input.Ncpu != 0 || input.Memory != 0 { + t.Fatalf("managed+sku should clear default ncpu/mem, got ncpu=%d mem=%d", input.Ncpu, input.Memory) + } +} + +func TestForecastSucceededByCandidates(t *testing.T) { + okForecast := jsonutils.Marshal(map[string]interface{}{ + "can_create": true, + "req_count": 1, + "candidates": []map[string]interface{}{ + {"host_id": "h1", "name": "qx-aliyun-cn-beijing-i", "error": ""}, + }, + }) + if err := forecastSucceeded(okForecast); err != nil { + t.Fatalf("expected success: %v", err) + } + failForecast := jsonutils.Marshal(map[string]interface{}{ + "can_create": false, + "req_count": 1, + "candidates": nil, + "not_allow_reasons": []string{"Out of resource"}, + }) + if err := forecastSucceeded(failForecast); err == nil { + t.Fatal("expected failure") + } +} + +func TestPreferDiskBackend(t *testing.T) { + if got := preferDiskBackend([]string{"cloud", "cloud_essd", "cloud_ssd"}); got != "cloud_essd" { + t.Fatalf("prefer=%q", got) + } + capa := jsonutils.Marshal(map[string]interface{}{ + "storage_types2": map[string][]string{ + "aliyun": {"cloud_efficiency", "cloud_ssd"}, + }, + }) + types := storageTypesFromCapability(capa, "aliyun") + if preferDiskBackend(types) != "cloud_ssd" { + t.Fatalf("types=%v prefer=%q", types, preferDiskBackend(types)) + } + if diskHasBackend("size=30g,image=x") { + t.Fatal("should not have backend") + } + if !diskHasBackend("size=30g,image=x,backend=cloud_essd") { + t.Fatal("should have backend") + } +} + +func TestEnsureNetworkAutoSched(t *testing.T) { + args := map[string]interface{}{"name": "t"} + ensureNetworkAutoSched(args) + parts := valueToArgvParts(args["net"]) + if len(parts) != 1 || parts[0] != "random" { + t.Fatalf("expected net=[random], got %v", args["net"]) + } + + args = map[string]interface{}{"net": []interface{}{}} + ensureNetworkAutoSched(args) + parts = valueToArgvParts(args["net"]) + if len(parts) != 1 || parts[0] != "random" { + t.Fatalf("empty net should become random, got %v", args["net"]) + } + + args = map[string]interface{}{"net": []string{"net-abc"}} + ensureNetworkAutoSched(args) + parts = valueToArgvParts(args["net"]) + if len(parts) != 1 || parts[0] != "net-abc" { + t.Fatalf("explicit net must be kept, got %v", args["net"]) + } +} + +func TestServerCreateDescriptionMentionsForecast(t *testing.T) { + cmd, ok := findCommand("server-create") + if !ok { + t.Fatal("server-create not registered") + } + desc := buildDescription(cmd) + if !strings.Contains(desc, "scheduler-forecast") { + t.Fatalf("mcp-desc should mention scheduler-forecast, got %q", desc) + } + if strings.Contains(desc, "dry-run 预调度") { + t.Fatalf("mcp-desc must not claim dry-run is preschedule: %q", desc) + } +} diff --git a/pkg/mcp-server/climcgen/doc.go b/pkg/mcp-server/climcgen/doc.go new file mode 100644 index 0000000000..dde826aeaf --- /dev/null +++ b/pkg/mcp-server/climcgen/doc.go @@ -0,0 +1,30 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +Package climcgen 从 climc 的 shell.CommandTable 与 Options struct tag +自动生成 MCP tool schema,并调用对应 callback 执行。 + +注册范围:只注册 Options 上带 mcp-desc 的命令。 + +AI/MCP 参数通过 Options 字段上的 mcp tag 标记: + - mcp:"true" 暴露给 MCP schema + - mcp:"required" 暴露且在 MCP schema 中标记为 required + +未标记的可选参数不会进入 schema(positional / climc required 仍会保留)。 + +MCP tool 补充说明通过 Options 上的 mcp-desc tag 写入(常用 `_ struct{}` 承载), +由 buildDescription 拼入 tool description。 +*/ +package climcgen diff --git a/pkg/mcp-server/climcgen/exec.go b/pkg/mcp-server/climcgen/exec.go new file mode 100644 index 0000000000..69c2fb899d --- /dev/null +++ b/pkg/mcp-server/climcgen/exec.go @@ -0,0 +1,285 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + + "yunion.io/x/structarg" + + "yunion.io/x/onecloud/cmd/climc/shell" + "yunion.io/x/onecloud/pkg/mcclient" + computeoptions "yunion.io/x/onecloud/pkg/mcclient/options/compute" +) + +func mcpArgsToArgv(parser *structarg.ArgumentParser, args map[string]interface{}) []string { + argv := make([]string, 0) + + normalize := func(k string) string { + return strings.ReplaceAll(k, "_", "-") + } + lookup := func(token string) (interface{}, bool) { + if v, ok := args[token]; ok { + return v, true + } + alt := strings.ReplaceAll(token, "-", "_") + if v, ok := args[alt]; ok { + return v, true + } + for k, v := range args { + if normalize(k) == token { + return v, true + } + } + return nil, false + } + + for _, arg := range parser.GetPosArgs() { + v, ok := lookup(arg.Token()) + if !ok { + continue + } + argv = append(argv, valueToArgvParts(v)...) + } + + for _, arg := range parser.GetOptArgs() { + token := arg.Token() + if token == "help" { + continue + } + v, ok := lookup(token) + if !ok { + continue + } + if !arg.NeedData() { + if isTruthy(v) { + argv = append(argv, "--"+token) + } else if neg := arg.NegativeToken(); neg != "" && isFalsy(v) { + argv = append(argv, "--"+neg) + } + continue + } + if arg.IsMulti() { + for _, part := range valueToArgvParts(v) { + argv = append(argv, "--"+token, part) + } + continue + } + parts := valueToArgvParts(v) + if len(parts) == 0 { + continue + } + argv = append(argv, "--"+token, parts[0]) + } + return argv +} + +func valueToArgvParts(v interface{}) []string { + if v == nil { + return nil + } + switch x := v.(type) { + case string: + if x == "" { + return nil + } + return []string{x} + case bool: + return []string{strconv.FormatBool(x)} + case float64: + if x == float64(int64(x)) { + return []string{strconv.FormatInt(int64(x), 10)} + } + return []string{strconv.FormatFloat(x, 'f', -1, 64)} + case int: + return []string{strconv.Itoa(x)} + case int64: + return []string{strconv.FormatInt(x, 10)} + case []interface{}: + out := make([]string, 0, len(x)) + for _, item := range x { + out = append(out, valueToArgvParts(item)...) + } + return out + case []string: + out := make([]string, 0, len(x)) + for _, item := range x { + if item != "" { + out = append(out, item) + } + } + return out + default: + s := fmt.Sprint(x) + if s == "" || s == "" { + return nil + } + return []string{s} + } +} + +func isTruthy(v interface{}) bool { + switch x := v.(type) { + case bool: + return x + case string: + s := strings.ToLower(strings.TrimSpace(x)) + return s == "true" || s == "1" || s == "yes" || s == "on" + case float64: + return x != 0 + case int: + return x != 0 + default: + return false + } +} + +func isFalsy(v interface{}) bool { + switch x := v.(type) { + case bool: + return !x + case string: + s := strings.ToLower(strings.TrimSpace(x)) + return s == "false" || s == "0" || s == "no" || s == "off" + default: + return false + } +} + +func invokeCommand(cmd shell.CMD, session *mcclient.ClientSession, args map[string]interface{}) (string, error) { + parser, _, err := newArgumentParser(cmd) + if err != nil { + return "", err + } + + // list 查询默认 scope=max,避免权限范围过窄查不到资源 + if strings.HasSuffix(cmd.Command, "-list") { + if _, ok := args["scope"]; !ok { + if _, ok := args["Scope"]; !ok { + args["scope"] = "max" + } + } + } + + // 创建虚拟机前查区域:默认 usable=true,只返回网络可用的区域 + if cmd.Command == "cloud-region-list" { + if _, ok := argLookup(args, "usable"); !ok { + args["usable"] = true + } + } + + if cmd.Command == "server-sku-list" { + normalizeServerSkuListArgs(args) + } + + argv := mcpArgsToArgv(parser, args) + if err := parser.ParseArgs(argv, false); err != nil { + return "", fmt.Errorf("parse args for %s: %w (argv=%v)", cmd.Command, err, argv) + } + filled := parser.Options() + + cbVal := reflect.ValueOf(cmd.Callback) + if cbVal.Kind() != reflect.Func { + return "", fmt.Errorf("callback of %s is not a function", cmd.Command) + } + + // 使用 goroutine 本地 writer,避免劫持全局 os.Stdout / stdoutMu 串行化所有 tools/call。 + var buf bytes.Buffer + restore := shell.PushOutput(&buf, shell.OUTPUT_FORMAT_JSON) + defer restore() + + var callErr error + func() { + defer func() { + if rec := recover(); rec != nil { + callErr = fmt.Errorf("panic invoking %s: %v", cmd.Command, rec) + } + }() + outs := cbVal.Call([]reflect.Value{ + reflect.ValueOf(session), + reflect.ValueOf(filled), + }) + if len(outs) == 1 && !outs[0].IsNil() { + callErr = outs[0].Interface().(error) + } + }() + + out := buf.String() + if callErr != nil { + return out, callErr + } + // 压缩 JSON,降低模型上下文占用,减少“只看完区域就不往下走” + if compact := compactJSON(out); compact != "" { + out = compact + } + return out, nil +} + +func compactJSON(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + var v interface{} + if err := json.Unmarshal([]byte(s), &v); err != nil { + return "" + } + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} + +// normalizeServerSkuListArgs 让 AI 传的 2c2g / cpu-core-count 等别名落到 climc 的 spec/cpu/mem。 +func normalizeServerSkuListArgs(args map[string]interface{}) { + aliasCopy := func(from, to string) { + if _, ok := argLookup(args, to); ok { + return + } + if v, ok := argLookup(args, from); ok { + args[to] = v + } + } + aliasCopy("cpu-core-count", "cpu") + aliasCopy("cpu_core_count", "cpu") + aliasCopy("memory-size-mb", "mem") + aliasCopy("memory_size_mb", "mem") + + if _, hasSpec := argLookup(args, "spec"); !hasSpec { + // search/name 里如果是口语规格,提升为 spec + for _, key := range []string{"search", "name"} { + v, ok := argLookup(args, key) + if !ok { + continue + } + s := strings.TrimSpace(firstString(v)) + if s == "" { + continue + } + if _, _, err := computeoptions.ParseSkuSpec(s); err == nil { + args["spec"] = s + delete(args, key) + delete(args, strings.ReplaceAll(key, "-", "_")) + break + } + } + } +} diff --git a/pkg/mcp-server/climcgen/extra.go b/pkg/mcp-server/climcgen/extra.go new file mode 100644 index 0000000000..92ba22265f --- /dev/null +++ b/pkg/mcp-server/climcgen/extra.go @@ -0,0 +1,75 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "strings" + + "yunion.io/x/log" +) + +// ExtraToolsFunc 通过 RegisterExtraTools 注册,用于追加 MCP tools。 +type ExtraToolsFunc func() []Tool + +// ExtraInstructionsFunc 通过 RegisterExtraInstructions 注册,追加到 MCP ServerInstructions。 +type ExtraInstructionsFunc func() string + +var ( + extraToolsFuncs []ExtraToolsFunc + extraInstructionsFuncs []ExtraInstructionsFunc +) + +// RegisterExtraTools 注册额外工具构建回调。应在 init() 中调用;StartService 前完成注册。 +func RegisterExtraTools(fn ExtraToolsFunc) { + if fn == nil { + return + } + extraToolsFuncs = append(extraToolsFuncs, fn) +} + +// RegisterExtraInstructions 注册额外全局说明回调。 +func RegisterExtraInstructions(fn ExtraInstructionsFunc) { + if fn == nil { + return + } + extraInstructionsFuncs = append(extraInstructionsFuncs, fn) +} + +// BuildExtraTools 执行已注册的 ExtraTools 回调,合并返回。 +func BuildExtraTools() []Tool { + out := make([]Tool, 0) + for _, fn := range extraToolsFuncs { + tools := fn() + if len(tools) == 0 { + continue + } + out = append(out, tools...) + for _, t := range tools { + log.Infof("climcgen: registered extra MCP tool %s", t.GetName()) + } + } + return out +} + +// BuildExtraInstructions 合并已注册的额外说明。 +func BuildExtraInstructions() string { + parts := make([]string, 0, len(extraInstructionsFuncs)) + for _, fn := range extraInstructionsFuncs { + if s := strings.TrimSpace(fn()); s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, "\n\n") +} diff --git a/pkg/mcp-server/tools/tools.go b/pkg/mcp-server/climcgen/imports.go similarity index 61% rename from pkg/mcp-server/tools/tools.go rename to pkg/mcp-server/climcgen/imports.go index c187e69117..aa88b48ffd 100644 --- a/pkg/mcp-server/tools/tools.go +++ b/pkg/mcp-server/climcgen/imports.go @@ -12,20 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -package tools +package climcgen +// 导入 climc shell 子包以填充 shell.CommandTable import ( - "context" - - "github.com/mark3labs/mcp-go/mcp" + _ "yunion.io/x/onecloud/cmd/climc/shell/compute" + _ "yunion.io/x/onecloud/cmd/climc/shell/identity" + _ "yunion.io/x/onecloud/cmd/climc/shell/image" + _ "yunion.io/x/onecloud/cmd/climc/shell/logger" + _ "yunion.io/x/onecloud/cmd/climc/shell/monitor" + _ "yunion.io/x/onecloud/cmd/climc/shell/scheduler" ) - -// Tool 是所有工具的接口,定义了工具的基本方法 -// GetTool 返回 MCP 工具定义 -// Handle 处理工具调用请求 -// GetName 返回工具名称 -type Tool interface { - GetTool() mcp.Tool - Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) - GetName() string -} diff --git a/pkg/mcp-server/climcgen/instructions.go b/pkg/mcp-server/climcgen/instructions.go new file mode 100644 index 0000000000..932d59fe26 --- /dev/null +++ b/pkg/mcp-server/climcgen/instructions.go @@ -0,0 +1,56 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "fmt" + "strings" + + "yunion.io/x/onecloud/pkg/mcp-server/options" +) + +// serverInstructionsTemplate 返回给 MCP 客户端的全局使用说明模板,%s 为 PlatformName。 +// 细则写在本说明;各工具 mcp-desc 保持一行摘要,避免重复占 token。 +const serverInstructionsTemplate = `%s MCP(climc tools)使用规则: + +认证:initialize / tools/list 可匿名;tools/call 必须带 Header(X-Auth-Token,或 AK+SK,或 X-API-Key=base64(ak:sk)),不要在工具参数传密钥。 + +0. 严禁空口编造:未实际调用 climc_* 并拿到返回前,禁止声称“已查到/已创建成功”。 +1. *-list 只用于准备参数,不等于任务完成;同一轮可并行多个查询。 +2. 创建虚拟机(连续到 climc_server_create): + a) climc_cloud_region_list(公有云须 provider;创建时 usable=true) + b) climc_cloud_region_capability → 取 storage_types2 作 disk.backend + c) climc_image_list(KVM)或 climc_cached_image_list(公有云,须 provider+region=区域 id) + d) climc_server_sku_list(口语 2c2g 用 spec=\"2c2g\")或直接 ncpu/mem + e) climc_server_create(name、disk 含 image+backend、规格;公有云 hypervisor+prefer-region)。net 可省略自动调度。工具会 forecast 预调度并等待 running/ready;若返回 wait_pending,用 climc_server_show 继续查,勿重复创建。 +3. 启停/重启/删除/重置密码/改配/挂盘/绑 EIP:climc_server_list 定位 id 后立刻调用对应操作工具。 +4. 监控指标用 climc_monitor_unifiedmonitor_query;climc_server_monitor 是 QEMU HMP/QMP,不是指标。 +5. 缺参只追问真正缺失项;已有 id 直接下一步。 +` + +// BuildServerInstructions 用 platformName(BaseOptions.PlatformName)生成 MCP ServerInstructions。 +// platformName 为空时使用 options.ResolvedPlatformName()。 +func BuildServerInstructions(platformName string) string { + name := strings.TrimSpace(platformName) + if name == "" { + name = options.ResolvedPlatformName() + } + return fmt.Sprintf(serverInstructionsTemplate, name) +} + +// isCreateFlowListCommand:创建流程中间查询工具(Options mcp-desc 含该标记)。 +func isCreateFlowListCommand(opt interface{}) bool { + return strings.Contains(collectMcpDesc(opt), "创建流程中的中间步骤") +} diff --git a/pkg/mcp-server/climcgen/mcp_tags.go b/pkg/mcp-server/climcgen/mcp_tags.go new file mode 100644 index 0000000000..ea167e2e72 --- /dev/null +++ b/pkg/mcp-server/climcgen/mcp_tags.go @@ -0,0 +1,139 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "reflect" + "strings" + + "yunion.io/x/pkg/util/reflectutils" + "yunion.io/x/pkg/utils" + "yunion.io/x/structarg" +) + +// TagMCP 与 climc Options 字段上的 mcp tag 对应。 +// 用法:`mcp:"true"` 表示暴露给 AI/MCP;`mcp:"required"` 表示 MCP 调用时建议必填。 +const TagMCP = "mcp" + +// TagMCPDesc 写在 Options 结构体任意字段上(常用 `_ struct{}`),作为 MCP tool 补充说明。 +// 例:`_ struct{} \`mcp-desc:"创建虚拟机最终动作..."\“ +const TagMCPDesc = "mcp-desc" + +type mcpFieldMeta struct { + Required bool +} + +// collectMcpDesc 读取 Options 上的 mcp-desc。 +// 只看本结构体的非嵌入字段,不递归匿名嵌入,避免复用 Options(如 statistics 嵌入 List)被误注册。 +func collectMcpDesc(optionsProto interface{}) string { + if optionsProto == nil { + return "" + } + return collectMcpDescFromType(reflect.TypeOf(optionsProto)) +} + +func collectMcpDescFromType(t reflect.Type) string { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return "" + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.Anonymous { + continue + } + if d := strings.TrimSpace(f.Tag.Get(TagMCPDesc)); d != "" { + return d + } + } + return "" +} + +// collectMcpFields 从 Options 结构体读取 mcp tag,返回 CLI token -> 元信息。 +// token 计算方式与 structarg 一致(token 标签或 json/字段名,再 CamelSplit 为 kebab-case)。 +func collectMcpFields(optionsProto interface{}) map[string]mcpFieldMeta { + out := make(map[string]mcpFieldMeta) + if optionsProto == nil { + return out + } + v := reflect.ValueOf(optionsProto) + for v.Kind() == reflect.Ptr { + if v.IsNil() { + v = reflect.New(v.Type().Elem()) + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return out + } + + sets := reflectutils.FetchAllStructFieldValueSetForWrite(v) + for i := range sets { + info := sets[i].Info + if info == nil { + continue + } + tagMap := info.Tags + mcpVal, ok := tagMap[TagMCP] + if !ok || mcpVal == "" || mcpVal == "false" { + continue + } + // 即便 json:"-"(Ignore)也保留:climc 仍可能用字段名作为 CLI token(如 MemSpec -> mem-spec) + token, tokOK := tagMap["token"] + if !tokOK { + if jsonName := tagMap["json"]; jsonName != "" && jsonName != "-" { + token = info.MarshalName() + } else if alias := tagMap["alias"]; alias != "" { + token = alias + } else { + // Ignore 字段的 info.Name 为空,必须用 FieldName + token = info.FieldName + } + } + if token == "" { + continue + } + cliToken := utils.CamelSplit(token, "-") + meta := mcpFieldMeta{Required: mcpVal == "required"} + out[cliToken] = meta + // 同时登记字段名 token,兼容 structarg 对 json:"-" 使用字段名的行为 + if fieldTok := utils.CamelSplit(info.FieldName, "-"); fieldTok != "" && fieldTok != cliToken { + out[fieldTok] = meta + } + // json 名与字段名不一致时(如 Region / prefer_region)两边都登记 + if jsonName := tagMap["json"]; jsonName != "" && jsonName != "-" { + if jsonTok := utils.CamelSplit(info.MarshalName(), "-"); jsonTok != "" && jsonTok != cliToken { + out[jsonTok] = meta + } + } + } + return out +} + +// mcpKeepArg:仅保留 positional/required,以及带 mcp tag 的可选参数。 +// 无 mcp tag 的可选参数一律不暴露,避免 schema 过大。 +func mcpKeepArg(fields map[string]mcpFieldMeta, arg structarg.Argument) bool { + name := arg.Token() + if arg.IsRequired() || arg.IsPositional() { + return true + } + if name == "" || name == "help" { + return false + } + _, ok := fields[name] + return ok +} diff --git a/pkg/mcp-server/climcgen/register.go b/pkg/mcp-server/climcgen/register.go new file mode 100644 index 0000000000..e2033de915 --- /dev/null +++ b/pkg/mcp-server/climcgen/register.go @@ -0,0 +1,94 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "fmt" + "sort" + "strings" + + "yunion.io/x/log" + + "yunion.io/x/onecloud/cmd/climc/shell" + "yunion.io/x/onecloud/pkg/mcp-server/adapters" +) + +// BuildTools 扫描 shell.CommandTable,注册 Options 上带 mcp-desc 的命令为 MCP tools。 +func BuildTools(adapter *adapters.CloudpodsAdapter) ([]Tool, error) { + byName := make(map[string]shell.CMD, len(shell.CommandTable)) + for _, cmd := range shell.CommandTable { + byName[cmd.Command] = cmd + } + + commands := discoverMcpDescCommands() + result := make([]Tool, 0, len(commands)) + for _, name := range commands { + cmd, ok := byName[name] + if !ok { + continue + } + tool, err := NewClimcTool(cmd, adapter) + if err != nil { + return nil, fmt.Errorf("build tool for %s: %w", name, err) + } + result = append(result, tool) + log.Infof("climcgen: registered MCP tool %s (%s)", tool.GetName(), name) + } + if len(result) == 0 { + return nil, fmt.Errorf("no climc tools registered; add mcp-desc on Options") + } + return result, nil +} + +// discoverMcpDescCommands 扫描 CommandTable,收集 Options 带 mcp-desc 的命令。 +// server-create /「最终动作」优先排前,便于模型在创建意图下选中。 +func discoverMcpDescCommands() []string { + type ranked struct { + name string + rank int + order int + } + found := make([]ranked, 0) + for i, cmd := range shell.CommandTable { + desc := collectMcpDesc(cmd.Options) + if desc == "" { + continue + } + r := ranked{name: cmd.Command, rank: 2, order: i} + switch { + case cmd.Command == "server-create" || strings.Contains(desc, "最终动作") || strings.Contains(desc, "优先调用"): + r.rank = 0 + case strings.Contains(desc, "创建流程中的中间步骤"): + r.rank = 1 + } + found = append(found, r) + } + sort.SliceStable(found, func(i, j int) bool { + if found[i].rank != found[j].rank { + return found[i].rank < found[j].rank + } + return found[i].order < found[j].order + }) + out := make([]string, 0, len(found)) + seen := make(map[string]bool, len(found)) + for _, r := range found { + if seen[r.name] { + continue + } + seen[r.name] = true + out = append(out, r.name) + } + return out +} diff --git a/pkg/mcp-server/climcgen/register_test.go b/pkg/mcp-server/climcgen/register_test.go new file mode 100644 index 0000000000..29469fc5b9 --- /dev/null +++ b/pkg/mcp-server/climcgen/register_test.go @@ -0,0 +1,73 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "testing" + + "yunion.io/x/onecloud/cmd/climc/shell" +) + +func TestDiscoverMcpDescCommands_UsesTagNotHardcodedList(t *testing.T) { + saved := shell.CommandTable + defer func() { shell.CommandTable = saved }() + + type withDesc struct { + _ struct{} `mcp-desc:"【创建虚拟机的最终动作/优先调用】demo"` + Name string `mcp:"required"` + } + type noDesc struct { + Name string + } + type listDesc struct { + _ struct{} `mcp-desc:"【创建流程中的中间步骤】list demo"` + } + + shell.CommandTable = []shell.CMD{ + {Options: &noDesc{}, Command: "should-skip", Desc: "no mcp-desc"}, + {Options: &listDesc{}, Command: "demo-list", Desc: "list"}, + {Options: &withDesc{}, Command: "server-create", Desc: "create"}, + } + + got := discoverMcpDescCommands() + if len(got) != 2 { + t.Fatalf("want 2 commands, got %v", got) + } + if got[0] != "server-create" { + t.Fatalf("server-create should be first, got %v", got) + } + if got[1] != "demo-list" { + t.Fatalf("want demo-list second, got %v", got) + } +} + +func TestCollectMcpDesc_DoesNotInheritFromEmbed(t *testing.T) { + type base struct { + _ struct{} `mcp-desc:"should not inherit"` + } + type wrapped struct { + base + } + if d := collectMcpDesc(&wrapped{}); d != "" { + t.Fatalf("embedded mcp-desc must not register, got %q", d) + } + type ownDesc struct { + _ struct{} `mcp-desc:"own"` + base + } + if d := collectMcpDesc(&ownDesc{}); d != "own" { + t.Fatalf("want own desc, got %q", d) + } +} diff --git a/pkg/mcp-server/climcgen/schema.go b/pkg/mcp-server/climcgen/schema.go new file mode 100644 index 0000000000..ca9c90ad2a --- /dev/null +++ b/pkg/mcp-server/climcgen/schema.go @@ -0,0 +1,272 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "yunion.io/x/structarg" + + "yunion.io/x/onecloud/cmd/climc/shell" +) + +// maxSchemaProperties 限制生成的参数数量,避免 BaseListOptions 等过大 schema 淹没模型 +const maxSchemaProperties = 48 + +func newOptionsInstance(cmd shell.CMD) (interface{}, error) { + if cmd.Options == nil { + return nil, fmt.Errorf("command %s has nil Options", cmd.Command) + } + t := reflect.TypeOf(cmd.Options) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("command %s Options is not a struct: %s", cmd.Command, t.Kind()) + } + return reflect.New(t).Interface(), nil +} + +func newArgumentParser(cmd shell.CMD) (*structarg.ArgumentParser, interface{}, error) { + optPtr, err := newOptionsInstance(cmd) + if err != nil { + return nil, nil, err + } + parser, err := structarg.NewArgumentParserWithHelp(optPtr, cmd.Command, cmd.Desc, "") + if err != nil { + return nil, nil, fmt.Errorf("build argument parser for %s: %w", cmd.Command, err) + } + return parser, optPtr, nil +} + +func buildInputSchema(cmd shell.CMD) (json.RawMessage, error) { + parser, optPtr, err := newArgumentParser(cmd) + if err != nil { + return nil, err + } + + mcpFields := collectMcpFields(optPtr) + + properties := map[string]interface{}{} + required := make([]string, 0) + requiredSet := map[string]bool{} + + addRequired := func(name string) { + if name == "" || requiredSet[name] { + return + } + requiredSet[name] = true + required = append(required, name) + } + + addArg := func(arg structarg.Argument) { + name := arg.Token() + if name == "" || name == "help" { + return + } + if _, exists := properties[name]; exists { + return + } + prop := map[string]interface{}{ + "description": strings.TrimSpace(arg.HelpString("")), + } + switch { + case !arg.NeedData(): + prop["type"] = "boolean" + case arg.IsMulti(): + prop["type"] = "array" + items := map[string]interface{}{"type": "string"} + if choices := argChoices(arg); len(choices) > 0 { + items["enum"] = choices + } + prop["items"] = items + default: + prop["type"] = "string" + } + if choices := argChoices(arg); len(choices) > 0 && !arg.IsMulti() { + prop["enum"] = choices + } + if arg.IsPositional() { + meta := arg.MetaVar() + if meta != "" { + desc, _ := prop["description"].(string) + if desc != "" { + prop["description"] = fmt.Sprintf("%s (positional: %s)", desc, meta) + } else { + prop["description"] = fmt.Sprintf("positional argument %s", meta) + } + } + } + properties[name] = prop + if arg.IsRequired() || arg.IsPositional() { + addRequired(name) + } + if meta, ok := mcpFields[name]; ok && meta.Required { + addRequired(name) + } + } + + for _, arg := range parser.GetPosArgs() { + addArg(arg) + } + for _, arg := range parser.GetOptArgs() { + if arg.IsRequired() { + addArg(arg) + } + } + + isList := strings.HasSuffix(cmd.Command, "-list") + isCreate := cmd.Command == "server-create" + for _, arg := range parser.GetOptArgs() { + if arg.IsRequired() || arg.Token() == "help" { + continue + } + if len(properties) >= maxSchemaProperties { + break + } + if mcpKeepArg(mcpFields, arg) { + addArg(arg) + } + } + + // 强化 create 关键字段说明,降低调用门槛 + if isCreate { + if prop, ok := properties["disk"].(map[string]interface{}); ok { + prop["description"] = "系统盘描述,数组。公有云示例:[\"size=40g,image=<镜像ID>,backend=cloud_essd\"];backend 必须来自 climc_cloud_region_capability 的 storage_types2;ISO 请用 cdrom 而不是 disk.image" + properties["disk"] = prop + } + if prop, ok := properties["net"].(map[string]interface{}); ok { + prop["description"] = "网络描述,可省略。省略或 [] 时自动 random(等价 API nets:[{exit:false}])由调度器选网;指定时示例:[\"<网络ID>\"]" + properties["net"] = prop + } + if prop, ok := properties["cdrom"].(map[string]interface{}); ok { + prop["description"] = "ISO/光驱镜像 ID;系统盘不要挂 ISO 镜像" + properties["cdrom"] = prop + } + } + + // list 命令确保 scope 出现在 schema 中,并强化 provider 说明 + if isList { + if prop, ok := properties["scope"].(map[string]interface{}); ok { + prop["description"] = "resource scope;省略时 MCP 默认注入 max" + properties["scope"] = prop + } else { + properties["scope"] = map[string]interface{}{ + "type": "string", + "description": "resource scope;省略时 MCP 默认注入 max", + "enum": []string{"system", "domain", "project", "user", "max"}, + } + } + if prop, ok := properties["provider"].(map[string]interface{}); ok { + prop["description"] = "云厂商过滤;用户指定阿里云/AWS 等公有云时必须传,例如 [\"Aliyun\"],不要省略" + properties["provider"] = prop + } + } + + if cmd.Command == "cloud-region-list" { + if prop, ok := properties["usable"].(map[string]interface{}); ok { + prop["description"] = "创建虚拟机时必须为 true,只返回网络可用区域;省略时 MCP 默认注入 true" + properties["usable"] = prop + } else { + properties["usable"] = map[string]interface{}{ + "type": "boolean", + "description": "创建虚拟机时必须为 true,只返回网络可用区域;省略时 MCP 默认注入 true", + } + } + } + + if cmd.Command == "cached-image-list" { + if prop, ok := properties["provider"].(map[string]interface{}); ok { + prop["description"] = "公有云选镜像必传,例如 [\"Aliyun\"];不传会混入其他云(如 AWS)镜像" + properties["provider"] = prop + } + if prop, ok := properties["region"].(map[string]interface{}); ok { + prop["description"] = "必须传 climc_cloud_region_list 返回的 id(UUID)。禁止传 cn-shanghai / Aliyun/cn-shanghai 这类外部 region code" + properties["region"] = prop + } + if prop, ok := properties["image-type"].(map[string]interface{}); ok { + prop["description"] = "镜像类型;创建系统盘优先 system,避免误用 ISO/驱动盘" + properties["image-type"] = prop + } + } + + if cmd.Command == "server-sku-list" { + if prop, ok := properties["spec"].(map[string]interface{}); ok { + prop["description"] = "口语规格,优先使用。例:2c2g、2核2G、4C8G;自动转为 cpu + mem(MB)。用户说「2核2G」就传 spec=\"2c2g\"" + properties["spec"] = prop + } else { + properties["spec"] = map[string]interface{}{ + "type": "string", + "description": "口语规格,优先使用。例:2c2g、2核2G、4C8G;自动转为 cpu + mem(MB)", + } + } + if prop, ok := properties["cpu"].(map[string]interface{}); ok { + prop["description"] = "CPU 核数;与 mem 联用。有口语规格时优先用 spec" + properties["cpu"] = prop + } + if prop, ok := properties["mem"].(map[string]interface{}); ok { + prop["description"] = "内存 MB;2G=2048。有口语规格时优先用 spec" + properties["mem"] = prop + } + if prop, ok := properties["postpaid-status"].(map[string]interface{}); ok { + prop["description"] = "按量付费状态;创建时优先 available,避免选到 soldout" + properties["postpaid-status"] = prop + } + } + + // 认证:请使用连接 Header(X-Auth-Token / AK+SK / X-API-Key),不要在工具参数里传密钥。 + + schema := map[string]interface{}{ + "type": "object", + "properties": properties, + } + if len(required) > 0 { + schema["required"] = required + } + raw, err := json.Marshal(schema) + if err != nil { + return nil, err + } + return raw, nil +} + +func argChoices(arg structarg.Argument) []string { + type chooser interface { + Choices() []string + } + if c, ok := arg.(chooser); ok { + return c.Choices() + } + return nil +} + +func toolNameFromCommand(command string) string { + return "climc_" + strings.ReplaceAll(command, "-", "_") +} + +func buildDescription(cmd shell.CMD) string { + desc := strings.TrimSpace(cmd.Desc) + if desc == "" { + desc = fmt.Sprintf("Execute climc command %s", cmd.Command) + } + desc = fmt.Sprintf("[climc %s] %s", cmd.Command, desc) + if mcp := collectMcpDesc(cmd.Options); mcp != "" { + desc += "。" + mcp + } + return desc +} diff --git a/pkg/mcp-server/climcgen/schema_test.go b/pkg/mcp-server/climcgen/schema_test.go new file mode 100644 index 0000000000..56d007ad43 --- /dev/null +++ b/pkg/mcp-server/climcgen/schema_test.go @@ -0,0 +1,82 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "encoding/json" + "strings" + "testing" + + _ "yunion.io/x/onecloud/cmd/climc/shell/compute" + _ "yunion.io/x/onecloud/cmd/climc/shell/image" + + "yunion.io/x/onecloud/cmd/climc/shell" +) + +func TestBuildInputSchemaUsesMcpTags(t *testing.T) { + var cmd shell.CMD + found := false + for _, c := range shell.CommandTable { + if c.Command == "server-create" { + cmd = c + found = true + break + } + } + if !found { + t.Fatal("server-create not registered") + } + raw, err := buildInputSchema(cmd) + if err != nil { + t.Fatalf("buildInputSchema: %v", err) + } + var schema map[string]interface{} + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatal(err) + } + props := schema["properties"].(map[string]interface{}) + for _, key := range []string{"name", "net", "disk", "ncpu", "mem-spec", "prefer-region", "hypervisor", "cdrom"} { + if _, ok := props[key]; !ok { + t.Errorf("expected mcp-tagged property %q in schema, got keys=%v", key, propKeys(props)) + } + } + // kickstart 等未打 mcp tag 的字段不应出现 + for _, key := range []string{"kickstart-os-type", "user-data-file", "fake-create"} { + if _, ok := props[key]; ok { + t.Errorf("unexpected untagged property %q in schema", key) + } + } + req, _ := schema["required"].([]interface{}) + reqSet := map[string]bool{} + for _, r := range req { + reqSet[r.(string)] = true + } + if !reqSet["name"] || reqSet["net"] { + t.Errorf("expected name required and net optional, got %v", req) + } + + desc := buildDescription(cmd) + if !strings.Contains(desc, "创建虚拟机的最终动作") { + t.Errorf("expected mcp-desc in tool description, got %q", desc) + } +} + +func propKeys(props map[string]interface{}) []string { + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + return keys +} diff --git a/pkg/mcp-server/climcgen/tool.go b/pkg/mcp-server/climcgen/tool.go new file mode 100644 index 0000000000..6d11147917 --- /dev/null +++ b/pkg/mcp-server/climcgen/tool.go @@ -0,0 +1,155 @@ +// Copyright 2019 Yunion +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package climcgen + +import ( + "context" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + + "yunion.io/x/log" + + "yunion.io/x/onecloud/cmd/climc/shell" + "yunion.io/x/onecloud/pkg/mcp-server/adapters" +) + +// Tool MCP 工具接口 +type Tool interface { + GetTool() mcp.Tool + Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) + GetName() string +} + +// ClimcTool 由 climc CommandTable 自动生成的 MCP 工具 +type ClimcTool struct { + cmd shell.CMD + name string + tool mcp.Tool + adapter *adapters.CloudpodsAdapter +} + +func NewClimcTool(cmd shell.CMD, adapter *adapters.CloudpodsAdapter) (*ClimcTool, error) { + schema, err := buildInputSchema(cmd) + if err != nil { + return nil, err + } + name := toolNameFromCommand(cmd.Command) + desc := buildDescription(cmd) + tool := mcp.NewToolWithRawSchema(name, desc, schema) + return &ClimcTool{ + cmd: cmd, + name: name, + tool: tool, + adapter: adapter, + }, nil +} + +func (t *ClimcTool) GetTool() mcp.Tool { + return t.tool +} + +func (t *ClimcTool) GetName() string { + return t.name +} + +func (t *ClimcTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := map[string]interface{}{} + if req.Params.Arguments != nil { + switch a := req.Params.Arguments.(type) { + case map[string]interface{}: + args = a + default: + return nil, fmt.Errorf("unexpected arguments type %T", req.Params.Arguments) + } + } + + ak, _ := args["ak"].(string) + sk, _ := args["sk"].(string) + delete(args, "ak") + delete(args, "sk") + + if !adapters.HasRequestCredentials(ctx) && (ak == "" || sk == "") { + return mcp.NewToolResultError(adapters.ErrAuthenticationRequired.Error()), nil + } + + session, err := t.adapter.GetSession(ctx, ak, sk) + if err != nil { + log.Errorf("climc tool %s get session failed: %s", t.name, err) + return mcp.NewToolResultError(fmt.Sprintf("authentication/session failed: %s", err.Error())), nil + } + + var output string + if t.cmd.Command == "server-create" { + output, err = t.handleServerCreate(ctx, session, args) + } else { + if err := ctx.Err(); err != nil { + return nil, err + } + output, err = invokeCommand(t.cmd, session, args) + } + if err != nil { + log.Errorf("climc tool %s failed: %s", t.name, err) + if output != "" { + return mcp.NewToolResultError(fmt.Sprintf("%s\noutput:\n%s", err.Error(), output)), nil + } + return nil, fmt.Errorf("execute %s: %w", t.cmd.Command, err) + } + if strings.TrimSpace(output) == "" { + output = fmt.Sprintf(`{"command":%q,"success":true}`, t.cmd.Command) + } + if hint := nextStepHint(t.cmd.Command, t.cmd.Options); hint != "" { + output = output + "\n\n" + hint + } + return mcp.NewToolResultText(output), nil +} + +func nextStepHint(command string, options interface{}) string { + switch { + case command == "cloud-region-list": + return `[MCP下一步] 创建虚拟机尚未完成。请用区域 id 调 climc_cloud_region_capability,再 climc_cached_image_list + climc_server_sku_list(可并行),最后 climc_server_create。net 可省略走自动调度,默认不要 network-list。不要在此处结束。` + case command == "cloud-region-capability": + return `[MCP下一步] 记下 storage_types2 中的系统盘类型(如 cloud_essd)。继续 climc_cached_image_list 与 climc_server_sku_list,然后 climc_server_create(disk 带 backend;net 可省略)。` + case command == "cached-image-list": + return `[MCP下一步] 确认已带 provider 及 region=区域id。继续 climc_server_sku_list(若未查),然后立刻 climc_server_create(disk 含 image+backend;net 可省略);禁止用 ISO,不要重复查同一镜像列表。` + case command == "image-list": + return `[MCP下一步] KVM 用本结果;公有云请改用 climc_cached_image_list。继续 sku 后立刻 climc_server_create。` + case command == "network-list" || command == "vpc-list": + return `[MCP下一步] 若用户未指定网络,可省略 net,直接 climc_server_create 走自动调度;若已选中网络 id,create 时传入 net=[""]。` + case command == "server-sku-list" || command == "storage-list": + return `[MCP下一步] 请立刻调用 climc_server_create;disk 须含 backend;net 可省略(自动 random / nets:[{exit:false}])。` + case command == "server-list": + return `[MCP下一步] 若用户要启动/停止/重启/删除/改配/重置密码/挂盘/绑定 EIP,拿到 id 后立刻调用对应操作工具,不要只查询就结束。` + case command == "cloud-account-list": + return `[MCP下一步] 查详情用 climc_cloud_account_show;同步资源用 climc_cloud_account_sync(可 force=true)。` + case command == "cloud-account-sync": + return `[MCP下一步] 同步为异步任务,可用 climc_cloud_account_show 查看 sync_status。` + case command == "eip-list": + return `[MCP下一步] 绑定到虚机用 climc_server_associate_eip(需 server id 与 eip id);详情用 climc_eip_show。` + case command == "disk-list": + return `[MCP下一步] 挂到虚机用 climc_server_attach_disk;扩容 climc_disk_resize;详情 climc_disk_show。` + case command == "host-list": + return `[MCP下一步] 查宿主机详情用 climc_host_show。` + case command == "docs_search": + return `[MCP下一步] 对最相关 path 调用 docs_get 阅读正文后再回答用户。` + case command == "action-show": + return `[MCP下一步] 这是操作审计日志。若要继续改资源,回到对应 climc_* 操作工具。` + case isCreateFlowListCommand(options): + return `[MCP下一步] 这只是创建前的资源查询。任务完成条件是成功调用 climc_server_create。` + default: + return "" + } +} diff --git a/pkg/mcp-server/models/doc.go b/pkg/mcp-server/models/doc.go deleted file mode 100644 index f6fb292fe7..0000000000 --- a/pkg/mcp-server/models/doc.go +++ /dev/null @@ -1 +0,0 @@ -package models // import "yunion.io/x/onecloud/pkg/mcp-server/models" diff --git a/pkg/mcp-server/models/models.go b/pkg/mcp-server/models/models.go deleted file mode 100644 index 98a2604eee..0000000000 --- a/pkg/mcp-server/models/models.go +++ /dev/null @@ -1,774 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package models - -import "time" - -type ListRegionsReq struct { -} - -type CloudregionDetails struct { - CanDelete bool `json:"can_delete"` - CanUpdate bool `json:"can_update"` - City string `json:"city"` - CloudEnv string `json:"cloud_env"` - CountryCode string `json:"country_code"` - CreatedAt *time.Time `json:"created_at"` - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - Environment string `json:"environment"` - ExternalId string `json:"external_id"` - GuestCount int64 `json:"guest_count"` - GuestIncrementCount int64 `json:"guest_increment_count"` - Id string `json:"id"` - ImportedAt *time.Time `json:"imported_at"` - IsEmulated bool `json:"is_emulated"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Metadata map[string]string `json:"metadata"` - Name string `json:"name"` - NetworkCount int64 `json:"network_count"` - Progress float64 `json:"progress"` - Provider string `json:"provider"` - Source string `json:"source"` - Status string `json:"status"` - UpdateVersion int64 `json:"update_version"` - UpdatedAt *time.Time `json:"updated_at"` - VpcCount int64 `json:"vpc_count"` - ZoneCount int64 `json:"zone_count"` -} - -type CloudregionListResponse struct { - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` - Cloudregions []CloudregionDetails `json:"cloudregions"` - Total int64 `json:"total"` -} - -type SharedDomain struct { - Id string `json:"id"` - Name string `json:"name"` -} - -type SharedProject struct { - Domain string `json:"domain"` - DomainId string `json:"domain_id"` - Id string `json:"id"` - Name string `json:"name"` -} - -type VpcDetails struct { - Account string `json:"account"` - AccountHealthStatus string `json:"account_health_status"` - AccountId string `json:"account_id"` - AccountReadOnly bool `json:"account_read_only"` - AccountStatus string `json:"account_status"` - AcceptVpcPeerCount int64 `json:"accpet_vpc_peer_count"` - Brand string `json:"brand"` - CanDelete bool `json:"can_delete"` - CanUpdate bool `json:"can_update"` - CidrBlock string `json:"cidr_block"` - CidrBlock6 string `json:"cidr_block6"` - CloudEnv string `json:"cloud_env"` - Cloudregion string `json:"cloudregion"` - CloudregionId string `json:"cloudregion_id"` - CreatedAt *time.Time `json:"created_at"` - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at"` - Description string `json:"description"` - Direct bool `json:"direct"` - DnsZoneCount int64 `json:"dns_zone_count"` - DomainId string `json:"domain_id"` - DomainSrc string `json:"domain_src"` - Enabled bool `json:"enabled"` - Environment string `json:"environment"` - ExternalAccessMode string `json:"external_access_mode"` - ExternalId string `json:"external_id"` - Globalvpc string `json:"globalvpc"` - GlobalvpcId string `json:"globalvpc_id"` - Id string `json:"id"` - ImportedAt *time.Time `json:"imported_at"` - IsDefault bool `json:"is_default"` - IsEmulated bool `json:"is_emulated"` - IsPublic bool `json:"is_public"` - Manager string `json:"manager"` - ManagerDomain string `json:"manager_domain"` - ManagerDomainId string `json:"manager_domain_id"` - ManagerId string `json:"manager_id"` - ManagerProject string `json:"manager_project"` - ManagerProjectId string `json:"manager_project_id"` - Metadata map[string]string `json:"metadata"` - Name string `json:"name"` - NatgatewayCount int64 `json:"natgateway_count"` - NetworkCount int64 `json:"network_count"` - Progress float64 `json:"progress"` - ProjectDomain string `json:"project_domain"` - Provider string `json:"provider"` - PublicScope string `json:"public_scope"` - PublicSrc string `json:"public_src"` - Region string `json:"region"` - RegionExtId string `json:"region_ext_id"` - RegionExternalId string `json:"region_external_id"` - RegionId string `json:"region_id"` - RequestVpcPeerCount int64 `json:"request_vpc_peer_count"` - RoutetableCount int64 `json:"routetable_count"` - SharedDomains []SharedDomain `json:"shared_domains"` - SharedProjects []SharedProject `json:"shared_projects"` - Source string `json:"source"` - Status string `json:"status"` - UpdateVersion int64 `json:"update_version"` - UpdatedAt *time.Time `json:"updated_at"` - WireCount int64 `json:"wire_count"` -} - -type VpcListResponse struct { - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` - Vpcs []VpcDetails `json:"vpcs"` - Total int64 `json:"total"` -} - -type SchedtagShortDescDetails struct { - Default string `json:"default"` - Id string `json:"id"` - Name string `json:"name"` - ResName string `json:"res_name"` -} - -type SRoute []string - -type SSimpleWire struct { - Wire string `json:"Wire"` - WireId string `json:"WireId"` -} - -type NetworkDetails struct { - Account string `json:"account"` - AccountHealthStatus string `json:"account_health_status"` - AccountId string `json:"account_id"` - AccountReadOnly bool `json:"account_read_only"` - AccountStatus string `json:"account_status"` - AdditionalWires []SSimpleWire `json:"additional_wires"` - AllocPolicy string `json:"alloc_policy"` - AllocTimoutSeconds int64 `json:"alloc_timout_seconds"` - BgpType string `json:"bgp_type"` - BmReusedVnics int64 `json:"bm_reused_vnics"` - BmVnics int64 `json:"bm_vnics"` - Brand string `json:"brand"` - CanDelete bool `json:"can_delete"` - CanUpdate bool `json:"can_update"` - CloudEnv string `json:"cloud_env"` - Cloudregion string `json:"cloudregion"` - CloudregionId string `json:"cloudregion_id"` - CreatedAt *time.Time `json:"created_at"` - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at"` - Description string `json:"description"` - Dns string `json:"dns"` - DomainId string `json:"domain_id"` - EipVnics int64 `json:"eip_vnics"` - Environment string `json:"environment"` - Exit bool `json:"exit"` - ExternalId string `json:"external_id"` - Freezed bool `json:"freezed"` - GroupVnics int64 `json:"group_vnics"` - GuestDhcp string `json:"guest_dhcp"` - GuestDns string `json:"guest_dns"` - GuestDns6 string `json:"guest_dns6"` - GuestDomain string `json:"guest_domain"` - GuestDomain6 string `json:"guest_domain6"` - GuestGateway string `json:"guest_gateway"` - GuestGateway6 string `json:"guest_gateway6"` - GuestIpEnd string `json:"guest_ip_end"` - GuestIpMask uint8 `json:"guest_ip_mask"` - GuestIpStart string `json:"guest_ip_start"` - GuestIp6End string `json:"guest_ip6_end"` - GuestIp6Mask uint8 `json:"guest_ip6_mask"` - GuestIp6Start string `json:"guest_ip6_start"` - GuestNtp string `json:"guest_ntp"` - Id string `json:"id"` - IfnameHint string `json:"ifname_hint"` - ImportedAt *time.Time `json:"imported_at"` - IsAutoAlloc bool `json:"is_auto_alloc"` - IsClassic bool `json:"is_classic"` - IsDefaultVpc bool `json:"is_default_vpc"` - IsEmulated bool `json:"is_emulated"` - IsPublic bool `json:"is_public"` - IsSystem bool `json:"is_system"` - LbVnics int64 `json:"lb_vnics"` - Manager string `json:"manager"` - ManagerDomain string `json:"manager_domain"` - ManagerDomainId string `json:"manager_domain_id"` - ManagerId string `json:"manager_id"` - ManagerProject string `json:"manager_project"` - ManagerProjectId string `json:"manager_project_id"` - Metadata map[string]string `json:"metadata"` - Name string `json:"name"` - NatVnics int64 `json:"nat_vnics"` - NetworkinterfaceVnics int64 `json:"networkinterface_vnics"` - PendingDeleted bool `json:"pending_deleted"` - PendingDeletedAt *time.Time `json:"pending_deleted_at"` - Ports int64 `json:"ports"` - PortsUsed int64 `json:"ports_used"` - Ports6Used int64 `json:"ports6_used"` - Progress float64 `json:"progress"` - Project string `json:"project"` - ProjectDomain string `json:"project_domain"` - ProjectId string `json:"project_id"` - ProjectMetadata map[string]string `json:"project_metadata"` - ProjectSrc string `json:"project_src"` - Provider string `json:"provider"` - PublicScope string `json:"public_scope"` - PublicSrc string `json:"public_src"` - RdsVnics int64 `json:"rds_vnics"` - Region string `json:"region"` - RegionExtId string `json:"region_ext_id"` - RegionExternalId string `json:"region_external_id"` - RegionId string `json:"region_id"` - ReserveVnics4 int64 `json:"reserve_vnics4"` - ReserveVnics6 int64 `json:"reserve_vnics6"` - Routes []SRoute `json:"routes"` - Schedtags []SchedtagShortDescDetails `json:"schedtags"` - ServerType string `json:"server_type"` - SharedDomains []SharedDomain `json:"shared_domains"` - SharedProjects []SharedProject `json:"shared_projects"` - Source string `json:"source"` - Status string `json:"status"` - Tenant string `json:"tenant"` - TenantId string `json:"tenant_id"` - Total int64 `json:"total"` - Total6 int64 `json:"total6"` - UpdateVersion int64 `json:"update_version"` - UpdatedAt *time.Time `json:"updated_at"` - VlanId int64 `json:"vlan_id"` - Vnics int64 `json:"vnics"` - Vnics4 int64 `json:"vnics4"` - Vnics6 int64 `json:"vnics6"` - Vpc string `json:"vpc"` - VpcExtId string `json:"vpc_ext_id"` - VpcId string `json:"vpc_id"` - Wire string `json:"wire"` - WireId string `json:"wire_id"` - Zone string `json:"zone"` - ZoneId string `json:"zone_id"` -} - -type NetworkListResponse struct { - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` - Networks []NetworkDetails `json:"networks"` - Total int64 `json:"total"` -} - -type ImageDetails struct { - AutoDeleteAt *time.Time `json:"auto_delete_at"` - CanDelete bool `json:"can_delete"` - CanUpdate bool `json:"can_update"` - Checksum string `json:"checksum"` - CreatedAt *time.Time `json:"created_at"` - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at"` - Description string `json:"description"` - DisableDelete bool `json:"disable_delete"` - DiskFormat string `json:"disk_format"` - DomainId string `json:"domain_id"` - EncryptAlg string `json:"encrypt_alg"` - EncryptKey string `json:"encrypt_key"` - EncryptKeyId string `json:"encrypt_key_id"` - EncryptKeyUser string `json:"encrypt_key_user"` - EncryptKeyUserDomain string `json:"encrypt_key_user_domain"` - EncryptKeyUserDomainId string `json:"encrypt_key_user_domain_id"` - EncryptKeyUserId string `json:"encrypt_key_user_id"` - EncryptStatus string `json:"encrypt_status"` - FastHash string `json:"fast_hash"` - Freezed bool `json:"freezed"` - Id string `json:"id"` - IsData bool `json:"is_data"` - IsEmulated bool `json:"is_emulated"` - IsGuestImage bool `json:"is_guest_image"` - IsPublic bool `json:"is_public"` - IsStandard bool `json:"is_standard"` - IsSystem bool `json:"is_system"` - Location string `json:"location"` - Metadata map[string]string `json:"metadata"` - MinDisk int32 `json:"min_disk"` - MinRam int32 `json:"min_ram"` - Name string `json:"name"` - OsArch string `json:"os_arch"` - OssChecksum string `json:"oss_checksum"` - Owner string `json:"owner"` - PendingDeleted bool `json:"pending_deleted"` - PendingDeletedAt *time.Time `json:"pending_deleted_at"` - Progress float64 `json:"progress"` - Project string `json:"project"` - ProjectDomain string `json:"project_domain"` - ProjectId string `json:"project_id"` - ProjectMetadata map[string]string `json:"project_metadata"` - ProjectSrc string `json:"project_src"` - Properties map[string]string `json:"properties"` - Protected bool `json:"protected"` - PublicScope string `json:"public_scope"` - PublicSrc string `json:"public_src"` - SharedDomains []SharedDomain `json:"shared_domains"` - SharedProjects []SharedProject `json:"shared_projects"` - Size int64 `json:"size"` - Status string `json:"status"` - Tenant string `json:"tenant"` - TenantId string `json:"tenant_id"` - UpdateVersion int64 `json:"update_version"` - UpdatedAt *time.Time `json:"updated_at"` -} - -type ImageListResponse struct { - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` - Images []ImageDetails `json:"images"` - Total int64 `json:"total"` -} - -type ServerSkuDetails struct { - AttachedDiskCount int64 `json:"attached_disk_count"` - AttachedDiskSizeGB int64 `json:"attached_disk_size_gb"` - AttachedDiskType string `json:"attached_disk_type"` - CanDelete bool `json:"can_delete"` - CanUpdate bool `json:"can_update"` - CloudEnv string `json:"cloud_env"` - Cloudregion string `json:"cloudregion"` - CloudregionId string `json:"cloudregion_id"` - CpuArch string `json:"cpu_arch"` - CpuCoreCount int64 `json:"cpu_core_count"` - CreatedAt *time.Time `json:"created_at"` - DataDiskMaxCount int64 `json:"data_disk_max_count"` - DataDiskTypes string `json:"data_disk_types"` - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - ExternalId string `json:"external_id"` - GpuAttachable bool `json:"gpu_attachable"` - GpuCount string `json:"gpu_count"` - GpuMaxCount int64 `json:"gpu_max_count"` - GpuSpec string `json:"gpu_spec"` - Id string `json:"id"` - ImportedAt *time.Time `json:"imported_at"` - InstanceTypeCategory string `json:"instance_type_category"` - InstanceTypeFamily string `json:"instance_type_family"` - IsEmulated bool `json:"is_emulated"` - LocalCategory string `json:"local_category"` - Md5 string `json:"md5"` - MemorySizeMB int64 `json:"memory_size_mb"` - Metadata map[string]string `json:"metadata"` - Name string `json:"name"` - NicMaxCount int64 `json:"nic_max_count"` - NicType string `json:"nic_type"` - OsName string `json:"os_name"` - PostpaidStatus string `json:"postpaid_status"` - PrepaidStatus string `json:"prepaid_status"` - Progress float64 `json:"progress"` - Provider string `json:"provider"` - Region string `json:"region"` - RegionExtId string `json:"region_ext_id"` - RegionExternalId string `json:"region_external_id"` - RegionId string `json:"region_id"` - Source string `json:"source"` - Status string `json:"status"` - SysDiskMaxSizeGB int64 `json:"sys_disk_max_size_gb"` - SysDiskMinSizeGB int64 `json:"sys_disk_min_size_gb"` - SysDiskResizable bool `json:"sys_disk_resizable"` - SysDiskType string `json:"sys_disk_type"` - TotalGuestCount int64 `json:"total_guest_count"` - UpdateVersion int64 `json:"update_version"` - UpdatedAt *time.Time `json:"updated_at"` - Zone string `json:"zone"` - ZoneExtId string `json:"zone_ext_id"` - ZoneId string `json:"zone_id"` -} - -type ServerSkuListResponse struct { - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` - Serverskus []ServerSkuDetails `json:"serverskus"` - Total int64 `json:"total"` -} - -type StorageHost struct { - HostStatus string `json:"HostStatus"` - Id string `json:"Id"` - Name string `json:"Name"` - Status string `json:"Status"` -} - -type StorageDetails struct { - DiskCount int64 `json:"DiskCount"` - HostCount int64 `json:"HostCount"` - SnapshotCount int64 `json:"SnapshotCount"` - Used int64 `json:"Used"` - Wasted int64 `json:"Wasted"` - Account string `json:"account"` - AccountHealthStatus string `json:"account_health_status"` - AccountId string `json:"account_id"` - AccountReadOnly bool `json:"account_read_only"` - AccountStatus string `json:"account_status"` - ActualCapacityUsed int64 `json:"actual_capacity_used"` - Brand string `json:"brand"` - CanDelete bool `json:"can_delete"` - CanUpdate bool `json:"can_update"` - Capacity int64 `json:"capacity"` - CloudEnv string `json:"cloud_env"` - Cloudregion string `json:"cloudregion"` - CloudregionId string `json:"cloudregion_id"` - Cmtbound float64 `json:"cmtbound"` - CommitBound float64 `json:"commit_bound"` - CommitRate float64 `json:"commit_rate"` - CreatedAt *time.Time `json:"created_at"` - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at"` - Description string `json:"description"` - DomainId string `json:"domain_id"` - DomainSrc string `json:"domain_src"` - Enabled bool `json:"enabled"` - Environment string `json:"environment"` - ExternalId string `json:"external_id"` - FreeCapacity int64 `json:"free_capacity"` - Hosts []StorageHost `json:"hosts"` - Id string `json:"id"` - ImportedAt *time.Time `json:"imported_at"` - IsEmulated bool `json:"is_emulated"` - IsPublic bool `json:"is_public"` - IsSysDiskStore bool `json:"is_sys_disk_store"` - Manager string `json:"manager"` - ManagerDomain string `json:"manager_domain"` - ManagerDomainId string `json:"manager_domain_id"` - ManagerId string `json:"manager_id"` - ManagerProject string `json:"manager_project"` - ManagerProjectId string `json:"manager_project_id"` - MasterHost string `json:"master_host"` - MasterHostName string `json:"master_host_name"` - MediumType string `json:"medium_type"` - Metadata map[string]string `json:"metadata"` - Name string `json:"name"` - Progress float64 `json:"progress"` - ProjectDomain string `json:"project_domain"` - Provider string `json:"provider"` - PublicScope string `json:"public_scope"` - PublicSrc string `json:"public_src"` - RealTimeUsedCapacity int64 `json:"real_time_used_capacity"` - Region string `json:"region"` - RegionExtId string `json:"region_ext_id"` - RegionExternalId string `json:"region_external_id"` - RegionId string `json:"region_id"` - Reserved int64 `json:"reserved"` - Schedtags []SchedtagShortDescDetails `json:"schedtags"` - SharedDomains []SharedDomain `json:"shared_domains"` - SharedProjects []SharedProject `json:"shared_projects"` - Source string `json:"source"` - Status string `json:"status"` - StorageConf map[string]interface{} `json:"storage_conf"` - StorageType string `json:"storage_type"` - StoragecacheId string `json:"storagecache_id"` - UpdateVersion int64 `json:"update_version"` - UpdatedAt *time.Time `json:"updated_at"` - UsedCapacity int64 `json:"used_capacity"` - VirtualCapacity int64 `json:"virtual_capacity"` - WasteCapacity int64 `json:"waste_capacity"` - Zone string `json:"zone"` - ZoneExtId string `json:"zone_ext_id"` - ZoneId string `json:"zone_id"` -} - -type StorageListResponse struct { - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` - Storages []StorageDetails `json:"storages"` - Total int64 `json:"total"` -} - -type ServerDetails struct { - Account string `json:"account"` - AccountHealthStatus string `json:"account_health_status"` - AccountId string `json:"account_id"` - AccountReadOnly bool `json:"account_read_only"` - AccountStatus string `json:"account_status"` - BackupGuestSync string `json:"backup_guest_sync"` - BackupGuestSyncStatus string `json:"backup_guest_sync_status"` - BackupHostId string `json:"backup_host_id"` - BackupHostName string `json:"backup_host_name"` - BackupHostStatus string `json:"backup_host_status"` - BillingCycle string `json:"billing_cycle"` - BillingType string `json:"billing_type"` - Bios string `json:"bios"` - BootOrder string `json:"boot_order"` - Brand string `json:"brand"` - CanDelete bool `json:"can_delete"` - CanRecycle bool `json:"can_recycle"` - CanUpdate bool `json:"can_update"` - Cdrom interface{} `json:"cdrom"` - CdromSupport bool `json:"cdrom_support"` - CloudEnv string `json:"cloud_env"` - Cloudregion string `json:"cloudregion"` - CloudregionId string `json:"cloudregion_id"` - Containers interface{} `json:"containers"` - CpuNumaPin map[string]interface{} `json:"cpu_numa_pin"` - CpuSockets int64 `json:"cpu_sockets"` - CreatedAt *time.Time `json:"created_at"` - DeleteFailReason interface{} `json:"delete_fail_reason"` - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at"` - Description string `json:"description"` - DisableDelete bool `json:"disable_delete"` - DiskSizeMb int64 `json:"disk"` - DiskCount int64 `json:"disk_count"` - Disks string `json:"disks"` - DisksInfo interface{} `json:"disks_info"` - DomainId string `json:"domain_id"` - Eip string `json:"eip"` - EipMode string `json:"eip_mode"` - EncryptAlg string `json:"encrypt_alg"` - EncryptKey string `json:"encrypt_key"` - EncryptKeyId string `json:"encrypt_key_id"` - EncryptKeyUser string `json:"encrypt_key_user"` - EncryptKeyUserDomain string `json:"encrypt_key_user_domain"` - EncryptKeyUserDomainId string `json:"encrypt_key_user_domain_id"` - EncryptKeyUserId string `json:"encrypt_key_user_id"` - Environment string `json:"environment"` - ExpiredAt *time.Time `json:"expired_at"` - ExternalId string `json:"external_id"` - ExtraCpuCount int64 `json:"extra_cpu_count"` - FlavorId string `json:"flavor_id"` - Floppy interface{} `json:"floppy"` - FloppySupport bool `json:"floppy_support"` - Freezed bool `json:"freezed"` - GpuCount string `json:"gpu_count"` - GpuModel string `json:"gpu_model"` - Host string `json:"host"` - HostAccessIp string `json:"host_access_ip"` - HostAccessMac string `json:"host_access_mac"` - HostBillingType string `json:"host_billing_type"` - HostEIP string `json:"host_eip"` - HostEnabled bool `json:"host_enabled"` - HostId string `json:"host_id"` - HostStatus string `json:"host_status"` - Hostname string `json:"hostname"` - Hypervisor string `json:"hypervisor"` - Id string `json:"id"` - ImportedAt *time.Time `json:"imported_at"` - Ips []string `json:"ips"` - IsBaremetal bool `json:"is_baremetal"` - IsDefer bool `json:"is_defer"` - IsEmulated bool `json:"is_emulated"` - IsMerge bool `json:"is_merge"` - IsMirror bool `json:"is_mirror"` - IsPublic bool `json:"is_public"` - IsSystem bool `json:"is_system"` - KeypairId string `json:"keypair_id"` - Manager string `json:"manager"` - ManagerDomain string `json:"manager_domain"` - ManagerDomainId string `json:"manager_domain_id"` - ManagerId string `json:"manager_id"` - ManagerProject string `json:"manager_project"` - ManagerProjectId string `json:"manager_project_id"` - MemoryPinned bool `json:"memory_pinned"` - Metadata map[string]string `json:"metadata"` - Mmemc interface{} `json:"mmemc"` - Name string `json:"name"` - NicType string `json:"nic_type"` - Nics interface{} `json:"nics"` - NSPSConfig map[string]interface{} `json:"nsps_config"` - OsArch string `json:"os_arch"` - OsFullName string `json:"os_full_name"` - OsName string `json:"os_name"` - OsType string `json:"os_type"` - PendingDeleted bool `json:"pending_deleted"` - PendingDeletedAt *time.Time `json:"pending_deleted_at"` - PowerStates string `json:"power_states"` - Progress float64 `json:"progress"` - Project string `json:"project"` - ProjectDomain string `json:"project_domain"` - ProjectId string `json:"project_id"` - ProjectMetadata map[string]string `json:"project_metadata"` - ProjectSrc string `json:"project_src"` - Provider string `json:"provider"` - PublicIp string `json:"public_ip"` - PublicScope string `json:"public_scope"` - PublicSrc string `json:"public_src"` - Rds bool `json:"rds"` - RecoveryMode string `json:"recovery_mode"` - ReorderMaster bool `json:"reorder_master"` - Schedtags []SchedtagShortDescDetails `json:"schedtags"` - SecurityGroup string `json:"security_group"` - SecurityGroupId string `json:"security_group_id"` - SecurityGroups interface{} `json:"security_groups"` - SharedDomains []SharedDomain `json:"shared_domains"` - SharedProjects []SharedProject `json:"shared_projects"` - ShutdownBehavior string `json:"shutdown_behavior"` - SourceOsDist string `json:"source_os_dist"` - Source string `json:"source"` - Status string `json:"status"` - StorageId string `json:"storage_id"` - StorageType string `json:"storage_type"` - SystemVmtypeName string `json:"system_vmtype_name"` - Tenant string `json:"tenant"` - TenantId string `json:"tenant_id"` - UpdateVersion int64 `json:"update_version"` - UpdatedAt *time.Time `json:"updated_at"` - UpgradeStatus string `json:"upgrade_status"` - UpdateFailReason interface{} `json:"update_fail_reason"` - UserData string `json:"user_data"` - VcpuCount int64 `json:"vcpu_count"` - VdiBrokerStuff map[string]interface{} `json:"vdi_broker_stuff"` - VdiConfig map[string]interface{} `json:"vdi_config"` - VditConfig map[string]interface{} `json:"vdit_config"` - VmemSize int64 `json:"vmem_size"` - VMEMSizeMb int64 `json:"vmem_size_mb"` - Vpc string `json:"vpc"` - VpcId string `json:"vpc_id"` - Zone string `json:"zone"` - ZoneId string `json:"zone_id"` -} - -type ServerListResponse struct { - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` - Servers []ServerDetails `json:"servers"` - Total int64 `json:"total"` -} - -type ServerStartRequest struct { - AutoPrepaid bool - QemuVersion string -} - -type ServerStopRequest struct { - IsForce bool - StopCharging bool - TimeoutSecs int64 -} - -type ServerRestartRequest struct { - IsForce bool -} - -type ServerOperationResponse struct { - Id string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - TaskId string `json:"task_id"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` - Operation string `json:"operation,omitempty"` -} - -type ServerResetPasswordRequest struct { - Password string - ResetPassword bool - AutoStart bool - Username string -} - -type ServerDeleteRequest struct { - OverridePendingDelete bool - Purge bool - DeleteSnapshots bool - DeleteEip bool - DeleteDisks bool -} - -type CreateServerRequest struct { - Name string - VcpuCount int64 - VmemSize int64 - ImageId string - DiskSize int64 - NetworkId string - ServerskuId string - Count int - Password string - AutoStart bool - BillingType string - Duration string - Description string - Hostname string - Hypervisor string - Metadata map[string]string - SecgroupId string - Secgroups []string - UserData string - KeypairId string - ProjectId string - ZoneId string - RegionId string - DisableDelete bool - BootOrder string - DataDisks []DiskConfig -} - -type DiskConfig struct { - ImageId string - Size int64 - DiskType string -} - -type ServerCreateResponseData struct { - Servers []ServerCreateInfo `json:"servers"` -} - -type ServerCreateInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - TaskID string `json:"task_id"` -} - -type CreateServerResponse struct { - Status int `json:"status"` - Message string `json:"msg"` - Data ServerCreateResponseData `json:"data"` -} - -type MonitorResponse struct { - Status int `json:"status"` - Data MonitorResponseData `json:"data"` -} - -type MonitorResponseData struct { - Metrics []MetricData `json:"metrics"` -} - -type MetricData struct { - Metric string `json:"metric"` - Unit string `json:"unit"` - Values []MetricValue `json:"values"` -} - -type MetricValue struct { - Timestamp int64 `json:"timestamp"` - Value float64 `json:"value"` -} - -type ServerStatsResponse struct { - Status int `json:"status"` - Data ServerStatsData `json:"data"` -} - -type ServerStatsData struct { - CPUUsage float64 `json:"cpu_usage"` - MemUsage float64 `json:"mem_usage"` - DiskUsage float64 `json:"disk_usage"` - NetBpsRx int64 `json:"net_bps_rx"` - NetBpsTx int64 `json:"net_bps_tx"` - UpdatedAt string `json:"updated_at"` -} diff --git a/pkg/mcp-server/options/options.go b/pkg/mcp-server/options/options.go index 84e37e9862..6bcacece63 100644 --- a/pkg/mcp-server/options/options.go +++ b/pkg/mcp-server/options/options.go @@ -15,9 +15,14 @@ package options import ( + "strings" + "time" + common_options "yunion.io/x/onecloud/pkg/cloudcommon/options" ) +const DefaultPlatformName = "Cloudpods" + type MCPServerOptions struct { common_options.CommonOptions // 服务基础信息 @@ -26,9 +31,45 @@ type MCPServerOptions struct { MCPServerDescription string `help:"MCP service description"` // 连接超时配置 - Timeout int `help:"SDK connection timeout to cloudpods service (seconds)" default:"30"` + Timeout int `help:"SDK connection timeout to platform API (seconds)" default:"30"` + // ServerCreateWaitSeconds 创建后等待 running/ready 的最长时间;超时仍返回 server_id,便于 agent 用 server-show 继续轮询(宜小于 LLM MCPAgentTimeout) + ServerCreateWaitSeconds int `help:"max seconds to wait for server running/ready after create; on timeout still return server_id" default:"90"` } var ( Options MCPServerOptions ) + +// ResolvedPlatformName 返回配置中的平台展示名,空则回退 DefaultPlatformName。 +func ResolvedPlatformName() string { + name := strings.TrimSpace(Options.PlatformName) + if name == "" { + return DefaultPlatformName + } + return name +} + +// ServerCreateWaitDuration 创建等待时长。 +func ServerCreateWaitDuration() time.Duration { + sec := Options.ServerCreateWaitSeconds + if sec <= 0 { + sec = 90 + } + return time.Duration(sec) * time.Second +} + +func OnOptionsChange(oldO, newO interface{}) bool { + oldOpts := oldO.(*MCPServerOptions) + newOpts := newO.(*MCPServerOptions) + + changed := false + if common_options.OnCommonOptionsChange(&oldOpts.CommonOptions, &newOpts.CommonOptions) { + changed = true + } + // PlatformName 写入 ServerInstructions 需重启进程 + if oldOpts.PlatformName != newOpts.PlatformName { + changed = true + } + // ServerCreateWaitSeconds / Timeout 热更新即可(OptionManager 会拷贝到 Options) + return changed +} diff --git a/pkg/mcp-server/server/server.go b/pkg/mcp-server/server/server.go index 9dfe5c38e2..0d8a8dcffa 100644 --- a/pkg/mcp-server/server/server.go +++ b/pkg/mcp-server/server/server.go @@ -17,9 +17,14 @@ package server import ( "context" "encoding/base64" + "errors" "fmt" "net/http" + "os" + "os/signal" "strings" + "syscall" + "time" "github.com/mark3labs/mcp-go/server" @@ -30,77 +35,63 @@ import ( "yunion.io/x/onecloud/pkg/appsrv" "yunion.io/x/onecloud/pkg/mcclient/auth" "yunion.io/x/onecloud/pkg/mcp-server/adapters" + "yunion.io/x/onecloud/pkg/mcp-server/climcgen" "yunion.io/x/onecloud/pkg/mcp-server/options" "yunion.io/x/onecloud/pkg/mcp-server/registry" - "yunion.io/x/onecloud/pkg/mcp-server/tools" ) // CloudpodsMCPServer 是 MCP 服务器的核心结构体,包含配置、日志、MCP 实例、注册中心和工具列表 type CloudpodsMCPServer struct { mcpServer *server.MCPServer registry *registry.Registry - tools []tools.Tool + tools []climcgen.Tool } -// NewServer 创建一个新的 Cloudpods MCP 服务器实例,初始化 MCP 服务器和注册中心,并创建所有工具 -func NewServer() *CloudpodsMCPServer { +// NewServerOptions 创建 MCP 服务器时可覆盖的选项。 +type NewServerOptions struct { + // Instructions MCP 全局说明;空则使用 climcgen.BuildServerInstructions(PlatformName)(再叠加 RegisterExtraInstructions) + Instructions string +} - // 创建mcp server对象 +// NewServer 创建一个新的 Cloudpods MCP 服务器实例。 +// 工具从 climc shell.CommandTable + Options struct tag(mcp-desc)自动生成; +// 可通过 climcgen.RegisterExtraTools 追加工具。 +func NewServer() *CloudpodsMCPServer { + return NewServerWithOptions(nil) +} + +// NewServerWithOptions 同 NewServer,允许覆盖 Instructions 等。 +func NewServerWithOptions(opt *NewServerOptions) *CloudpodsMCPServer { + instructions := climcgen.BuildServerInstructions(options.ResolvedPlatformName()) + if opt != nil && strings.TrimSpace(opt.Instructions) != "" { + instructions = opt.Instructions + } + if extra := climcgen.BuildExtraInstructions(); extra != "" { + instructions = strings.TrimSpace(instructions) + "\n\n" + extra + } + + serverName := strings.TrimSpace(options.Options.MCPServerName) + if serverName == "" { + serverName = options.ResolvedPlatformName() + } mcpServer := server.NewMCPServer( - options.Options.MCPServerName, + serverName, options.Options.MCPServerVersion, server.WithToolCapabilities(false), server.WithRecovery(), + server.WithInstructions(instructions), ) - // 创建注册中心对象 reg := registry.NewRegistry() - - var allTools []tools.Tool - - // 创建mcclient sdk的适配器对象 adapter := adapters.NewCloudpodsAdapter() - // 创建具体的工具函数对象 - // 用于查询资源的工具函数 - regionsTool := tools.NewCloudpodsRegionsTool(adapter) - vpcsTool := tools.NewCloudpodsVPCsTool(adapter) - networksTool := tools.NewCloudpodsNetworksTool(adapter) - imagesTool := tools.NewCloudpodsImagesTool(adapter) - skusTool := tools.NewCloudpodsServerSkusTool(adapter) - storagesTool := tools.NewCloudpodsStoragesTool(adapter) - serversTool := tools.NewCloudpodsServersTool(adapter) - - // 用于操作资源的工具函数 - serverStartTool := tools.NewCloudpodsServerStartTool(adapter) - serverStopTool := tools.NewCloudpodsServerStopTool(adapter) - serverRestartTool := tools.NewCloudpodsServerRestartTool(adapter) - serverResetPasswordTool := tools.NewCloudpodsServerResetPasswordTool(adapter) - serverDeleteTool := tools.NewCloudpodsServerDeleteTool(adapter) - serverCreateTool := tools.NewCloudpodsServerCreateTool(adapter) - serverMonitorTool := tools.NewCloudpodsServerMonitorTool(adapter) - serverStatsTool := tools.NewCloudpodsServerStatsTool(adapter) - - // 将所有的工具函数存储到一个切片中 - allTools = append( - allTools, - regionsTool, - vpcsTool, - networksTool, - imagesTool, - skusTool, - storagesTool, - serversTool, - - serverStartTool, - serverStopTool, - serverRestartTool, - serverResetPasswordTool, - serverDeleteTool, - serverCreateTool, - serverMonitorTool, - serverStatsTool, - ) + allTools, err := climcgen.BuildTools(adapter) + if err != nil { + log.Fatalf("build climc MCP tools failed: %s", err) + } + if extra := climcgen.BuildExtraTools(); len(extra) > 0 { + allTools = append(extra, allTools...) + } return &CloudpodsMCPServer{ mcpServer: mcpServer, @@ -111,24 +102,17 @@ func NewServer() *CloudpodsMCPServer { // Initialize 初始化注册中心和所有工具 func (s *CloudpodsMCPServer) Initialize() error { - - // 初始化工具注册中心 if err := s.registry.Initialize(s.mcpServer); err != nil { return fmt.Errorf("初始化工具注册中心失败: %w", err) } - - // 注册内置工具 if err := s.registerAllTools(); err != nil { return fmt.Errorf("注册内置工具失败: %w", err) } - return nil } -// registerAllTools 将所有工具注册到注册中心 func (s *CloudpodsMCPServer) registerAllTools() error { for _, tool := range s.tools { - // 注册距离查询工具 if err := s.registry.RegisterTool( tool.GetName(), tool.GetTool(), @@ -137,65 +121,62 @@ func (s *CloudpodsMCPServer) registerAllTools() error { return fmt.Errorf("注册工具失败: %w", err) } } - - log.Infof("All tools register completed") + log.Infof("All tools register completed, count=%d", len(s.tools)) return nil } +// authenticateRequest 从 Header 注入会话凭据(可选);无凭据时 ok=false,仍返回原 ctx。 +// /sse 与 tools/list 允许匿名;tools/call 在工具 Handler 内强制鉴权。 +func authenticateRequest(ctx context.Context, r *http.Request) (context.Context, bool) { + tokenStr := r.Header.Get(api.AUTH_TOKEN_HEADER) + akStr := r.Header.Get("AK") + skStr := r.Header.Get("SK") + apiKey := r.Header.Get("X-API-Key") + + if tokenStr != "" { + if auth.IsAuthed() { + userCred, err := auth.Verify(ctx, tokenStr) + if err != nil { + log.Errorf("Verify token failed: %s", err) + } else { + ctx = context.WithValue(ctx, appctx.APP_CONTEXT_KEY_AUTH_TOKEN, userCred) + return ctx, true + } + } + } + + if akStr != "" && skStr != "" { + ctx = context.WithValue(ctx, adapters.ContextKeyAK, akStr) + ctx = context.WithValue(ctx, adapters.ContextKeySK, skStr) + return ctx, true + } + + if apiKey != "" { + if auth.IsAuthed() { + if userCred, err := auth.Verify(ctx, apiKey); err == nil { + ctx = context.WithValue(ctx, appctx.APP_CONTEXT_KEY_AUTH_TOKEN, userCred) + return ctx, true + } + } + decoded, err := base64.StdEncoding.DecodeString(apiKey) + if err == nil { + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + ctx = context.WithValue(ctx, adapters.ContextKeyAK, parts[0]) + ctx = context.WithValue(ctx, adapters.ContextKeySK, parts[1]) + return ctx, true + } + } + } + + return ctx, false +} + // Start 以sse模式启动 mcp 服务 func (s *CloudpodsMCPServer) Start() error { - // 设置 contextFunc 来从 HTTP header 中提取认证信息并放入 context - // 支持:X-Auth-Token(token)、AK/SK(Cursor 双 header)、X-API-Key(Claude 单 header:token 或 base64(ak:sk)) contextFunc := func(ctx context.Context, r *http.Request) context.Context { - tokenStr := r.Header.Get(api.AUTH_TOKEN_HEADER) - akStr := r.Header.Get("AK") - skStr := r.Header.Get("SK") - apiKey := r.Header.Get("X-API-Key") - - // 1) 优先使用 X-Auth-Token - if tokenStr != "" { - if auth.IsAuthed() { - userCred, err := auth.Verify(ctx, tokenStr) - if err != nil { - log.Errorf("Verify token failed: %s", err) - } else { - ctx = context.WithValue(ctx, appctx.APP_CONTEXT_KEY_AUTH_TOKEN, userCred) - log.Debugf("UserCred set in context from HTTP header token") - return ctx - } - } - } - - // 2) Cursor 方式:直接使用 AK、SK 两个 Header - if akStr != "" && skStr != "" { - ctx = context.WithValue(ctx, adapters.ContextKeyAK, akStr) - ctx = context.WithValue(ctx, adapters.ContextKeySK, skStr) - log.Debugf("AK/SK set in context from headers") - return ctx - } - - // 3) Claude 方式:X-API-Key 可为 token,或 base64(ak:sk) - if apiKey != "" { - if auth.IsAuthed() { - if userCred, err := auth.Verify(ctx, apiKey); err == nil { - ctx = context.WithValue(ctx, appctx.APP_CONTEXT_KEY_AUTH_TOKEN, userCred) - log.Debugf("UserCred set in context from X-API-Key token") - return ctx - } - } - decoded, err := base64.StdEncoding.DecodeString(apiKey) - if err == nil { - parts := strings.SplitN(string(decoded), ":", 2) - if len(parts) == 2 && parts[0] != "" && parts[1] != "" { - ctx = context.WithValue(ctx, adapters.ContextKeyAK, parts[0]) - ctx = context.WithValue(ctx, adapters.ContextKeySK, parts[1]) - log.Debugf("AK/SK set in context from X-API-Key base64(ak:sk)") - return ctx - } - } - } - - return ctx + ctx2, _ := authenticateRequest(ctx, r) + return ctx2 } mux := http.NewServeMux() @@ -223,17 +204,37 @@ func (s *CloudpodsMCPServer) Start() error { mux.Handle(sseServer.CompleteSsePath(), sseServer.SSEHandler()) mux.Handle(sseServer.CompleteMessagePath(), sseServer.MessageHandler()) - if err := sseServer.Start(fmt.Sprintf("%s:%d", options.Options.Address, options.Options.Port)); err != nil { - return err - } - log.Infof("Start mcp server successfully") + addr := fmt.Sprintf("%s:%d", options.Options.Address, options.Options.Port) + errCh := make(chan error, 1) + go func() { + errCh <- sseServer.Start(addr) + }() - return nil + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + log.Infof("Start mcp server successfully on %s", addr) + select { + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + case sig := <-sigCh: + log.Infof("Received signal %v, shutting down mcp server...", sig) + signal.Stop(sigCh) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := sseServer.Shutdown(ctx); err != nil { + return fmt.Errorf("graceful shutdown failed: %w", err) + } + log.Infof("Mcp server stopped") + return nil + } } -// StartStdio 以stdio模式启动 mcp 服务 +// StartStdio 以stdio模式启动 mcp 服务 func (s *CloudpodsMCPServer) StartStdio() error { - err := server.ServeStdio(s.mcpServer) if err != nil { return err diff --git a/pkg/mcp-server/service/service.go b/pkg/mcp-server/service/service.go index 717396b44f..06eac14cb6 100644 --- a/pkg/mcp-server/service/service.go +++ b/pkg/mcp-server/service/service.go @@ -16,42 +16,74 @@ package service import ( "os" + "strings" "yunion.io/x/log" app_common "yunion.io/x/onecloud/pkg/cloudcommon/app" common_options "yunion.io/x/onecloud/pkg/cloudcommon/options" + "yunion.io/x/onecloud/pkg/mcp-server/climcgen" "yunion.io/x/onecloud/pkg/mcp-server/options" "yunion.io/x/onecloud/pkg/mcp-server/server" ) +const ( + serviceType = "mcpserver" + serviceVersion = "" +) + +// StartOptions 启动时可覆盖的选项。 +type StartOptions struct { + // Instructions 若非空则完全替换默认 ServerInstructions(已按 PlatformName 生成) + Instructions string + // ExtraInstructions 追加到默认(或 Instructions)说明之后 + ExtraInstructions string +} + func StartService() { + StartServiceWithOptions(nil) +} +func StartServiceWithOptions(startOpt *StartOptions) { opts := &options.Options - common_options.ParseOptions(opts, os.Args, "mcpserver.conf", "mcpserver") + common_options.ParseOptions(opts, os.Args, "mcpserver.conf", serviceType) - // 如果配置了认证信息,初始化 auth manager commonOpts := &opts.CommonOptions - // 只有当所有必需的认证配置都存在时,才初始化 auth manager if len(commonOpts.AuthURL) > 0 && len(commonOpts.AdminUser) > 0 && len(commonOpts.AdminPassword) > 0 && len(commonOpts.AdminProject) > 0 { app_common.InitAuth(commonOpts, func() { log.Infof("Auth complete!!") }) } else { - log.Infof("Auth configuration incomplete, skipping auth initialization. AuthURL: %s, AdminUser: %s, AdminPassword: %s, AdminProject: %s", commonOpts.AuthURL, commonOpts.AdminUser, commonOpts.AdminPassword, commonOpts.AdminProject) + log.Infof("Auth configuration incomplete, skipping auth initialization. AuthURL: %s, AdminUser: %s, AdminPasswordSet: %v, AdminProject: %s", + commonOpts.AuthURL, commonOpts.AdminUser, len(commonOpts.AdminPassword) > 0, commonOpts.AdminProject) } - // 创建服务器 - srv := server.NewServer() + common_options.StartOptionManager(opts, opts.ConfigSyncPeriodSeconds, serviceType, serviceVersion, options.OnOptionsChange) + + srvOpt := &server.NewServerOptions{ + Instructions: resolveInstructions(startOpt), + } + srv := server.NewServerWithOptions(srvOpt) - // 初始化服务器 if err := srv.Initialize(); err != nil { log.Fatalf("Fail to init mcp server: %s", err) } - // 启动服务器 if err := srv.Start(); err != nil { log.Fatalf("Fail to start mcp server: %s", err) } } + +func resolveInstructions(startOpt *StartOptions) string { + base := climcgen.BuildServerInstructions(options.ResolvedPlatformName()) + if startOpt != nil { + if strings.TrimSpace(startOpt.Instructions) != "" { + base = startOpt.Instructions + } + if extra := strings.TrimSpace(startOpt.ExtraInstructions); extra != "" { + base = strings.TrimSpace(base) + "\n\n" + extra + } + } + return base +} diff --git a/pkg/mcp-server/tools/cloudpods_images_tool.go b/pkg/mcp-server/tools/cloudpods_images_tool.go deleted file mode 100644 index 085f4c6652..0000000000 --- a/pkg/mcp-server/tools/cloudpods_images_tool.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsImagesTool 是一个用于查询 Cloudpods 镜像列表的工具 -// 它封装了 Cloudpods 适配器和日志记录器 -type CloudpodsImagesTool struct { - // adapter 用于与 Cloudpods API 进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsImagesTool 创建一个新的 CloudpodsImagesTool 实例 -// 参数: -// - adapter: Cloudpods 适配器实例,用于与 Cloudpods API 交互 -// -// 返回值: -// - *CloudpodsImagesTool: 新创建的 CloudpodsImagesTool 实例 -func NewCloudpodsImagesTool(adapter *adapters.CloudpodsAdapter) *CloudpodsImagesTool { - return &CloudpodsImagesTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回 Cloudpods 镜像列表查询工具的元数据 -// 该工具允许用户查询 Cloudpods 中的磁盘镜像列表,并支持多种查询参数 -// 返回值: -// - mcp.Tool: 定义了工具名称、描述和参数的工具对象 -func (c *CloudpodsImagesTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_list_images", - mcp.WithDescription("查询Cloudpods磁盘镜像列表,获取系统镜像信息"), - mcp.WithString("limit", mcp.Description("返回结果数量限制,默认为20")), - mcp.WithString("offset", mcp.Description("返回结果偏移量,默认为0")), - mcp.WithString("search", mcp.Description("搜索关键词,可以按镜像名称搜索")), - mcp.WithString("os_types", mcp.Description("操作系统类型,多个用逗号分隔,如:Linux,Windows,FreeBSD")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理 Cloudpods 镜像列表查询请求 -// 该方法解析请求参数,调用适配器查询镜像列表,并格式化返回结果 -// 参数: -// - ctx: 上下文对象,用于控制请求生命周期 -// - req: 工具调用请求对象,包含查询参数 -// -// 返回值: -// - *mcp.CallToolResult: 格式化后的镜像列表查询结果 -// - error: 如果查询过程中发生错误,则返回相应的错误信息 -func (c *CloudpodsImagesTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 设置默认的查询结果数量限制为20 - limit := 20 - // 如果请求中包含limit参数且为有效正整数,则使用该值 - if limitStr := req.GetString("limit", ""); limitStr != "" { - if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - limit = parsedLimit - } - } - - // 设置默认的查询偏移量为0 - offset := 0 - // 如果请求中包含offset参数且为有效非负整数,则使用该值 - if offsetStr := req.GetString("offset", ""); offsetStr != "" { - if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } - - // 获取搜索关键词参数 - search := req.GetString("search", "") - - // 解析操作系统类型参数,支持多个类型用逗号分隔 - var osTypes []string - if osTypesStr := req.GetString("os_types", ""); osTypesStr != "" { - osTypes = strings.Split(osTypesStr, ",") - for i, osType := range osTypes { - osTypes[i] = strings.TrimSpace(osType) - } - } - - // 获取访问凭证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器查询镜像列表 - imagesResponse, err := c.adapter.ListImages(ctx, limit, offset, search, osTypes, ak, sk) - if err != nil { - log.Errorf("Fail to query image: %s", err) - return nil, fmt.Errorf("fail to query image: %w", err) - } - - // 格式化查询结果 - formattedResult := c.formatImagesResult(imagesResponse, limit, offset, search, osTypes) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - // 返回格式化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// 返回值: -// - string: 工具名称,用于唯一标识该工具 -func (c *CloudpodsImagesTool) GetName() string { - return "cloudpods_list_images" -} - -// formatImagesResult 格式化镜像列表查询结果 -// 该方法将从适配器获取的原始镜像数据转换为结构化的响应格式,包含查询信息、镜像详情和摘要信息 -// 参数: -// - response: 从适配器获取的原始镜像列表响应数据 -// - limit: 查询结果数量限制 -// - offset: 查询偏移量 -// - search: 搜索关键词 -// - osTypes: 操作系统类型过滤条件 -// -// 返回值: -// - map[string]interface{}: 格式化后的镜像列表数据,包含查询信息、镜像详情和摘要 -func (c *CloudpodsImagesTool) formatImagesResult(response *models.ImageListResponse, limit, offset int, search string, osTypes []string) map[string]interface{} { - // 初始化格式化结果结构 - formatted := map[string]interface{}{ - // 查询信息部分,包含查询参数和结果统计 - "query_info": map[string]interface{}{ - "limit": limit, - "offset": offset, - "search": search, - "os_types": osTypes, - "total": response.Total, - "count": len(response.Images), - }, - // 镜像列表部分,初始化为空数组 - "images": make([]map[string]interface{}, 0, len(response.Images)), - } - - // 遍历原始镜像数据,提取每个镜像的详细信息 - for _, image := range response.Images { - // 构造单个镜像的详细信息 - imageInfo := map[string]interface{}{ - "id": image.Id, - "name": image.Name, - "description": image.Description, - "status": image.Status, - "disk_format": image.DiskFormat, - "size": image.Size, - "checksum": image.Checksum, - "oss_checksum": image.OssChecksum, - "fast_hash": image.FastHash, - "location": image.Location, - "os_arch": image.OsArch, - "min_disk": image.MinDisk, - "min_ram": image.MinRam, - "is_data": image.IsData, - "is_guest_image": image.IsGuestImage, - "is_public": image.IsPublic, - "is_standard": image.IsStandard, - "is_system": image.IsSystem, - "is_emulated": image.IsEmulated, - "protected": image.Protected, - "disable_delete": image.DisableDelete, - "freezed": image.Freezed, - "pending_deleted": image.PendingDeleted, - "pending_deleted_at": image.PendingDeletedAt, - "auto_delete_at": image.AutoDeleteAt, - "encrypt_alg": image.EncryptAlg, - "encrypt_key": image.EncryptKey, - "encrypt_key_id": image.EncryptKeyId, - "encrypt_key_user": image.EncryptKeyUser, - "encrypt_key_user_domain": image.EncryptKeyUserDomain, - "encrypt_key_user_domain_id": image.EncryptKeyUserDomainId, - "encrypt_key_user_id": image.EncryptKeyUserId, - "encrypt_status": image.EncryptStatus, - "owner": image.Owner, - "project": image.Project, - "project_id": image.ProjectId, - "project_domain": image.ProjectDomain, - "project_metadata": image.ProjectMetadata, - "project_src": image.ProjectSrc, - "tenant": image.Tenant, - "tenant_id": image.TenantId, - "domain_id": image.DomainId, - "public_scope": image.PublicScope, - "public_src": image.PublicSrc, - "shared_domains": image.SharedDomains, - "shared_projects": image.SharedProjects, - "properties": image.Properties, - "metadata": image.Metadata, - "progress": image.Progress, - "can_delete": image.CanDelete, - "can_update": image.CanUpdate, - "update_version": image.UpdateVersion, - "created_at": image.CreatedAt, - "updated_at": image.UpdatedAt, - } - // 将镜像信息添加到结果数组中 - formatted["images"] = append(formatted["images"].([]map[string]interface{}), imageInfo) - } - - // 构造结果摘要信息 - formatted["summary"] = map[string]interface{}{ - "total_images": response.Total, - "returned_count": len(response.Images), - "has_more": response.Total > int64(offset+len(response.Images)), - "next_offset": offset + len(response.Images), - } - - // 返回格式化后的完整结果 - return formatted -} diff --git a/pkg/mcp-server/tools/cloudpods_networks_tool.go b/pkg/mcp-server/tools/cloudpods_networks_tool.go deleted file mode 100644 index 1ef860aa17..0000000000 --- a/pkg/mcp-server/tools/cloudpods_networks_tool.go +++ /dev/null @@ -1,265 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsNetworksTool 是一个用于查询 Cloudpods 网络列表的工具 -type CloudpodsNetworksTool struct { - // adapter 用于与 Cloudpods API 进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsNetworksTool 创建一个新的 CloudpodsNetworksTool 实例 -// adapter: 用于与 Cloudpods API 进行交互的适配器 -// 返回值: 指向新创建的 CloudpodsNetworksTool 实例的指针 -func NewCloudpodsNetworksTool(adapter *adapters.CloudpodsAdapter) *CloudpodsNetworksTool { - return &CloudpodsNetworksTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回网络列表查询工具的元数据 -// 该工具用于查询Cloudpods中的IP子网列表,获取网络配置信息 -// 支持的参数包括: -// - limit: 返回结果数量限制,默认为20 -// - offset: 返回结果偏移量,默认为0 -// - search: 搜索关键词,可以按网络名称搜索 -// - vpc_id: 过滤指定VPC的网络资源 -// - ak: 用户登录cloudpods后获取的access key -// - sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsNetworksTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_list_networks", - mcp.WithDescription("查询Cloudpods IP子网列表,获取网络配置信息"), - mcp.WithString("limit", mcp.Description("返回结果数量限制,默认为20")), - mcp.WithString("offset", mcp.Description("返回结果偏移量,默认为0")), - mcp.WithString("search", mcp.Description("搜索关键词,可以按网络名称搜索")), - mcp.WithString("vpc_id", mcp.Description("过滤指定VPC的网络资源")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理网络列表查询请求 -// ctx: 控制请求生命周期的上下文 -// req: 包含查询参数的请求对象 -// 返回值: 包含查询结果的工具结果对象或错误信息 -func (c *CloudpodsNetworksTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 设置默认查询限制为20 - limit := 20 - if limitStr := req.GetString("limit", ""); limitStr != "" { - // 解析limit参数,如果解析成功且大于0,则使用解析后的值 - if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - limit = parsedLimit - } - } - - // 设置默认偏移量为0 - offset := 0 - if offsetStr := req.GetString("offset", ""); offsetStr != "" { - // 解析offset参数,如果解析成功且大于等于0,则使用解析后的值 - if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } - - // 获取搜索关键词和VPC ID参数 - search := req.GetString("search", "") - vpcId := req.GetString("vpc_id", "") - - // 获取访问凭证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器获取网络列表 - networksResponse, err := c.adapter.ListNetworks(ctx, limit, offset, search, vpcId, ak, sk) - if err != nil { - log.Errorf("Fail to query network: %s", err) - return nil, fmt.Errorf("fail to query network: %w", err) - } - - // 格式化查询结果 - formattedResult := c.formatNetworksResult(networksResponse, limit, offset, search, vpcId) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - // 返回格式化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// 返回值: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsNetworksTool) GetName() string { - return "cloudpods_list_networks" -} - -// formatNetworksResult 格式化网络列表查询结果 -// response: 从适配器获取的原始网络数据 -// limit: 查询限制数量 -// offset: 查询偏移量 -// search: 搜索关键词 -// vpcId: VPC ID过滤条件 -// 返回值: 格式化后的网络列表数据,包含查询信息、网络列表和摘要信息 -func (c *CloudpodsNetworksTool) formatNetworksResult(response *models.NetworkListResponse, limit, offset int, search, vpcId string) map[string]interface{} { - // 初始化结果结构,包含查询信息和网络列表 - formatted := map[string]interface{}{ - "query_info": map[string]interface{}{ - "limit": limit, - "offset": offset, - "search": search, - "vpc_id": vpcId, - "total": response.Total, - "count": len(response.Networks), - }, - "networks": make([]map[string]interface{}, 0, len(response.Networks)), - } - - // 遍历原始网络数据,构造每个网络的详细信息 - for _, network := range response.Networks { - // 构造单个网络信息 - networkInfo := map[string]interface{}{ - "id": network.Id, - "name": network.Name, - "description": network.Description, - "status": network.Status, - "guest_ip_start": network.GuestIpStart, - "guest_ip_end": network.GuestIpEnd, - "guest_ip_mask": network.GuestIpMask, - "guest_gateway": network.GuestGateway, - "guest_dns": network.GuestDns, - "guest_dhcp": network.GuestDhcp, - "guest_ntp": network.GuestNtp, - "guest_domain": network.GuestDomain, - "guest_ip6_start": network.GuestIp6Start, - "guest_ip6_end": network.GuestIp6End, - "guest_ip6_mask": network.GuestIp6Mask, - "guest_gateway6": network.GuestGateway6, - "guest_dns6": network.GuestDns6, - "guest_domain6": network.GuestDomain6, - "vpc": network.Vpc, - "vpc_id": network.VpcId, - "vpc_ext_id": network.VpcExtId, - "wire": network.Wire, - "wire_id": network.WireId, - "zone": network.Zone, - "zone_id": network.ZoneId, - "cloudregion": network.Cloudregion, - "cloudregion_id": network.CloudregionId, - "region": network.Region, - "region_id": network.RegionId, - "provider": network.Provider, - "brand": network.Brand, - "cloud_env": network.CloudEnv, - "environment": network.Environment, - "external_id": network.ExternalId, - "account": network.Account, - "account_id": network.AccountId, - "account_status": network.AccountStatus, - "account_health_status": network.AccountHealthStatus, - "manager": network.Manager, - "manager_id": network.ManagerId, - "manager_domain": network.ManagerDomain, - "manager_domain_id": network.ManagerDomainId, - "manager_project": network.ManagerProject, - "manager_project_id": network.ManagerProjectId, - "server_type": network.ServerType, - "alloc_policy": network.AllocPolicy, - "vlan_id": network.VlanId, - "bgp_type": network.BgpType, - "is_auto_alloc": network.IsAutoAlloc, - "is_classic": network.IsClassic, - "is_default_vpc": network.IsDefaultVpc, - "is_public": network.IsPublic, - "is_system": network.IsSystem, - "is_emulated": network.IsEmulated, - "exit": network.Exit, - "freezed": network.Freezed, - "pending_deleted": network.PendingDeleted, - "pending_deleted_at": network.PendingDeletedAt, - "ports": network.Ports, - "ports_used": network.PortsUsed, - "ports6_used": network.Ports6Used, - "total": network.Total, - "total6": network.Total6, - "vnics": network.Vnics, - "vnics4": network.Vnics4, - "vnics6": network.Vnics6, - "bm_vnics": network.BmVnics, - "bm_reused_vnics": network.BmReusedVnics, - "eip_vnics": network.EipVnics, - "group_vnics": network.GroupVnics, - "lb_vnics": network.LbVnics, - "nat_vnics": network.NatVnics, - "networkinterface_vnics": network.NetworkinterfaceVnics, - "rds_vnics": network.RdsVnics, - "reserve_vnics4": network.ReserveVnics4, - "reserve_vnics6": network.ReserveVnics6, - "routes": network.Routes, - "schedtags": network.Schedtags, - "additional_wires": network.AdditionalWires, - "shared_domains": network.SharedDomains, - "shared_projects": network.SharedProjects, - "project": network.Project, - "project_id": network.ProjectId, - "project_domain": network.ProjectDomain, - "project_metadata": network.ProjectMetadata, - "project_src": network.ProjectSrc, - "tenant": network.Tenant, - "tenant_id": network.TenantId, - "domain_id": network.DomainId, - "public_scope": network.PublicScope, - "public_src": network.PublicSrc, - "source": network.Source, - "progress": network.Progress, - "can_delete": network.CanDelete, - "can_update": network.CanUpdate, - "metadata": network.Metadata, - "created_at": network.CreatedAt, - "updated_at": network.UpdatedAt, - "imported_at": network.ImportedAt, - } - // 将网络信息添加到结果数组中 - formatted["networks"] = append(formatted["networks"].([]map[string]interface{}), networkInfo) - } - - // 构造摘要信息 - formatted["summary"] = map[string]interface{}{ - "total_networks": response.Total, - "returned_count": len(response.Networks), - "has_more": response.Total > int64(offset+len(response.Networks)), - "next_offset": offset + len(response.Networks), - } - - // 返回格式化后的结果 - return formatted -} diff --git a/pkg/mcp-server/tools/cloudpods_regions_tool.go b/pkg/mcp-server/tools/cloudpods_regions_tool.go deleted file mode 100644 index 89e65eae9e..0000000000 --- a/pkg/mcp-server/tools/cloudpods_regions_tool.go +++ /dev/null @@ -1,192 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsRegionsTool 是用于查询 Cloudpods 区域列表的工具 -type CloudpodsRegionsTool struct { - // adapter 用于与 Cloudpods API 进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsRegionsTool 创建一个新的 Cloudpods 区域查询工具 -// adapter: 用于与 Cloudpods API 进行交互的适配器 -// 返回值: 指向新创建的 CloudpodsRegionsTool 实例的指针 -func NewCloudpodsRegionsTool(adapter *adapters.CloudpodsAdapter) *CloudpodsRegionsTool { - return &CloudpodsRegionsTool{ - adapter: adapter, - } -} - -// GetTool 返回 MCP 工具定义,用于查询 Cloudpods 区域列表 -// 该工具用于查询Cloudpods中的区域列表,获取所有可用的云区域信息 -// 支持的参数包括: -// - limit: 返回结果数量限制,默认为50 -// - offset: 返回结果偏移量,默认为0 -// - search: 搜索关键词,可以按区域名称搜索 -// - provider: 云平台提供商,例如:aws、azure、aliyun等 -// - ak: 用户登录cloudpods后获取的access key -// - sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsRegionsTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_list_regions", - mcp.WithDescription("查询Cloudpods区域列表,获取所有可用的云区域信息"), - mcp.WithString("limit", mcp.Description("返回结果数量限制,默认为50")), - mcp.WithString("offset", mcp.Description("返回结果偏移量,默认为0")), - mcp.WithString("search", mcp.Description("搜索关键词,可以按区域名称搜索")), - mcp.WithString("provider", mcp.Description("云平台提供商,例如:aws、azure、aliyun等")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理查询 Cloudpods 区域列表的请求 -// ctx: 控制请求生命周期的上下文 -// req: 包含查询参数的请求对象 -// 返回值: 包含查询结果的工具结果对象或错误信息 -func (c *CloudpodsRegionsTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 设置默认查询限制为50 - limit := 50 - if limitStr := req.GetString("limit", ""); limitStr != "" { - // 解析limit参数,如果解析成功且大于0,则使用解析后的值 - if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - limit = parsedLimit - } - } - - // 设置默认偏移量为0 - offset := 0 - if offsetStr := req.GetString("offset", ""); offsetStr != "" { - // 解析offset参数,如果解析成功且大于等于0,则使用解析后的值 - if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } - - // 获取搜索关键词和提供商参数 - search := req.GetString("search", "") - provider := req.GetString("provider", "") - - // 获取访问凭证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器获取区域列表 - regionsResponse, err := c.adapter.ListCloudRegions(ctx, limit, offset, search, provider, ak, sk) - if err != nil { - log.Errorf("Fail to query region: %s", err) - return nil, fmt.Errorf("fail to query region: %w", err) - } - - // 格式化查询结果 - formattedResult := c.formatRegionsResult(regionsResponse, limit, offset, search, provider) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - // 返回格式化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// formatRegionsResult 格式化区域列表查询结果 -// response: 从适配器获取的原始区域数据 -// limit: 查询限制数量 -// offset: 查询偏移量 -// search: 搜索关键词 -// provider: 云平台提供商 -// 返回值: 格式化后的区域列表数据,包含查询信息、区域列表和摘要信息 -func (c *CloudpodsRegionsTool) formatRegionsResult(response *models.CloudregionListResponse, limit, offset int, search, provider string) map[string]interface{} { - // 初始化结果结构,包含查询信息和区域列表 - formatted := map[string]interface{}{ - "query_info": map[string]interface{}{ - "limit": limit, - "offset": offset, - "search": search, - "provider": provider, - "total": response.Total, - "count": len(response.Cloudregions), - }, - "cloudregions": make([]map[string]interface{}, 0, len(response.Cloudregions)), - } - - // 遍历原始区域数据,构造每个区域的详细信息 - for _, region := range response.Cloudregions { - // 构造单个区域信息 - regionInfo := map[string]interface{}{ - "id": region.Id, - "name": region.Name, - "description": region.Description, - "provider": region.Provider, - "cloud_env": region.CloudEnv, - "environment": region.Environment, - "city": region.City, - "country_code": region.CountryCode, - "latitude": region.Latitude, - "longitude": region.Longitude, - "status": region.Status, - "enabled": region.Enabled, - "external_id": region.ExternalId, - "guest_count": region.GuestCount, - "guest_increment_count": region.GuestIncrementCount, - "network_count": region.NetworkCount, - "vpc_count": region.VpcCount, - "zone_count": region.ZoneCount, - "progress": region.Progress, - "source": region.Source, - "can_delete": region.CanDelete, - "can_update": region.CanUpdate, - "is_emulated": region.IsEmulated, - "metadata": region.Metadata, - "created_at": region.CreatedAt, - "updated_at": region.UpdatedAt, - "imported_at": region.ImportedAt, - } - // 将区域信息添加到结果数组中 - formatted["cloudregions"] = append(formatted["cloudregions"].([]map[string]interface{}), regionInfo) - } - - // 构造摘要信息 - formatted["summary"] = map[string]interface{}{ - "total_cloudregions": response.Total, - "returned_count": len(response.Cloudregions), - "has_more": response.Total > int64(offset+len(response.Cloudregions)), - "next_offset": offset + len(response.Cloudregions), - } - - // 返回格式化后的结果 - return formatted -} - -// GetName 返回工具名称 -// 返回值: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsRegionsTool) GetName() string { - return "cloudpods_list_regions" -} diff --git a/pkg/mcp-server/tools/cloudpods_server_create_tool.go b/pkg/mcp-server/tools/cloudpods_server_create_tool.go deleted file mode 100644 index 129e85047c..0000000000 --- a/pkg/mcp-server/tools/cloudpods_server_create_tool.go +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsServerCreateTool 用于创建Cloudpods虚拟机实例 -type CloudpodsServerCreateTool struct { - // adapter 用于与 Cloudpods API 进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerCreateTool 创建一个新的CloudpodsServerCreateTool实例 -// adapter: 用于与Cloudpods API交互的适配器 -// 返回值: CloudpodsServerCreateTool实例指针 -func NewCloudpodsServerCreateTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerCreateTool { - return &CloudpodsServerCreateTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回创建虚拟机工具的元数据 -// 该工具用于创建Cloudpods虚拟机实例,支持指定各种配置参数 -// name: 虚拟机名称 (必填) -// vcpu_count: CPU核心数 (必填) -// vmem_size: 内存大小(MB) (必填) -// image_id: 镜像ID (必填) -// disk_size: 系统盘大小(GB),不指定则使用镜像默认大小 -// network_id: 网络ID (必填) -// serversku_id: 套餐ID,指定后将忽略vcpu_count和vmem_size参数 -// password: 虚拟机密码,长度8-30个字符 -// count: 创建数量,默认为1 -// auto_start: 是否自动启动,默认为true -// billing_type: 计费类型,例如:postpaid、prepaid -// duration: 包年包月时长,例如:1M、1Y -// description: 描述信息 -// hostname: 主机名 -// hypervisor: 虚拟化技术,如kvm, esxi等,默认为kvm -// metadata: 标签列表,格式为JSON字符串,例如:{"key1":"value1","key2":"value2"} -// secgroup_id: 安全组ID -// secgroups: 安全组ID列表,多个ID用逗号分隔 -// user_data: 用户自定义启动脚本 -// keypair_id: 秘钥对ID -// project_id: 项目ID -// zone_id: 可用区ID -// region_id: 区域ID -// disable_delete: 是否开启删除保护,默认为true -// boot_order: 启动顺序,如cdn -// data_disks: 数据盘配置,格式为JSON字符串数组,例如:[{"size":100,"disk_type":"data"}] -// ak: 用户登录cloudpods后获取的access key -// sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsServerCreateTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_create_server", - mcp.WithDescription("创建Cloudpods虚拟机实例"), - mcp.WithString("name", mcp.Required(), mcp.Description("虚拟机名称")), - mcp.WithString("vcpu_count", mcp.Required(), mcp.Description("CPU核心数")), - mcp.WithString("vmem_size", mcp.Required(), mcp.Description("内存大小(MB)")), - mcp.WithString("image_id", mcp.Required(), mcp.Description("镜像ID")), - mcp.WithString("disk_size", mcp.Description("系统盘大小(GB),不指定则使用镜像默认大小")), - mcp.WithString("network_id", mcp.Required(), mcp.Description("网络ID")), - mcp.WithString("serversku_id", mcp.Description("套餐ID,指定后将忽略vcpu_count和vmem_size参数")), - mcp.WithString("password", mcp.Description("虚拟机密码,长度8-30个字符")), - mcp.WithString("count", mcp.Description("创建数量,默认为1")), - mcp.WithString("auto_start", mcp.Description("是否自动启动,默认为true")), - mcp.WithString("billing_type", mcp.Description("计费类型,例如:postpaid、prepaid")), - mcp.WithString("duration", mcp.Description("包年包月时长,例如:1M、1Y")), - mcp.WithString("description", mcp.Description("描述信息")), - mcp.WithString("hostname", mcp.Description("主机名")), - mcp.WithString("hypervisor", mcp.Description("虚拟化技术,如kvm, esxi等,默认为kvm")), - mcp.WithString("metadata", mcp.Description("标签列表,格式为JSON字符串,例如:{\"key1\":\"value1\",\"key2\":\"value2\"}")), - mcp.WithString("secgroup_id", mcp.Description("安全组ID")), - mcp.WithString("secgroups", mcp.Description("安全组ID列表,多个ID用逗号分隔")), - mcp.WithString("user_data", mcp.Description("用户自定义启动脚本")), - mcp.WithString("keypair_id", mcp.Description("秘钥对ID")), - mcp.WithString("project_id", mcp.Description("项目ID")), - mcp.WithString("zone_id", mcp.Description("可用区ID")), - mcp.WithString("region_id", mcp.Description("区域ID")), - mcp.WithString("disable_delete", mcp.Description("是否开启删除保护,默认为true")), - mcp.WithString("boot_order", mcp.Description("启动顺序,如cdn")), - mcp.WithString("data_disks", mcp.Description("数据盘配置,格式为JSON字符串数组,例如:[{\"size\":100,\"disk_type\":\"data\"}]")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理创建虚拟机的请求 -// ctx: 上下文,用于控制请求的生命周期 -// req: 包含创建虚拟机所需参数的请求对象 -// 返回值: 包含创建结果的工具结果对象或错误信息 -func (c *CloudpodsServerCreateTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 获取必填参数:虚拟机名称 - name, err := req.RequireString("name") - if err != nil { - return nil, err - } - - // 获取必填参数:镜像ID - imageID, err := req.RequireString("image_id") - if err != nil { - return nil, err - } - - // 获取必填参数:网络ID - networkID, err := req.RequireString("network_id") - if err != nil { - return nil, err - } - - // 获取必填参数:CPU核心数并转换为整数 - vcpuCountStr, err := req.RequireString("vcpu_count") - if err != nil { - return nil, err - } - vcpuCount, err := strconv.ParseInt(vcpuCountStr, 10, 64) - if err != nil { - return nil, fmt.Errorf("无效的CPU核心数: %s", vcpuCountStr) - } - - // 获取必填参数:内存大小并转换为整数 - vmemSizeStr, err := req.RequireString("vmem_size") - if err != nil { - return nil, err - } - vmemSize, err := strconv.ParseInt(vmemSizeStr, 10, 64) - if err != nil { - return nil, fmt.Errorf("无效的内存大小: %s", vmemSizeStr) - } - - // 获取可选参数:套餐ID - serverSkuID := req.GetString("serversku_id", "") - - // 获取可选参数:磁盘大小,如果指定则转换为整数 - diskSize := int64(0) - if diskSizeStr := req.GetString("disk_size", ""); diskSizeStr != "" { - if parsedSize, err := strconv.ParseInt(diskSizeStr, 10, 64); err == nil && parsedSize > 0 { - diskSize = parsedSize - } - } - - // 获取可选参数:虚拟机密码,并验证长度 - password := req.GetString("password", "") - if password != "" && (len(password) < 8 || len(password) > 30) { - return nil, fmt.Errorf("密码长度必须在8-30个字符之间") - } - - // 获取可选参数:创建数量,默认为1 - count := 1 - if countStr := req.GetString("count", "1"); countStr != "1" { - if parsedCount, err := strconv.Atoi(countStr); err == nil && parsedCount > 0 { - count = parsedCount - } - } - - // 获取可选参数:是否自动启动,默认为true - autoStart := true - if autoStartStr := req.GetString("auto_start", "true"); autoStartStr == "false" { - autoStart = false - } - - // 获取可选参数:是否开启删除保护,默认为true - disableDelete := true - if disableDeleteStr := req.GetString("disable_delete", "true"); disableDeleteStr == "false" { - disableDelete = false - } - - // 获取其他可选参数 - billingType := req.GetString("billing_type", "") - duration := req.GetString("duration", "") - description := req.GetString("description", "") - hostname := req.GetString("hostname", "") - hypervisor := req.GetString("hypervisor", "") - secgroupID := req.GetString("secgroup_id", "") - userData := req.GetString("user_data", "") - keypairID := req.GetString("keypair_id", "") - projectID := req.GetString("project_id", "") - zoneID := req.GetString("zone_id", "") - regionID := req.GetString("region_id", "") - bootOrder := req.GetString("boot_order", "") - - // 获取安全组ID列表,并按逗号分割 - var secgroups []string - if secgroupsStr := req.GetString("secgroups", ""); secgroupsStr != "" { - secgroups = strings.Split(secgroupsStr, ",") - } - - // 解析元数据JSON字符串 - metadata := make(map[string]string) - if metadataStr := req.GetString("metadata", ""); metadataStr != "" { - if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { - return nil, fmt.Errorf("无效的元数据JSON格式: %w", err) - } - } - - // 解析数据盘配置JSON数组 - var dataDisks []models.DiskConfig - if dataDisksStr := req.GetString("data_disks", ""); dataDisksStr != "" { - if err := json.Unmarshal([]byte(dataDisksStr), &dataDisks); err != nil { - return nil, fmt.Errorf("无效的数据盘配置JSON格式: %w", err) - } - } - - // 构造创建虚拟机的请求对象 - createRequest := models.CreateServerRequest{ - Name: name, - VcpuCount: vcpuCount, - VmemSize: vmemSize, - ImageId: imageID, - DiskSize: diskSize, - NetworkId: networkID, - ServerskuId: serverSkuID, - Count: count, - Password: password, - AutoStart: autoStart, - BillingType: billingType, - Duration: duration, - Description: description, - Hostname: hostname, - Hypervisor: hypervisor, - Metadata: metadata, - SecgroupId: secgroupID, - Secgroups: secgroups, - UserData: userData, - KeypairId: keypairID, - ProjectId: projectID, - ZoneId: zoneID, - RegionId: regionID, - DisableDelete: disableDelete, - BootOrder: bootOrder, - DataDisks: dataDisks, - } - - // 获取访问凭证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器创建虚拟机 - response, err := c.adapter.CreateServer(ctx, createRequest, ak, sk) - if err != nil { - log.Errorf("Fail to create server: %s", err) - return nil, fmt.Errorf("fail to create server: %w", err) - } - - // 格式化创建结果 - formattedResult := c.formatCreateResult(response, &createRequest) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - // 返回格式化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// 返回值: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsServerCreateTool) GetName() string { - return "cloudpods_create_server" -} - -// formatCreateResult 格式化创建虚拟机的响应结果 -// response: 原始的创建虚拟机响应数据 -// request: 原始的创建虚拟机请求数据 -// 返回值: 格式化后的结果,包含创建信息、结果详情和摘要 -func (c *CloudpodsServerCreateTool) formatCreateResult(response *models.CreateServerResponse, request *models.CreateServerRequest) map[string]interface{} { - // 初始化格式化结果结构 - formatted := map[string]interface{}{ - // 创建请求的基本信息 - "create_info": map[string]interface{}{ - "name": request.Name, - "vcpu_count": request.VcpuCount, - "vmem_size": request.VmemSize, - "image_id": request.ImageId, - "disk_size": request.DiskSize, - "network_id": request.NetworkId, - "serversku_id": request.ServerskuId, - "count": request.Count, - "auto_start": request.AutoStart, - "billing_type": request.BillingType, - "duration": request.Duration, - "description": request.Description, - "hostname": request.Hostname, - "hypervisor": request.Hypervisor, - "secgroup_id": request.SecgroupId, - "keypair_id": request.KeypairId, - "project_id": request.ProjectId, - "zone_id": request.ZoneId, - "region_id": request.RegionId, - "disable_delete": request.DisableDelete, - "boot_order": request.BootOrder, - }, - // 创建响应的结果信息 - "result": map[string]interface{}{ - "status": response.Status, - "message": response.Message, - "servers": make([]map[string]interface{}, 0, len(response.Data.Servers)), - }, - } - - // 遍历创建的虚拟机列表,构造每个虚拟机的详细信息 - for _, server := range response.Data.Servers { - serverInfo := map[string]interface{}{ - "id": server.ID, - "name": server.Name, - "status": server.Status, - "task_id": server.TaskID, - } - formatted["result"].(map[string]interface{})["servers"] = append( - formatted["result"].(map[string]interface{})["servers"].([]map[string]interface{}), - serverInfo, - ) - } - - // 构造摘要信息 - formatted["summary"] = map[string]interface{}{ - "requested_count": request.Count, // 请求创建的虚拟机数量 - "created_count": len(response.Data.Servers), // 实际创建的虚拟机数量 - "success": response.Status == 200, // 创建是否成功 - } - - return formatted -} diff --git a/pkg/mcp-server/tools/cloudpods_server_monitor_tool.go b/pkg/mcp-server/tools/cloudpods_server_monitor_tool.go deleted file mode 100644 index d2561cdea9..0000000000 --- a/pkg/mcp-server/tools/cloudpods_server_monitor_tool.go +++ /dev/null @@ -1,366 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsServerMonitorTool 用于获取Cloudpods虚拟机监控信息 -type CloudpodsServerMonitorTool struct { - // adapter 用于与 Cloudpods API 进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerMonitorTool 创建一个新的CloudpodsServerMonitorTool实例 -// adapter: 用于与Cloudpods API交互的适配器 -// 返回值: CloudpodsServerMonitorTool实例指针 -func NewCloudpodsServerMonitorTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerMonitorTool { - return &CloudpodsServerMonitorTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回获取虚拟机监控信息工具的元数据 -// 该工具用于获取Cloudpods虚拟机的监控信息,包括CPU、内存、磁盘、网络等指标 -// server_id: 虚拟机ID (必填) -// start_time: 开始时间戳(秒),默认为1小时前 -// end_time: 结束时间戳(秒),默认为当前时间 -// metrics: 监控指标,多个用逗号分隔,例如:cpu_usage,mem_usage,disk_usage,net_bps_rx,net_bps_tx -// ak: 用户登录cloudpods后获取的access key -// sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsServerMonitorTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_get_server_monitor", - mcp.WithDescription("获取Cloudpods虚拟机监控信息,包括CPU、内存、磁盘、网络等指标"), - mcp.WithString("server_id", mcp.Required(), mcp.Description("虚拟机ID")), - mcp.WithString("start_time", mcp.Description("开始时间戳(秒),默认为1小时前")), - mcp.WithString("end_time", mcp.Description("结束时间戳(秒),默认为当前时间")), - mcp.WithString("metrics", mcp.Description("监控指标,多个用逗号分隔,例如:cpu_usage,mem_usage,disk_usage,net_bps_rx,net_bps_tx")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理获取虚拟机监控信息的请求 -// ctx: 控制生命周期的上下文 -// req: 包含获取监控信息所需参数的请求对象 -// 返回值: 包含监控信息的响应对象和可能的错误 -func (c *CloudpodsServerMonitorTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 获取必填参数:虚拟机ID - serverID, err := req.RequireString("server_id") - if err != nil { - return nil, err - } - - // 设置默认时间范围:结束时间为当前时间,开始时间为1小时前 - now := time.Now().Unix() - startTime := now - 3600 - - // 解析开始时间参数,如果指定则使用指定值 - if startTimeStr := req.GetString("start_time", ""); startTimeStr != "" { - if parsedStartTime, err := strconv.ParseInt(startTimeStr, 10, 64); err == nil { - startTime = parsedStartTime - } - } - - // 解析结束时间参数,如果指定则使用指定值 - endTime := now - if endTimeStr := req.GetString("end_time", ""); endTimeStr != "" { - if parsedEndTime, err := strconv.ParseInt(endTimeStr, 10, 64); err == nil { - endTime = parsedEndTime - } - } - - // 获取可选参数:监控指标 - var metrics []string - if metricsStr := req.GetString("metrics", ""); metricsStr != "" { - metrics = strings.Split(metricsStr, ",") - for i, metric := range metrics { - metrics[i] = strings.TrimSpace(metric) - } - } else { - metrics = []string{"cpu_usage", "mem_usage", "disk_usage", "net_bps_rx", "net_bps_tx"} - } - - // 获取ak和sk参数,用于认证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器获取虚拟机监控信息 - monitorResponse, err := c.adapter.GetServerMonitor(ctx, serverID, startTime, endTime, metrics, ak, sk) - if err != nil { - log.Errorf("Fail to get server monitor: %s", err) - return nil, fmt.Errorf("fail to get server monitor: %w", err) - } - - // 格式化监控结果 - formattedResult := c.formatMonitorResult(monitorResponse, serverID, startTime, endTime, metrics) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - // 返回格式化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// 返回值: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsServerMonitorTool) GetName() string { - return "cloudpods_get_server_monitor" -} - -// formatMonitorResult 格式化虚拟机监控信息的响应结果 -// response: 原始监控响应数据 -// serverID: 虚拟机ID -// startTime: 监控开始时间 -// endTime: 监控结束时间 -// requestedMetrics: 请求的监控指标 -// 返回值: 包含监控信息的格式化结果 -func (c *CloudpodsServerMonitorTool) formatMonitorResult(response *models.MonitorResponse, serverID string, startTime, endTime int64, requestedMetrics []string) map[string]interface{} { - // 初始化格式化结果结构 - formatted := map[string]interface{}{ - // 添加请求的基本信息 - "query_info": map[string]interface{}{ - "server_id": serverID, - "start_time": startTime, - "end_time": endTime, - "start_time_human": time.Unix(startTime, 0).Format("2006-01-02 15:04:05"), - "end_time_human": time.Unix(endTime, 0).Format("2006-01-02 15:04:05"), - "requested_metrics": requestedMetrics, - "duration_seconds": endTime - startTime, - }, - "status": response.Status, - "metrics": make([]map[string]interface{}, 0, len(response.Data.Metrics)), - } - - for _, metric := range response.Data.Metrics { - metricInfo := map[string]interface{}{ - "metric": metric.Metric, - "unit": metric.Unit, - "data_points": len(metric.Values), - "values": make([]map[string]interface{}, 0, len(metric.Values)), - } - - var totalValue float64 - var minValue, maxValue float64 - var latestValue float64 - var latestTime int64 - - for i, value := range metric.Values { - valueInfo := map[string]interface{}{ - "timestamp": value.Timestamp, - "time_human": time.Unix(value.Timestamp, 0).Format("2006-01-02 15:04:05"), - "value": value.Value, - } - metricInfo["values"] = append(metricInfo["values"].([]map[string]interface{}), valueInfo) - - totalValue += value.Value - if i == 0 { - minValue = value.Value - maxValue = value.Value - } else { - if value.Value < minValue { - minValue = value.Value - } - if value.Value > maxValue { - maxValue = value.Value - } - } - - if value.Timestamp > latestTime { - latestTime = value.Timestamp - latestValue = value.Value - } - } - - if len(metric.Values) > 0 { - metricInfo["statistics"] = map[string]interface{}{ - "min": minValue, - "max": maxValue, - "average": totalValue / float64(len(metric.Values)), - "latest": latestValue, - } - } - - formatted["metrics"] = append(formatted["metrics"].([]map[string]interface{}), metricInfo) - } - - formatted["summary"] = map[string]interface{}{ - "total_metrics": len(response.Data.Metrics), - "query_successful": response.Status == 200, - "time_range_hours": float64(endTime-startTime) / 3600, - } - - return formatted -} - -// CloudpodsServerStatsTool 用于获取Cloudpods虚拟机实时统计信息 -type CloudpodsServerStatsTool struct { - // adapter 用于与 Cloudpods API 进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerStatsTool 创建一个新的CloudpodsServerStatsTool实例 -// adapter: 用于与Cloudpods API交互的适配器 -// 返回值: CloudpodsServerStatsTool实例指针 -func NewCloudpodsServerStatsTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerStatsTool { - return &CloudpodsServerStatsTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回获取虚拟机统计信息工具的元数据 -// 该工具用于获取Cloudpods虚拟机的实时统计信息,包括CPU使用率、内存使用率、磁盘使用率和网络流量 -// server_id: 虚拟机ID (必填) -// ak: 用户登录cloudpods后获取的access key -// sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsServerStatsTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_get_server_stats", - mcp.WithDescription("获取Cloudpods虚拟机实时统计信息,包括CPU使用率、内存使用率、磁盘使用率和网络流量"), - mcp.WithString("server_id", mcp.Required(), mcp.Description("虚拟机ID")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理获取虚拟机统计信息的请求 -// ctx: 控制生命周期的上下文 -// req: 包含获取统计信息所需参数的请求对象 -// 返回值: 包含统计信息的响应对象和可能的错误 -func (c *CloudpodsServerStatsTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 获取必填参数:虚拟机ID - serverID, err := req.RequireString("server_id") - if err != nil { - return nil, err - } - - // 获取可选参数:访问凭证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器获取虚拟机统计信息 - statsResponse, err := c.adapter.GetServerStats(ctx, serverID, ak, sk) - if err != nil { - log.Errorf("Fail to get server stats: %s", err) - return nil, fmt.Errorf("fail to get server stats: %w", err) - } - - // 格式化统计结果 - formattedResult := c.formatStatsResult(statsResponse, serverID) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - // 返回格式化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// 返回值: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsServerStatsTool) GetName() string { - return "cloudpods_get_server_stats" -} - -// formatStatsResult 格式化虚拟机统计信息的响应结果 -// response: 原始统计响应数据 -// serverID: 虚拟机ID -// 返回值: 包含统计信息的格式化结果 -func (c *CloudpodsServerStatsTool) formatStatsResult(response *models.ServerStatsResponse, serverID string) map[string]interface{} { - // 初始化格式化结果结构 - formatted := map[string]interface{}{ - "server_id": serverID, - "status": response.Status, - // 添加统计信息 - "stats": map[string]interface{}{ - "cpu_usage": fmt.Sprintf("%.1f%%", response.Data.CPUUsage), - "memory_usage": fmt.Sprintf("%.1f%%", response.Data.MemUsage), - "disk_usage": fmt.Sprintf("%.1f%%", response.Data.DiskUsage), - "network": map[string]interface{}{ - "receive_bps": response.Data.NetBpsRx, - "transmit_bps": response.Data.NetBpsTx, - "receive_mbps": fmt.Sprintf("%.2f Mbps", float64(response.Data.NetBpsRx)/(1024*1024)), - "transmit_mbps": fmt.Sprintf("%.2f Mbps", float64(response.Data.NetBpsTx)/(1024*1024)), - }, - "updated_at": response.Data.UpdatedAt, - }, - // 添加原始数据 - "raw_data": map[string]interface{}{ - "cpu_usage": response.Data.CPUUsage, - "mem_usage": response.Data.MemUsage, - "disk_usage": response.Data.DiskUsage, - "net_bps_rx": response.Data.NetBpsRx, - "net_bps_tx": response.Data.NetBpsTx, - }, - } - - // 评估虚拟机健康状态 - var healthStatus string - var healthScore int - - if response.Data.CPUUsage > 90 || response.Data.MemUsage > 90 || response.Data.DiskUsage > 90 { - healthStatus = "警告" - healthScore = 1 - } else if response.Data.CPUUsage > 70 || response.Data.MemUsage > 70 || response.Data.DiskUsage > 80 { - healthStatus = "注意" - healthScore = 2 - } else { - healthStatus = "正常" - healthScore = 3 - } - - // 添加健康状态信息 - formatted["health"] = map[string]interface{}{ - "status": healthStatus, - "score": healthScore, - "notes": []string{}, - } - - // 添加健康状态建议 - notes := []string{} - if response.Data.CPUUsage > 90 { - notes = append(notes, "CPU使用率过高,建议检查系统负载") - } - if response.Data.MemUsage > 90 { - notes = append(notes, "内存使用率过高,建议释放内存或增加内存") - } - if response.Data.DiskUsage > 90 { - notes = append(notes, "磁盘使用率过高,建议清理磁盘空间") - } - formatted["health"].(map[string]interface{})["notes"] = notes - - return formatted -} diff --git a/pkg/mcp-server/tools/cloudpods_server_operations_tool.go b/pkg/mcp-server/tools/cloudpods_server_operations_tool.go deleted file mode 100644 index 9a2ce0ed1e..0000000000 --- a/pkg/mcp-server/tools/cloudpods_server_operations_tool.go +++ /dev/null @@ -1,524 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsServerStartTool 用于启动指定的Cloudpods虚拟机实例 -type CloudpodsServerStartTool struct { - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerStartTool 创建一个新的CloudpodsServerStartTool实例 -func NewCloudpodsServerStartTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerStartTool { - return &CloudpodsServerStartTool{ - adapter: adapter, - } -} - -// GetTool 返回启动虚拟机工具的定义,包括参数和描述 -func (c *CloudpodsServerStartTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_start_server", - mcp.WithDescription("启动指定的Cloudpods虚拟机实例。用户要求开机/启动时必须调用本工具真正执行启动,仅调用cloudpods_list_servers 查询不算完成。若尚不知 server_id,先用 cloudpods_list_servers(可用 search)定位,拿到 id 后立即调用本工具,不要只查询就结束。"), - mcp.WithString("server_id", mcp.Required(), mcp.Description("虚拟机ID,来自 cloudpods_list_servers 返回的 id 字段")), - mcp.WithString("auto_prepaid", mcp.Description("按量机器自动转换为包年包月,默认为false")), - mcp.WithString("qemu_version", mcp.Description("指定启动虚拟机的Qemu版本,可选值:2.12.1, 4.2.0,仅适用于KVM虚拟机")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理启动虚拟机的请求,调用适配器执行启动操作并返回结果 -func (c *CloudpodsServerStartTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 从请求中获取必需的 server_id 参数 - serverID, err := req.RequireString("server_id") - if err != nil { - return nil, err - } - - // 解析 auto_prepaid 参数,决定是否自动转换为包年包月 - autoPrepaid := false - if autoPrepaidStr := req.GetString("auto_prepaid", "false"); autoPrepaidStr == "true" { - autoPrepaid = true - } - - // 获取 qemu_version 参数,用于指定启动虚拟机的 Qemu 版本 - qemuVersion := req.GetString("qemu_version", "") - - // 构造启动虚拟机的请求参数 - startReq := models.ServerStartRequest{ - AutoPrepaid: autoPrepaid, - QemuVersion: qemuVersion, - } - - // 获取认证所需的 access key 和 secret key - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器的 StartServer 方法执行启动操作 - response, err := c.adapter.StartServer(ctx, serverID, startReq, ak, sk) - if err != nil { - log.Errorf("Fail to start server: %s", err) - return nil, fmt.Errorf("fail to start server: %w", err) - } - - // 构造返回结果,包含任务ID、成功状态和状态信息 - result := map[string]interface{}{ - "server_id": serverID, - "operation": "start", - "task_id": response.TaskId, - "success": response.Success, - "status": response.Status, - } - - // 如果有错误信息,则添加到结果中 - if response.Error != "" { - result["error"] = response.Error - } - - // 将结果序列化为 JSON 格式 - resultJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %w", err) - } - - // 返回序列化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回启动虚拟机工具的名称 -func (c *CloudpodsServerStartTool) GetName() string { - return "cloudpods_start_server" -} - -// CloudpodsServerStopTool 用于停止指定的Cloudpods虚拟机实例 -type CloudpodsServerStopTool struct { - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerStopTool 创建一个新的CloudpodsServerStopTool实例 -func NewCloudpodsServerStopTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerStopTool { - return &CloudpodsServerStopTool{ - adapter: adapter, - } -} - -// GetTool 返回停止虚拟机工具的定义,包括参数和描述 -func (c *CloudpodsServerStopTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_stop_server", - mcp.WithDescription("停止指定的Cloudpods虚拟机实例。用户要求关机/停止时必须调用本工具真正执行停止,仅调用 cloudpods_list_servers 查询不算完成。若尚不知 server_id,先用 cloudpods_list_servers(可用 search)定位,拿到 id 后立即调用本工具,不要只查询就结束,也不要再次向用户确认。"), - mcp.WithString("server_id", mcp.Required(), mcp.Description("虚拟机ID,来自 cloudpods_list_servers 返回的 id 字段")), - mcp.WithString("is_force", mcp.Description("是否强制停止,默认为false")), - mcp.WithString("stop_charging", mcp.Description("是否关机停止计费,默认为false")), - mcp.WithString("timeout_secs", mcp.Description("关机等待时间,如果是强制关机,则等待时间为0,如果不设置,默认为30秒")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理停止虚拟机的请求,调用适配器执行停止操作并返回结果 -func (c *CloudpodsServerStopTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 从请求中获取必需的 server_id 参数 - serverID, err := req.RequireString("server_id") - if err != nil { - return nil, err - } - - // 解析 is_force 参数,决定是否强制停止虚拟机 - isForce := false - if isForceStr := req.GetString("is_force", "false"); isForceStr == "true" { - isForce = true - } - - // 解析 stop_charging 参数,决定是否停止计费 - stopCharging := false - if stopChargingStr := req.GetString("stop_charging", "false"); stopChargingStr == "true" { - stopCharging = true - } - - // 解析 timeout_secs 参数,设置停止操作的超时时间 - timeoutSecs := int64(0) - if timeoutSecsStr := req.GetString("timeout_secs", ""); timeoutSecsStr != "" { - if parsed, err := strconv.ParseInt(timeoutSecsStr, 10, 64); err == nil && parsed > 0 { - timeoutSecs = parsed - } - } - - // 构造停止虚拟机的请求参数 - stopReq := models.ServerStopRequest{ - IsForce: isForce, - StopCharging: stopCharging, - TimeoutSecs: timeoutSecs, - } - - // 获取认证所需的 access key 和 secret key - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器的 StopServer 方法执行停止操作 - response, err := c.adapter.StopServer(ctx, serverID, stopReq, ak, sk) - if err != nil { - log.Errorf("Fail to stop server: %s", err) - return nil, fmt.Errorf("fail to stop server: %w", err) - } - - // 构造返回结果,包含任务ID、成功状态和状态信息 - result := map[string]interface{}{ - "server_id": serverID, - "operation": "stop", - "task_id": response.TaskId, - "success": response.Success, - "status": response.Status, - } - - // 如果有错误信息,则添加到结果中 - if response.Error != "" { - result["error"] = response.Error - } - - // 将结果序列化为 JSON 格式 - resultJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %w", err) - } - - // 返回序列化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回停止虚拟机工具的名称 -func (c *CloudpodsServerStopTool) GetName() string { - return "cloudpods_stop_server" -} - -// CloudpodsServerRestartTool 用于重启指定的Cloudpods虚拟机实例 -type CloudpodsServerRestartTool struct { - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerRestartTool 创建一个新的CloudpodsServerRestartTool实例 -func NewCloudpodsServerRestartTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerRestartTool { - return &CloudpodsServerRestartTool{ - adapter: adapter, - } -} - -// GetTool 返回重启虚拟机工具的定义,包括参数和描述 -func (c *CloudpodsServerRestartTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_restart_server", - mcp.WithDescription("重启指定的Cloudpods虚拟机实例。用户要求重启时必须调用本工具真正执行重启,仅调用 cloudpods_list_servers 查询不算完成。若尚不知 server_id,先用 cloudpods_list_servers(可用 search)定位,拿到 id 后立即调用本工具,不要只查询就结束,也不要再次向用户确认。"), - mcp.WithString("server_id", mcp.Required(), mcp.Description("虚拟机ID,来自 cloudpods_list_servers 返回的 id 字段")), - mcp.WithString("is_force", mcp.Description("是否强制重启,默认为false")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理重启虚拟机的请求,调用适配器执行重启操作并返回结果 -func (c *CloudpodsServerRestartTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 从请求中获取必需的 server_id 参数 - serverID, err := req.RequireString("server_id") - if err != nil { - return nil, err - } - - // 解析 is_force 参数,决定是否强制重启虚拟机 - isForce := false - if isForceStr := req.GetString("is_force", "false"); isForceStr == "true" { - isForce = true - } - - // 构造重启虚拟机的请求参数 - restartReq := models.ServerRestartRequest{ - IsForce: isForce, - } - - // 获取认证所需的 access key 和 secret key - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器的 RestartServer 方法执行重启操作 - response, err := c.adapter.RestartServer(ctx, serverID, restartReq, ak, sk) - if err != nil { - log.Errorf("Fail to query restart server: %s", err) - return nil, fmt.Errorf("fail to restart server: %w", err) - } - - // 构造返回结果,包含任务ID、成功状态和状态信息 - result := map[string]interface{}{ - "server_id": serverID, - "operation": "restart", - "task_id": response.TaskId, - "success": response.Success, - "status": response.Status, - } - - // 如果有错误信息,则添加到结果中 - if response.Error != "" { - result["error"] = response.Error - } - - // 将结果序列化为 JSON 格式 - resultJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %w", err) - } - - // 返回序列化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回重启虚拟机工具的名称 -func (c *CloudpodsServerRestartTool) GetName() string { - return "cloudpods_restart_server" -} - -// CloudpodsServerResetPasswordTool 用于重置指定Cloudpods虚拟机的登录密码 -type CloudpodsServerResetPasswordTool struct { - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerResetPasswordTool 创建一个新的CloudpodsServerResetPasswordTool实例 -func NewCloudpodsServerResetPasswordTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerResetPasswordTool { - return &CloudpodsServerResetPasswordTool{ - adapter: adapter, - } -} - -// GetTool 返回重置虚拟机密码工具的定义,包括参数和描述 -func (c *CloudpodsServerResetPasswordTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_reset_server_password", - mcp.WithDescription("重置指定Cloudpods虚拟机的登录密码。用户要求重置密码时必须调用本工具真正执行,仅调用 cloudpods_list_servers 查询不算完成。若尚不知 server_id,先用 cloudpods_list_servers 定位,拿到 id 后立即调用本工具。"), - mcp.WithString("server_id", mcp.Required(), mcp.Description("虚拟机ID,来自 cloudpods_list_servers 返回的 id 字段")), - mcp.WithString("password", mcp.Required(), mcp.Description("新密码,长度8-30个字符")), - mcp.WithString("reset_password", mcp.Description("是否重置密码,默认为true")), - mcp.WithString("auto_start", mcp.Description("重置后是否自动启动,默认为true")), - mcp.WithString("username", mcp.Description("用户名,可选,默认为空")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理重置虚拟机密码的请求,调用适配器执行密码重置操作并返回结果 -func (c *CloudpodsServerResetPasswordTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 从请求中获取必需的 server_id 参数 - serverID, err := req.RequireString("server_id") - if err != nil { - return nil, err - } - - // 从请求中获取必需的 password 参数,并验证其长度 - password, err := req.RequireString("password") - if err != nil { - return nil, err - } - - if len(password) < 8 || len(password) > 30 { - return nil, fmt.Errorf("密码长度必须在8-30个字符之间") - } - - // 解析 reset_password 参数,决定是否重置密码 - resetPassword := true - if resetPasswordStr := req.GetString("reset_password", "true"); resetPasswordStr == "false" { - resetPassword = false - } - - // 解析 auto_start 参数,决定重置密码后是否自动启动虚拟机 - autoStart := true - if autoStartStr := req.GetString("auto_start", "true"); autoStartStr == "false" { - autoStart = false - } - - // 获取 username 参数,可选 - username := req.GetString("username", "") - - // 构造重置虚拟机密码的请求参数 - resetPasswordReq := models.ServerResetPasswordRequest{ - Password: password, - ResetPassword: resetPassword, - AutoStart: autoStart, - Username: username, - } - - // 获取认证所需的 access key 和 secret key - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器的 ResetServerPassword 方法执行密码重置操作 - response, err := c.adapter.ResetServerPassword(ctx, serverID, resetPasswordReq, ak, sk) - if err != nil { - log.Errorf("Fail to reset server password: %s", err) - return nil, fmt.Errorf("fail to reset server password: %w", err) - } - - // 构造返回结果,包含任务ID、成功状态和状态信息 - result := map[string]interface{}{ - "server_id": serverID, - "operation": "reset-password", - "task_id": response.TaskId, - "success": response.Success, - "status": response.Status, - } - - // 如果有错误信息,则添加到结果中 - if response.Error != "" { - result["error"] = response.Error - } - - // 将结果序列化为 JSON 格式 - resultJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %w", err) - } - - // 返回序列化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回重置虚拟机密码工具的名称 -func (c *CloudpodsServerResetPasswordTool) GetName() string { - return "cloudpods_reset_server_password" -} - -// CloudpodsServerDeleteTool 用于删除指定的Cloudpods虚拟机实例 -type CloudpodsServerDeleteTool struct { - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerDeleteTool 创建一个新的CloudpodsServerDeleteTool实例 -func NewCloudpodsServerDeleteTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerDeleteTool { - return &CloudpodsServerDeleteTool{ - adapter: adapter, - } -} - -// GetTool 返回删除虚拟机工具的定义,包括参数和描述 -func (c *CloudpodsServerDeleteTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_delete_server", - mcp.WithDescription("删除指定的Cloudpods虚拟机实例。用户要求删除时必须调用本工具真正执行删除,仅调用 cloudpods_list_servers 查询不算完成。若尚不知 server_id,先用 cloudpods_list_servers 定位,拿到 id 后立即调用本工具。"), - mcp.WithString("server_id", mcp.Required(), mcp.Description("虚拟机ID,来自 cloudpods_list_servers 返回的 id 字段")), - mcp.WithString("override_pending_delete", mcp.Description("是否强制删除(包括在回收站中的实例),默认为false")), - mcp.WithString("purge", mcp.Description("是否仅删除本地资源,默认为false")), - mcp.WithString("delete_snapshots", mcp.Description("是否删除快照,默认为false")), - mcp.WithString("delete_eip", mcp.Description("是否删除关联的EIP,默认为false")), - mcp.WithString("delete_disks", mcp.Description("是否删除关联的数据盘,默认为false")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理删除虚拟机的请求,调用适配器执行删除操作并返回结果 -func (c *CloudpodsServerDeleteTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 从请求中获取必需的 server_id 参数 - serverID, err := req.RequireString("server_id") - if err != nil { - return nil, err - } - - // 解析 override_pending_delete 参数,决定是否强制删除(包括在回收站中的实例) - overridePendingDelete := false - if overrideStr := req.GetString("override_pending_delete", "false"); overrideStr == "true" { - overridePendingDelete = true - } - - // 解析 purge 参数,决定是否仅删除本地资源 - purge := false - if purgeStr := req.GetString("purge", "false"); purgeStr == "true" { - purge = true - } - - // 解析 delete_snapshots 参数,决定是否删除快照 - deleteSnapshots := false - if deleteSnapshotsStr := req.GetString("delete_snapshots", "false"); deleteSnapshotsStr == "true" { - deleteSnapshots = true - } - - // 解析 delete_eip 参数,决定是否删除关联的EIP - deleteEip := false - if deleteEipStr := req.GetString("delete_eip", "false"); deleteEipStr == "true" { - deleteEip = true - } - - // 解析 delete_disks 参数,决定是否删除关联的数据盘 - deleteDisks := false - if deleteDisksStr := req.GetString("delete_disks", "false"); deleteDisksStr == "true" { - deleteDisks = true - } - - // 构造删除虚拟机的请求参数 - deleteReq := models.ServerDeleteRequest{ - OverridePendingDelete: overridePendingDelete, - Purge: purge, - DeleteSnapshots: deleteSnapshots, - DeleteEip: deleteEip, - DeleteDisks: deleteDisks, - } - - // 获取认证所需的 access key 和 secret key - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器的 DeleteServer 方法执行删除操作 - response, err := c.adapter.DeleteServer(ctx, serverID, deleteReq, ak, sk) - if err != nil { - log.Errorf("Fail to delete server: %s", err) - return nil, fmt.Errorf("fail to delete server: %w", err) - } - - // 构造返回结果,包含任务ID、成功状态和状态信息 - result := map[string]interface{}{ - "server_id": serverID, - "operation": "delete", - "task_id": response.TaskId, - "success": response.Success, - "status": response.Status, - } - - // 如果有错误信息,则添加到结果中 - if response.Error != "" { - result["error"] = response.Error - } - - // 将结果序列化为 JSON 格式 - resultJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %w", err) - } - - // 返回序列化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回删除虚拟机工具的名称 -func (c *CloudpodsServerDeleteTool) GetName() string { - return "cloudpods_delete_server" -} diff --git a/pkg/mcp-server/tools/cloudpods_servers_tool.go b/pkg/mcp-server/tools/cloudpods_servers_tool.go deleted file mode 100644 index 469b916904..0000000000 --- a/pkg/mcp-server/tools/cloudpods_servers_tool.go +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsServersTool 是用于查询 Cloudpods 虚拟机实例列表的工具 -type CloudpodsServersTool struct { - // adapter 用于与 Cloudpods API 进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServersTool 创建一个新的 Cloudpods 虚拟机查询工具 -// adapter: 用于与Cloudpods API交互的适配器 -// 返回值: CloudpodsServersTool实例指针 -func NewCloudpodsServersTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServersTool { - return &CloudpodsServersTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回查询虚拟机实例列表工具的元数据 -// 该工具用于查询Cloudpods虚拟机实例列表,获取虚拟机信息 -// limit: 返回结果数量限制,默认为50 -// offset: 结果偏移量,默认为0 -// search: 按名称或ID模糊搜索 -// status: 虚拟机状态,例如:running、stopped、creating等 -// ak: 用户登录cloudpods后获取的access key -// sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsServersTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_list_servers", - mcp.WithDescription("查询Cloudpods虚拟机实例列表,获取虚拟机信息(含id、name、status)。当用户要求启动/停止/重启/删除/重置密码时:先用本工具定位目标虚拟机,拿到返回结果中的id后,必须立刻继续调用对应操作工具(cloudpods_start_server / cloudpods_stop_server / cloudpods_restart_server / cloudpods_delete_server / cloudpods_reset_server_password)完成操作,不要只查询就结束,也不要再次向用户确认(除非匹配到多台需用户选择)。"), - mcp.WithString("limit", mcp.Description("返回结果数量限制,默认为50")), - mcp.WithString("offset", mcp.Description("结果偏移量,默认为0")), - mcp.WithString("search", mcp.Description("按名称或ID模糊搜索")), - mcp.WithString("status", mcp.Description("虚拟机状态,例如:running、stopped、creating等")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理查询 Cloudpods 虚拟机实例列表的请求 -// ctx: 控制生命周期的上下文 -// req: 包含查询参数的请求对象 -// 返回值: 包含虚拟机列表的响应对象和可能的错误 -func (c *CloudpodsServersTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 获取可选参数:返回结果数量限制,如果指定则转换为整数 - limit := 50 - if limitStr := req.GetString("limit", ""); limitStr != "" { - if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - limit = parsedLimit - } - } - - // 获取可选参数:结果偏移量,如果指定则转换为整数 - offset := 0 - if offsetStr := req.GetString("offset", ""); offsetStr != "" { - if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } - - // 获取可选参数:搜索关键词和虚拟机状态 - search := req.GetString("search", "") - status := req.GetString("status", "") - - // 获取可选参数:访问凭证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器查询虚拟机列表 - serversResponse, err := c.adapter.ListServers(ctx, limit, offset, search, status, ak, sk) - if err != nil { - log.Errorf("Fail to query server: %s", err) - return nil, fmt.Errorf("fail to query server: %w", err) - } - - // 格式化查询结果 - formattedResult := c.formatServersResult(serversResponse, limit, offset, search, status) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - // 返回格式化后的结果 - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// 返回值: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsServersTool) GetName() string { - return "cloudpods_list_servers" -} - -// formatServersResult 格式化虚拟机实例列表查询结果 -// response: 原始虚拟机列表响应数据 -// limit: 查询限制数量 -// offset: 查询偏移量 -// search: 搜索关键词 -// status: 虚拟机状态 -// 返回值: 包含虚拟机列表的格式化结果 -func (c *CloudpodsServersTool) formatServersResult(response *models.ServerListResponse, limit int, offset int, search string, status string) map[string]interface{} { - // 初始化格式化结果结构 - formatted := map[string]interface{}{ - // 添加查询信息 - "query_info": map[string]interface{}{ - "limit": limit, - "offset": offset, - "search": search, - "status": status, - "total": response.Total, - "count": len(response.Servers), - }, - // 初始化虚拟机列表 - "servers": make([]map[string]interface{}, 0, len(response.Servers)), - } - - // 遍历虚拟机列表,构造每个虚拟机的详细信息 - for _, server := range response.Servers { - // 将内存大小从MB转换为GB - memoryGB := float64(server.VmemSize) / 1024 - - // 构造虚拟机信息 - serverInfo := map[string]interface{}{ - "id": server.Id, - "name": server.Name, - "status": server.Status, - "vcpu_count": server.VcpuCount, - "vmem_size": server.VmemSize, - "memory_gb": fmt.Sprintf("%.1f GB", memoryGB), - "os_name": server.OsName, - "ips": server.Ips, - "host": server.Host, - "zone": server.Zone, - "region": server.Cloudregion, - "created_at": server.CreatedAt, - } - formatted["servers"] = append(formatted["servers"].([]map[string]interface{}), serverInfo) - } - - // 构造摘要信息 - formatted["summary"] = map[string]interface{}{ - "total_servers": response.Total, - "returned_count": len(response.Servers), - } - - return formatted -} diff --git a/pkg/mcp-server/tools/cloudpods_serverskus_tool.go b/pkg/mcp-server/tools/cloudpods_serverskus_tool.go deleted file mode 100644 index b347f99e45..0000000000 --- a/pkg/mcp-server/tools/cloudpods_serverskus_tool.go +++ /dev/null @@ -1,310 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsServerSkusTool 用于查询Cloudpods主机套餐规格列表的工具 -type CloudpodsServerSkusTool struct { - // adapter 用于与Cloudpods API进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsServerSkusTool 创建一个新的CloudpodsServerSkusTool实例 -// -// 参数: -// - adapter: 用于与Cloudpods API交互的适配器 -// -// 返回值: -// - *CloudpodsServerSkusTool: CloudpodsServerSkusTool实例指针 -func NewCloudpodsServerSkusTool(adapter *adapters.CloudpodsAdapter) *CloudpodsServerSkusTool { - return &CloudpodsServerSkusTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回查询主机套餐规格列表工具的元数据 -// -// 工具用途: -// -// 查询Cloudpods主机套餐规格列表,获取虚拟机规格信息 -// -// 参数说明: -// - limit: 返回结果数量限制,默认为20 -// - offset: 返回结果偏移量,默认为0 -// - search: 搜索关键词,可以按规格名称搜索 -// - cloudregion_ids: 云区域ID,多个用逗号分隔 -// - zone_ids: 可用区ID,多个用逗号分隔 -// - cpu_core_count: CPU核心数,多个用逗号分隔,如:1,2,4,8 -// - memory_size_mb: 内存大小MB,多个用逗号分隔,如:1024,2048,4096 -// - providers: 云平台提供商,多个用逗号分隔,如:OneCloud,Aliyun,Huawei -// - cpu_arch: CPU架构,多个用逗号分隔,如:x86,arm -// - ak: 用户登录cloudpods后获取的access key -// - sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsServerSkusTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_list_serverskus", - mcp.WithDescription("查询Cloudpods主机套餐规格列表,获取虚拟机规格信息"), - mcp.WithString("limit", mcp.Description("返回结果数量限制,默认为20")), - mcp.WithString("offset", mcp.Description("返回结果偏移量,默认为0")), - mcp.WithString("search", mcp.Description("搜索关键词,可以按规格名称搜索")), - mcp.WithString("cloudregion_ids", mcp.Description("云区域ID,多个用逗号分隔")), - mcp.WithString("zone_ids", mcp.Description("可用区ID,多个用逗号分隔")), - mcp.WithString("cpu_core_count", mcp.Description("CPU核心数,多个用逗号分隔,如:1,2,4,8")), - mcp.WithString("memory_size_mb", mcp.Description("内存大小MB,多个用逗号分隔,如:1024,2048,4096")), - mcp.WithString("providers", mcp.Description("云平台提供商,多个用逗号分隔,如:OneCloud,Aliyun,Huawei")), - mcp.WithString("cpu_arch", mcp.Description("CPU架构,多个用逗号分隔,如:x86,arm")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理查询主机套餐规格列表的请求 -// -// 参数: -// - ctx: 控制生命周期的上下文 -// - req: 包含查询参数的请求对象 -// -// 返回值: -// - *mcp.CallToolResult: 包含主机套餐规格列表的响应对象 -// - error: 可能的错误信息 -func (c *CloudpodsServerSkusTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 获取可选参数:返回结果数量限制,如果指定则转换为整数 - limit := 20 - if limitStr := req.GetString("limit", ""); limitStr != "" { - if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - limit = parsedLimit - } - } - - // 获取可选参数:结果偏移量,如果指定则转换为整数 - offset := 0 - if offsetStr := req.GetString("offset", ""); offsetStr != "" { - if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } - - // 获取可选参数:搜索关键词 - search := req.GetString("search", "") - - // 获取可选参数:云区域ID列表 - var cloudregionIds []string - if cloudregionIdsStr := req.GetString("cloudregion_ids", ""); cloudregionIdsStr != "" { - cloudregionIds = strings.Split(cloudregionIdsStr, ",") - for i, id := range cloudregionIds { - cloudregionIds[i] = strings.TrimSpace(id) - } - } - - // 获取可选参数:可用区ID列表 - var zoneIds []string - if zoneIdsStr := req.GetString("zone_ids", ""); zoneIdsStr != "" { - zoneIds = strings.Split(zoneIdsStr, ",") - for i, id := range zoneIds { - zoneIds[i] = strings.TrimSpace(id) - } - } - - // 获取可选参数:CPU核心数列表 - var cpuCoreCount []string - if cpuCoreCountStr := req.GetString("cpu_core_count", ""); cpuCoreCountStr != "" { - cpuCoreCount = strings.Split(cpuCoreCountStr, ",") - for i, count := range cpuCoreCount { - cpuCoreCount[i] = strings.TrimSpace(count) - } - } - - // 获取可选参数:内存大小列表(MB) - var memorySizeMB []string - if memorySizeMBStr := req.GetString("memory_size_mb", ""); memorySizeMBStr != "" { - memorySizeMB = strings.Split(memorySizeMBStr, ",") - for i, size := range memorySizeMB { - memorySizeMB[i] = strings.TrimSpace(size) - } - } - - // 获取可选参数:云平台提供商列表 - var providers []string - if providersStr := req.GetString("providers", ""); providersStr != "" { - providers = strings.Split(providersStr, ",") - for i, provider := range providers { - providers[i] = strings.TrimSpace(provider) - } - } - - // 获取可选参数:CPU架构列表 - var cpuArch []string - if cpuArchStr := req.GetString("cpu_arch", ""); cpuArchStr != "" { - cpuArch = strings.Split(cpuArchStr, ",") - for i, arch := range cpuArch { - cpuArch[i] = strings.TrimSpace(arch) - } - } - - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器查询主机套餐规格列表 - skusResponse, err := c.adapter.ListServerSkus(ctx, limit, offset, search, cloudregionIds, zoneIds, cpuCoreCount, memorySizeMB, providers, cpuArch, ak, sk) - if err != nil { - log.Errorf("Fail to query server skus: %s", err) - return nil, fmt.Errorf("fail to query server skus: %w", err) - } - - // 格式化查询结果 - formattedResult := c.formatServerSkusResult(skusResponse, limit, offset, search, cloudregionIds, zoneIds, cpuCoreCount, memorySizeMB, providers, cpuArch) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// -// 返回值: -// - string: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsServerSkusTool) GetName() string { - return "cloudpods_list_serverskus" -} - -// formatServerSkusResult 格式化主机套餐规格列表的响应结果 -// -// 参数: -// - response: 原始主机套餐规格列表响应数据 -// - limit: 查询限制数量 -// - offset: 查询偏移量 -// - search: 搜索关键词 -// - cloudregionIds: 云区域ID列表 -// - zoneIds: 可用区ID列表 -// - cpuCoreCount: CPU核心数列表 -// - memorySizeMB: 内存大小列表(MB) -// - providers: 云平台提供商列表 -// - cpuArch: CPU架构列表 -// -// 返回值: -// - map[string]interface{}: 包含主机套餐规格列表的格式化结果 -func (c *CloudpodsServerSkusTool) formatServerSkusResult( - response *models.ServerSkuListResponse, - limit, offset int, - search string, - cloudregionIds, zoneIds, cpuCoreCount, memorySizeMB, providers, cpuArch []string, -) map[string]interface{} { - // 初始化格式化结果结构 - formatted := map[string]interface{}{ - "query_info": map[string]interface{}{ - "limit": limit, - "offset": offset, - "search": search, - "cloudregion_ids": cloudregionIds, - "zone_ids": zoneIds, - "cpu_core_count": cpuCoreCount, - "memory_size_mb": memorySizeMB, - "providers": providers, - "cpu_arch": cpuArch, - "total": response.Total, - "count": len(response.Serverskus), - }, - "serverskus": make([]map[string]interface{}, 0, len(response.Serverskus)), - } - - // 遍历主机套餐列表,构造每个主机套餐的详细信息 - for _, sku := range response.Serverskus { - skuInfo := map[string]interface{}{ - "id": sku.Id, - "name": sku.Name, - "description": sku.Description, - "status": sku.Status, - "enabled": sku.Enabled, - "provider": sku.Provider, - "cloud_env": sku.CloudEnv, - "cloudregion": sku.Cloudregion, - "cloudregion_id": sku.CloudregionId, - "zone": sku.Zone, - "zone_id": sku.ZoneId, - "zone_ext_id": sku.ZoneExtId, - "cpu_core_count": sku.CpuCoreCount, - "memory_size_mb": sku.MemorySizeMB, - "cpu_arch": sku.CpuArch, - "instance_type_family": sku.InstanceTypeFamily, - "instance_type_category": sku.InstanceTypeCategory, - "local_category": sku.LocalCategory, - "sys_disk_type": sku.SysDiskType, - "sys_disk_min_size_gb": sku.SysDiskMinSizeGB, - "sys_disk_max_size_gb": sku.SysDiskMaxSizeGB, - "sys_disk_resizable": sku.SysDiskResizable, - "data_disk_types": sku.DataDiskTypes, - "data_disk_max_count": sku.DataDiskMaxCount, - "attached_disk_count": sku.AttachedDiskCount, - "attached_disk_size_gb": sku.AttachedDiskSizeGB, - "attached_disk_type": sku.AttachedDiskType, - "nic_type": sku.NicType, - "nic_max_count": sku.NicMaxCount, - "gpu_attachable": sku.GpuAttachable, - "gpu_count": sku.GpuCount, - "gpu_max_count": sku.GpuMaxCount, - "gpu_spec": sku.GpuSpec, - "os_name": sku.OsName, - "postpaid_status": sku.PostpaidStatus, - "prepaid_status": sku.PrepaidStatus, - "total_guest_count": sku.TotalGuestCount, - "external_id": sku.ExternalId, - "source": sku.Source, - "is_emulated": sku.IsEmulated, - "region": sku.Region, - "region_id": sku.RegionId, - "region_ext_id": sku.RegionExtId, - "region_external_id": sku.RegionExternalId, - "md5": sku.Md5, - "metadata": sku.Metadata, - "progress": sku.Progress, - "can_delete": sku.CanDelete, - "can_update": sku.CanUpdate, - "update_version": sku.UpdateVersion, - "created_at": sku.CreatedAt, - "updated_at": sku.UpdatedAt, - "imported_at": sku.ImportedAt, - } - formatted["serverskus"] = append(formatted["serverskus"].([]map[string]interface{}), skuInfo) - } - - // 构造摘要信息 - formatted["summary"] = map[string]interface{}{ - "total_serverskus": response.Total, - "returned_count": len(response.Serverskus), - "has_more": response.Total > int64(offset+len(response.Serverskus)), - "next_offset": offset + len(response.Serverskus), - } - - return formatted -} diff --git a/pkg/mcp-server/tools/cloudpods_storages_tool.go b/pkg/mcp-server/tools/cloudpods_storages_tool.go deleted file mode 100644 index 30ffeda77e..0000000000 --- a/pkg/mcp-server/tools/cloudpods_storages_tool.go +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsStoragesTool 用于查询Cloudpods块存储列表的工具 -type CloudpodsStoragesTool struct { - // adapter 用于与Cloudpods API进行交互 - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsStoragesTool 创建一个新的CloudpodsStoragesTool实例 -// -// 参数: -// - adapter: 用于与Cloudpods API交互的适配器 -// -// 返回值: -// - *CloudpodsStoragesTool: CloudpodsStoragesTool实例指针 -func NewCloudpodsStoragesTool(adapter *adapters.CloudpodsAdapter) *CloudpodsStoragesTool { - return &CloudpodsStoragesTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回查询块存储列表工具的元数据 -// -// 工具用途: -// -// 查询Cloudpods块存储列表,获取存储资源信息 -// -// 参数说明: -// - limit: 返回结果数量限制,默认为20 -// - offset: 返回结果偏移量,默认为0 -// - search: 搜索关键词,可以按存储名称搜索 -// - cloudregion_ids: 云区域ID,多个用逗号分隔 -// - zone_ids: 可用区ID,多个用逗号分隔 -// - providers: 云平台提供商,多个用逗号分隔,如:OneCloud,Aliyun,Huawei -// - storage_types: 存储类型,多个用逗号分隔,如:local,rbd,nfs,cephfs -// - host_id: 主机ID,过滤关联指定主机的存储 -// - ak: 用户登录cloudpods后获取的access key -// - sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsStoragesTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_list_storages", - mcp.WithDescription("查询Cloudpods块存储列表,获取存储资源信息"), - mcp.WithString("limit", mcp.Description("返回结果数量限制,默认为20")), - mcp.WithString("offset", mcp.Description("返回结果偏移量,默认为0")), - mcp.WithString("search", mcp.Description("搜索关键词,可以按存储名称搜索")), - mcp.WithString("cloudregion_ids", mcp.Description("云区域ID,多个用逗号分隔")), - mcp.WithString("zone_ids", mcp.Description("可用区ID,多个用逗号分隔")), - mcp.WithString("providers", mcp.Description("云平台提供商,多个用逗号分隔,如:OneCloud,Aliyun,Huawei")), - mcp.WithString("storage_types", mcp.Description("存储类型,多个用逗号分隔,如:local,rbd,nfs,cephfs")), - mcp.WithString("host_id", mcp.Description("主机ID,过滤关联指定主机的存储")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理查询块存储列表的请求 -// -// 参数: -// - ctx: 控制生命周期的上下文 -// - req: 包含查询参数的请求对象 -// -// 返回值: -// - *mcp.CallToolResult: 包含块存储列表的响应对象 -// - error: 可能的错误信息 -func (c *CloudpodsStoragesTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 获取可选参数:返回结果数量限制,如果指定则转换为整数 - limit := 20 - if limitStr := req.GetString("limit", ""); limitStr != "" { - if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - limit = parsedLimit - } - } - - // 获取可选参数:结果偏移量,如果指定则转换为整数 - offset := 0 - if offsetStr := req.GetString("offset", ""); offsetStr != "" { - if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } - - // 获取可选参数:搜索关键词 - search := req.GetString("search", "") - - // 获取可选参数:云区域ID列表 - var cloudregionIds []string - if cloudregionIdsStr := req.GetString("cloudregion_ids", ""); cloudregionIdsStr != "" { - cloudregionIds = strings.Split(cloudregionIdsStr, ",") - for i, id := range cloudregionIds { - cloudregionIds[i] = strings.TrimSpace(id) - } - } - - // 获取可选参数:可用区ID列表 - var zoneIds []string - if zoneIdsStr := req.GetString("zone_ids", ""); zoneIdsStr != "" { - zoneIds = strings.Split(zoneIdsStr, ",") - for i, id := range zoneIds { - zoneIds[i] = strings.TrimSpace(id) - } - } - - // 获取可选参数:云平台提供商列表 - var providers []string - if providersStr := req.GetString("providers", ""); providersStr != "" { - providers = strings.Split(providersStr, ",") - for i, provider := range providers { - providers[i] = strings.TrimSpace(provider) - } - } - - // 获取可选参数:存储类型列表 - var storageTypes []string - if storageTypesStr := req.GetString("storage_types", ""); storageTypesStr != "" { - storageTypes = strings.Split(storageTypesStr, ",") - for i, storageType := range storageTypes { - storageTypes[i] = strings.TrimSpace(storageType) - } - } - - // 获取可选参数:主机ID - hostId := req.GetString("host_id", "") - - // 获取可选参数:访问凭证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器查询块存储列表 - storagesResponse, err := c.adapter.ListStorages(ctx, limit, offset, search, cloudregionIds, zoneIds, providers, storageTypes, hostId, ak, sk) - if err != nil { - log.Errorf("Fail to query storage: %s", err) - return nil, fmt.Errorf("fail to query storage: %w", err) - } - - // 格式化查询结果 - formattedResult := c.formatStoragesResult(storagesResponse, limit, offset, search, cloudregionIds, zoneIds, providers, storageTypes, hostId) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// -// 返回值: -// - string: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsStoragesTool) GetName() string { - return "cloudpods_list_storages" -} - -// formatStoragesResult 格式化块存储列表的响应结果 -// -// 参数: -// - response: 原始响应数据 -// - limit: 查询限制 -// - offset: 查询偏移量 -// - search: 搜索关键词 -// - cloudregionIds: 云区域ID列表 -// - zoneIds: 可用区ID列表 -// - providers: 云平台提供商列表 -// - storageTypes: 存储类型列表 -// - hostId: 主机ID -// -// 返回值: -// - map[string]interface{}: 包含块存储列表的格式化结果 -func (c *CloudpodsStoragesTool) formatStoragesResult( - response *models.StorageListResponse, - limit, offset int, - search string, - cloudregionIds, zoneIds, providers, storageTypes []string, - hostId string, -) map[string]interface{} { - // 初始化格式化结果结构 - formatted := map[string]interface{}{ - "query_info": map[string]interface{}{ - "limit": limit, - "offset": offset, - "search": search, - "cloudregion_ids": cloudregionIds, - "zone_ids": zoneIds, - "providers": providers, - "storage_types": storageTypes, - "host_id": hostId, - "total": response.Total, - "count": len(response.Storages), - }, - "storages": make([]map[string]interface{}, 0, len(response.Storages)), - } - - // 遍历块存储列表,构造每个块存储的详细信息 - for _, storage := range response.Storages { - capacityGB := float64(storage.Capacity) / 1024 - usedCapacityGB := float64(storage.UsedCapacity) / 1024 - freeCapacityGB := float64(storage.FreeCapacity) / 1024 - actualUsedGB := float64(storage.ActualCapacityUsed) / 1024 - - storageInfo := map[string]interface{}{ - "id": storage.Id, - "name": storage.Name, - "description": storage.Description, - "status": storage.Status, - "enabled": storage.Enabled, - "storage_type": storage.StorageType, - "medium_type": storage.MediumType, - "provider": storage.Provider, - "brand": storage.Brand, - "cloud_env": storage.CloudEnv, - "cloudregion": storage.Cloudregion, - "cloudregion_id": storage.CloudregionId, - "zone": storage.Zone, - "zone_id": storage.ZoneId, - "zone_ext_id": storage.ZoneExtId, - "capacity_mb": storage.Capacity, - "capacity_gb": fmt.Sprintf("%.2f GB", capacityGB), - "used_capacity_mb": storage.UsedCapacity, - "used_capacity_gb": fmt.Sprintf("%.2f GB", usedCapacityGB), - "free_capacity_mb": storage.FreeCapacity, - "free_capacity_gb": fmt.Sprintf("%.2f GB", freeCapacityGB), - "actual_capacity_used": storage.ActualCapacityUsed, - "actual_used_gb": fmt.Sprintf("%.2f GB", actualUsedGB), - "virtual_capacity": storage.VirtualCapacity, - "waste_capacity": storage.WasteCapacity, - "reserved": storage.Reserved, - "commit_bound": storage.CommitBound, - "commit_rate": storage.CommitRate, - "cmtbound": storage.Cmtbound, - "is_sys_disk_store": storage.IsSysDiskStore, - "is_public": storage.IsPublic, - "is_emulated": storage.IsEmulated, - "disk_count": storage.DiskCount, - "host_count": storage.HostCount, - "snapshot_count": storage.SnapshotCount, - "master_host": storage.MasterHost, - "master_host_name": storage.MasterHostName, - "storagecache_id": storage.StoragecacheId, - "account": storage.Account, - "account_id": storage.AccountId, - "account_status": storage.AccountStatus, - "account_health_status": storage.AccountHealthStatus, - "account_read_only": storage.AccountReadOnly, - "manager": storage.Manager, - "manager_id": storage.ManagerId, - "manager_domain": storage.ManagerDomain, - "manager_domain_id": storage.ManagerDomainId, - "manager_project": storage.ManagerProject, - "manager_project_id": storage.ManagerProjectId, - "external_id": storage.ExternalId, - "source": storage.Source, - "region": storage.Region, - "region_id": storage.RegionId, - "region_ext_id": storage.RegionExtId, - "region_external_id": storage.RegionExternalId, - "environment": storage.Environment, - "domain_id": storage.DomainId, - "domain_src": storage.DomainSrc, - "project_domain": storage.ProjectDomain, - "public_scope": storage.PublicScope, - "public_src": storage.PublicSrc, - "shared_domains": storage.SharedDomains, - "shared_projects": storage.SharedProjects, - "schedtags": storage.Schedtags, - "hosts": storage.Hosts, - "storage_conf": storage.StorageConf, - "metadata": storage.Metadata, - "progress": storage.Progress, - "can_delete": storage.CanDelete, - "can_update": storage.CanUpdate, - "update_version": storage.UpdateVersion, - "created_at": storage.CreatedAt, - "updated_at": storage.UpdatedAt, - "imported_at": storage.ImportedAt, - } - formatted["storages"] = append(formatted["storages"].([]map[string]interface{}), storageInfo) - } - - // 构造摘要信息 - formatted["summary"] = map[string]interface{}{ - "total_storages": response.Total, // 总存储数量 - "returned_count": len(response.Storages), // 当前返回的存储数量 - "has_more": response.Total > int64(offset+len(response.Storages)), // 是否还有更多数据 - "next_offset": offset + len(response.Storages), // 下一页的偏移量 - } - - return formatted -} diff --git a/pkg/mcp-server/tools/cloudpods_vpcs_tool.go b/pkg/mcp-server/tools/cloudpods_vpcs_tool.go deleted file mode 100644 index 45e8370064..0000000000 --- a/pkg/mcp-server/tools/cloudpods_vpcs_tool.go +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright 2019 Yunion -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - - "github.com/mark3labs/mcp-go/mcp" - - "yunion.io/x/log" - - "yunion.io/x/onecloud/pkg/mcp-server/adapters" - "yunion.io/x/onecloud/pkg/mcp-server/models" -) - -// CloudpodsVPCsTool 用于查询Cloudpods VPC列表的工具 -// -// 字段: -// - adapter: 用于与Cloudpods API进行交互的适配器 -type CloudpodsVPCsTool struct { - adapter *adapters.CloudpodsAdapter -} - -// NewCloudpodsVPCsTool 创建CloudpodsVPCsTool实例 -// -// 参数: -// - adapter: 用于与Cloudpods API交互的适配器 -// -// 返回值: -// - *CloudpodsVPCsTool: CloudpodsVPCsTool实例指针 -func NewCloudpodsVPCsTool(adapter *adapters.CloudpodsAdapter) *CloudpodsVPCsTool { - return &CloudpodsVPCsTool{ - adapter: adapter, - } -} - -// GetTool 定义并返回查询VPC列表工具的元数据 -// -// 工具用途: -// -// 查询Cloudpods VPC列表,获取虚拟私有网络信息 -// -// 参数说明: -// - limit: 返回结果数量限制,默认为20 -// - offset: 返回结果偏移量,默认为0 -// - search: 搜索关键词,可以按VPC名称搜索 -// - cloudregion_id: 过滤指定云区域的VPC资源 -// - ak: 用户登录cloudpods后获取的access key -// - sk: 用户登录cloudpods后获取的secret key -func (c *CloudpodsVPCsTool) GetTool() mcp.Tool { - return mcp.NewTool( - "cloudpods_list_vpcs", - mcp.WithDescription("查询Cloudpods VPC列表,获取虚拟私有网络信息"), - mcp.WithString("limit", mcp.Description("返回结果数量限制,默认为20")), - mcp.WithString("offset", mcp.Description("返回结果偏移量,默认为0")), - mcp.WithString("search", mcp.Description("搜索关键词,可以按VPC名称搜索")), - mcp.WithString("cloudregion_id", mcp.Description("过滤指定云区域的VPC资源")), - mcp.WithString("ak", mcp.Description("用户登录cloudpods后获取的access key")), - mcp.WithString("sk", mcp.Description("用户登录cloudpods后获取的secret key")), - ) -} - -// Handle 处理查询VPC列表的请求 -// -// 参数: -// - ctx: 控制生命周期的上下文 -// - req: 包含查询参数的请求对象 -// -// 返回值: -// - *mcp.CallToolResult: 包含VPC列表的响应对象 -// - error: 可能的错误信息 -func (c *CloudpodsVPCsTool) Handle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 获取可选参数:返回结果数量限制,如果指定则转换为整数 - limit := 20 - if limitStr := req.GetString("limit", ""); limitStr != "" { - if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - limit = parsedLimit - } - } - - // 获取可选参数:结果偏移量,如果指定则转换为整数 - offset := 0 - if offsetStr := req.GetString("offset", ""); offsetStr != "" { - if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } - - // 获取可选参数:搜索关键词 - search := req.GetString("search", "") - // 获取可选参数:云区域ID - cloudRegionID := req.GetString("cloudregion_id", "") - - // 获取可选参数:访问凭证 - ak := req.GetString("ak", "") - sk := req.GetString("sk", "") - - // 调用适配器查询VPC列表 - vpcsResponse, err := c.adapter.ListVPCs(ctx, limit, offset, search, cloudRegionID, ak, sk) - if err != nil { - log.Errorf("Fail to query vpc: %s", err) - return nil, fmt.Errorf("fail to query vpc: %w", err) - } - - // 格式化查询结果 - formattedResult := c.formatVPCsResult(vpcsResponse, limit, offset, search, cloudRegionID) - - // 将结果序列化为JSON格式 - resultJSON, err := json.MarshalIndent(formattedResult, "", " ") - if err != nil { - log.Errorf("Fail to serialize result: %s", err) - return nil, fmt.Errorf("fail to serialize result: %w", err) - } - - return mcp.NewToolResultText(string(resultJSON)), nil -} - -// GetName 返回工具的名称标识符 -// -// 返回值: -// - string: 工具名称字符串,用于唯一标识该工具 -func (c *CloudpodsVPCsTool) GetName() string { - return "cloudpods_list_vpcs" -} - -// formatVPCsResult 格式化VPC列表的响应结果 -// -// 参数: -// - response: 原始响应数据 -// - limit: 查询限制 -// - offset: 查询偏移量 -// - search: 搜索关键词 -// - cloudRegionID: 云区域ID -// -// 返回值: -// - map[string]interface{}: 包含VPC列表的格式化结果 -func (c *CloudpodsVPCsTool) formatVPCsResult(response *models.VpcListResponse, limit, offset int, search, cloudRegionID string) map[string]interface{} { - // 初始化格式化结果结构 - formatted := map[string]interface{}{ - "query_info": map[string]interface{}{ - "limit": limit, - "offset": offset, - "search": search, - "cloudregion_id": cloudRegionID, - "total": response.Total, - "count": len(response.Vpcs), - }, - "vpcs": make([]map[string]interface{}, 0, len(response.Vpcs)), - } - - // 遍历VPC列表,构造每个VPC的详细信息 - for _, vpc := range response.Vpcs { - vpcInfo := map[string]interface{}{ - "id": vpc.Id, - "name": vpc.Name, - "description": vpc.Description, - "cidr_block": vpc.CidrBlock, - "cidr_block6": vpc.CidrBlock6, - "status": vpc.Status, - "enabled": vpc.Enabled, - "is_default": vpc.IsDefault, - "is_public": vpc.IsPublic, - "provider": vpc.Provider, - "brand": vpc.Brand, - "cloud_env": vpc.CloudEnv, - "environment": vpc.Environment, - "cloudregion": vpc.Cloudregion, - "cloudregion_id": vpc.CloudregionId, - "region": vpc.Region, - "region_id": vpc.RegionId, - "external_id": vpc.ExternalId, - "external_access_mode": vpc.ExternalAccessMode, - "globalvpc": vpc.Globalvpc, - "globalvpc_id": vpc.GlobalvpcId, - "account": vpc.Account, - "account_id": vpc.AccountId, - "account_status": vpc.AccountStatus, - "account_health_status": vpc.AccountHealthStatus, - "manager": vpc.Manager, - "manager_id": vpc.ManagerId, - "manager_domain": vpc.ManagerDomain, - "manager_domain_id": vpc.ManagerDomainId, - "manager_project": vpc.ManagerProject, - "manager_project_id": vpc.ManagerProjectId, - "network_count": vpc.NetworkCount, - "wire_count": vpc.WireCount, - "dns_zone_count": vpc.DnsZoneCount, - "natgateway_count": vpc.NatgatewayCount, - "routetable_count": vpc.RoutetableCount, - "accept_vpc_peer_count": vpc.AcceptVpcPeerCount, - "request_vpc_peer_count": vpc.RequestVpcPeerCount, - "direct": vpc.Direct, - "domain_id": vpc.DomainId, - "domain_src": vpc.DomainSrc, - "project_domain": vpc.ProjectDomain, - "public_scope": vpc.PublicScope, - "public_src": vpc.PublicSrc, - "region_ext_id": vpc.RegionExtId, - "region_external_id": vpc.RegionExternalId, - "source": vpc.Source, - "progress": vpc.Progress, - "shared_domains": vpc.SharedDomains, - "shared_projects": vpc.SharedProjects, - "can_delete": vpc.CanDelete, - "can_update": vpc.CanUpdate, - "is_emulated": vpc.IsEmulated, - "metadata": vpc.Metadata, - "created_at": vpc.CreatedAt, - "updated_at": vpc.UpdatedAt, - "imported_at": vpc.ImportedAt, - } - formatted["vpcs"] = append(formatted["vpcs"].([]map[string]interface{}), vpcInfo) - } - - // 构造摘要信息 - formatted["summary"] = map[string]interface{}{ - "total_vpcs": response.Total, - "returned_count": len(response.Vpcs), - "has_more": response.Total > int64(offset+len(response.Vpcs)), - "next_offset": offset + len(response.Vpcs), - } - - return formatted -} diff --git a/pkg/mcp-server/tools/doc.go b/pkg/mcp-server/tools/doc.go deleted file mode 100644 index 1b8062821d..0000000000 --- a/pkg/mcp-server/tools/doc.go +++ /dev/null @@ -1 +0,0 @@ -package tools // import "yunion.io/x/onecloud/pkg/mcp-server/tools"