This is an automated email from the ASF dual-hosted git repository.
robocanic pushed a commit to branch ai
in repository https://gitbox.apache.org/repos/asf/dubbo-admin.git
The following commit(s) were added to refs/heads/ai by this push:
new 2df3e076 feat: get_service_detail (#1475)
2df3e076 is described below
commit 2df3e07674c6cff6ec05fc78fd4563206efdc8e9
Author: hurukawa <[email protected]>
AuthorDate: Sat Jun 13 15:34:01 2026 +0800
feat: get_service_detail (#1475)
* feat: get_service_detail
* clear code
* feat: test
---
pkg/mcp/register.go | 30 +++++-
pkg/mcp/register_test.go | 57 ++++++++++
pkg/mcp/tools/detail_tools_test.go | 211 +++++++++++++++++++++++++++++++++++++
pkg/mcp/tools/search.go | 8 +-
pkg/mcp/tools/service.go | 44 ++++++--
pkg/mcp/transport/http/sse.go | 16 +--
6 files changed, 346 insertions(+), 20 deletions(-)
diff --git a/pkg/mcp/register.go b/pkg/mcp/register.go
index 641d567f..cec3acb4 100644
--- a/pkg/mcp/register.go
+++ b/pkg/mcp/register.go
@@ -100,7 +100,7 @@ func RegisterTools(server *Server) {
})
server.RegisterTool(&common.ToolDef{
- Name: "get_service_detail",
+ Name: "get_service_distribution",
Description: "获取服务详情,包括服务的提供者或消费者应用列表",
InputSchema: common.InputSchema{
Type: "object",
@@ -128,6 +128,34 @@ func RegisterTools(server *Server) {
},
},
},
+ Handler: tools.GetServiceDistribution,
+ })
+
+ server.RegisterTool(&common.ToolDef{
+ Name: "get_service_detail",
+ Description: "获取服务详情,包括语言和方法列表",
+ InputSchema: common.InputSchema{
+ Type: "object",
+ Required: []string{"serviceName"},
+ Properties: map[string]common.PropertyDef{
+ "serviceName": {
+ Type: "string",
+ Description: "服务名称",
+ },
+ "group": {
+ Type: "string",
+ Description: "服务分组",
+ },
+ "version": {
+ Type: "string",
+ Description: "服务版本",
+ },
+ "mesh": {
+ Type: "string",
+ Description: "网格名称,默认使用第一个 discovery
配置的 id",
+ },
+ },
+ },
Handler: tools.GetServiceDetail,
})
diff --git a/pkg/mcp/register_test.go b/pkg/mcp/register_test.go
new file mode 100644
index 00000000..c9806f73
--- /dev/null
+++ b/pkg/mcp/register_test.go
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 mcp
+
+import "testing"
+
+func TestRegisterServiceDetailTools(t *testing.T) {
+ server := NewServer("test", "dev")
+ RegisterTools(server)
+
+ detail, ok := server.tools["get_service_detail"]
+ if !ok {
+ t.Fatal("Tool 'get_service_detail' not registered")
+ }
+ if detail.Handler == nil {
+ t.Fatal("Tool 'get_service_detail' handler is nil")
+ }
+ if len(detail.InputSchema.Required) != 1 ||
detail.InputSchema.Required[0] != "serviceName" {
+ t.Fatalf("Expected serviceName to be required, got %v",
detail.InputSchema.Required)
+ }
+ for _, prop := range []string{"serviceName", "version", "group",
"mesh"} {
+ if _, ok := detail.InputSchema.Properties[prop]; !ok {
+ t.Fatalf("get_service_detail missing property %q", prop)
+ }
+ }
+ if _, ok := detail.InputSchema.Properties["side"]; ok {
+ t.Fatal("get_service_detail should not expose side")
+ }
+
+ distribution, ok := server.tools["get_service_distribution"]
+ if !ok {
+ t.Fatal("Tool 'get_service_distribution' not registered")
+ }
+ if distribution.Handler == nil {
+ t.Fatal("Tool 'get_service_distribution' handler is nil")
+ }
+ for _, prop := range []string{"serviceName", "version", "group",
"side", "mesh"} {
+ if _, ok := distribution.InputSchema.Properties[prop]; !ok {
+ t.Fatalf("get_service_distribution missing property
%q", prop)
+ }
+ }
+}
diff --git a/pkg/mcp/tools/detail_tools_test.go
b/pkg/mcp/tools/detail_tools_test.go
new file mode 100644
index 00000000..8aeb1411
--- /dev/null
+++ b/pkg/mcp/tools/detail_tools_test.go
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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"
+ "testing"
+
+ meshapi "github.com/apache/dubbo-admin/api/mesh/v1alpha1"
+ "github.com/apache/dubbo-admin/pkg/config/app"
+ discoverycfg "github.com/apache/dubbo-admin/pkg/config/discovery"
+ enginecfg "github.com/apache/dubbo-admin/pkg/config/engine"
+ consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+ "github.com/apache/dubbo-admin/pkg/console/counter"
+ "github.com/apache/dubbo-admin/pkg/core/lock"
+ "github.com/apache/dubbo-admin/pkg/core/manager"
+ meshresource
"github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+ "github.com/apache/dubbo-admin/pkg/core/store/index"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestGetServiceDetailMissingServiceName(t *testing.T) {
+ result, err := GetServiceDetail(newToolTestContext(nil),
map[string]any{})
+ if err != nil {
+ t.Fatalf("GetServiceDetail returned unexpected error: %v", err)
+ }
+ if !result.IsError {
+ t.Fatal("Expected error result")
+ }
+ if got := result.Content[0].Text; got != "required parameter
'serviceName' is missing" {
+ t.Fatalf("Expected missing parameter error, got %q", got)
+ }
+}
+
+func TestGetServiceDetailSuccess(t *testing.T) {
+ const (
+ mesh = "mesh1"
+ serviceName = "org.apache.demo.DemoService"
+ version = "1.0.0"
+ group = "demo"
+ )
+ serviceKey := coremodel.BuildResourceKey(mesh,
meshresource.BuildServiceIdentityKey(serviceName, version, group))
+ resource := &meshresource.ServiceResource{
+ ObjectMeta: metav1.ObjectMeta{Name:
meshresource.BuildServiceIdentityKey(serviceName, version, group)},
+ Mesh: mesh,
+ Spec: &meshapi.Service{
+ Name: serviceName,
+ Version: version,
+ Group: group,
+ Language: "java",
+ Methods: []string{"sayHello"},
+ },
+ }
+ ctx :=
newToolTestContext(map[coremodel.ResourceKind]map[string]coremodel.Resource{
+ meshresource.ServiceKind: {
+ serviceKey: resource,
+ },
+ })
+
+ result, err := GetServiceDetail(ctx, map[string]any{
+ "serviceName": serviceName,
+ "version": version,
+ "group": group,
+ "mesh": mesh,
+ })
+ if err != nil {
+ t.Fatalf("GetServiceDetail returned unexpected error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("Expected success result, got %q",
result.Content[0].Text)
+ }
+
+ var payload struct {
+ Language string `json:"language"`
+ Methods []string `json:"methods"`
+ }
+ if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err
!= nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+ if payload.Language != "java" {
+ t.Fatalf("Expected language java, got %q", payload.Language)
+ }
+ if len(payload.Methods) != 1 || payload.Methods[0] != "sayHello" {
+ t.Fatalf("Expected methods [sayHello], got %v", payload.Methods)
+ }
+}
+
+func TestGetServiceDistributionSuccessWithEmptyDistribution(t *testing.T) {
+ result, err := GetServiceDistribution(newToolTestContext(nil),
map[string]any{"serviceName": "missing"})
+ if err != nil {
+ t.Fatalf("get_service_distribution handler returned unexpected
error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("Expected success result with empty distribution, got
%q", result.Content[0].Text)
+ }
+
+ var payload struct {
+ ServiceName string `json:"serviceName"`
+ Distribution []any `json:"distribution"`
+ TotalApps int `json:"totalApps"`
+ }
+ if err := json.Unmarshal([]byte(result.Content[0].Text), &payload); err
!= nil {
+ t.Fatalf("Failed to unmarshal result: %v", err)
+ }
+ if payload.ServiceName != "missing" {
+ t.Fatalf("Expected serviceName missing, got %q",
payload.ServiceName)
+ }
+ if len(payload.Distribution) != 0 || payload.TotalApps != 0 {
+ t.Fatalf("Expected empty distribution, got distribution=%v
totalApps=%d", payload.Distribution, payload.TotalApps)
+ }
+}
+
+func newToolTestContext(resources
map[coremodel.ResourceKind]map[string]coremodel.Resource) consolectx.Context {
+ return &toolTestContext{
+ config: app.AdminConfig{
+ Discovery: []*discoverycfg.Config{{ID: "mesh1"}},
+ Engine: &enginecfg.Config{Name: "engine1"},
+ },
+ resourceManager: &toolTestResourceManager{resources: resources},
+ }
+}
+
+type toolTestContext struct {
+ config app.AdminConfig
+ resourceManager manager.ResourceManager
+}
+
+func (c *toolTestContext) ResourceManager() manager.ResourceManager {
+ return c.resourceManager
+}
+
+func (c *toolTestContext) CounterManager() counter.CounterManager {
+ return nil
+}
+
+func (c *toolTestContext) Config() app.AdminConfig {
+ return c.config
+}
+
+func (c *toolTestContext) AppContext() context.Context {
+ return context.Background()
+}
+
+func (c *toolTestContext) LockManager() lock.Lock {
+ return nil
+}
+
+type toolTestResourceManager struct {
+ resources map[coremodel.ResourceKind]map[string]coremodel.Resource
+}
+
+func (m *toolTestResourceManager) GetByKey(rk coremodel.ResourceKind, key
string) (coremodel.Resource, bool, error) {
+ byKind := m.resources[rk]
+ if byKind == nil {
+ return nil, false, nil
+ }
+ resource, ok := byKind[key]
+ return resource, ok, nil
+}
+
+func (m *toolTestResourceManager) GetByKeys(rk coremodel.ResourceKind, keys
[]string) ([]coremodel.Resource, error) {
+ byKind := m.resources[rk]
+ result := make([]coremodel.Resource, 0, len(keys))
+ for _, key := range keys {
+ if resource, ok := byKind[key]; ok {
+ result = append(result, resource)
+ }
+ }
+ return result, nil
+}
+
+func (m *toolTestResourceManager) ListByIndexes(coremodel.ResourceKind,
[]index.IndexCondition) ([]coremodel.Resource, error) {
+ return nil, nil
+}
+
+func (m *toolTestResourceManager) PageListByIndexes(coremodel.ResourceKind,
[]index.IndexCondition, coremodel.PageReq)
(*coremodel.PageData[coremodel.Resource], error) {
+ return coremodel.NewPageData[coremodel.Resource](0, 0, 0, nil), nil
+}
+
+func (m *toolTestResourceManager) Add(coremodel.Resource) error {
+ return nil
+}
+
+func (m *toolTestResourceManager) Update(coremodel.Resource) error {
+ return nil
+}
+
+func (m *toolTestResourceManager) Upsert(coremodel.Resource) error {
+ return nil
+}
+
+func (m *toolTestResourceManager) DeleteByKey(coremodel.ResourceKind, string,
string) error {
+ return nil
+}
diff --git a/pkg/mcp/tools/search.go b/pkg/mcp/tools/search.go
index f8093945..2b4e2786 100644
--- a/pkg/mcp/tools/search.go
+++ b/pkg/mcp/tools/search.go
@@ -120,11 +120,11 @@ func (e *appNameSearchExecutor) execute(ctx
consolectx.Context, keyword, mesh st
func (e *appNameSearchExecutor) buildResult(pagedResult
*model.SearchPaginationResult, keyword string, pageSize, pageNumber int)
map[string]any {
apps := extractGlobalApplications(pagedResult)
return map[string]any{
- "keyword": keyword,
- "pageSize": pageSize,
- "pageNumber": pageNumber,
+ "keyword": keyword,
+ "pageSize": pageSize,
+ "pageNumber": pageNumber,
"applications": apps,
- "totalCount": len(apps),
+ "totalCount": len(apps),
}
}
diff --git a/pkg/mcp/tools/service.go b/pkg/mcp/tools/service.go
index 7bca8b22..354656eb 100644
--- a/pkg/mcp/tools/service.go
+++ b/pkg/mcp/tools/service.go
@@ -18,6 +18,8 @@
package tools
import (
+ "fmt"
+
consolectx "github.com/apache/dubbo-admin/pkg/console/context"
"github.com/apache/dubbo-admin/pkg/console/model"
"github.com/apache/dubbo-admin/pkg/console/service"
@@ -46,8 +48,8 @@ func SearchServices(ctx consolectx.Context, args
map[string]any) (*common.ToolRe
return buildServiceSearchResult(result, keywords, mesh, pageSize,
pageNumber)
}
-// GetServiceDetail 获取服务详情
-func GetServiceDetail(ctx consolectx.Context, args map[string]any)
(*common.ToolResult, error) {
+// GetServiceDistribution 获取服务关联的应用分布
+func GetServiceDistribution(ctx consolectx.Context, args map[string]any)
(*common.ToolResult, error) {
helper := common.NewArgsHelper(args)
serviceName := helper.GetString("serviceName", "")
@@ -156,14 +158,38 @@ func extractServices(result
*model.SearchPaginationResult) ([]any, int) {
resultSlice := make([]any, 0, len(services))
for _, svc := range services {
- if svc != nil {
- resultSlice = append(resultSlice, map[string]any{
- "serviceName": svc.ServiceName,
- "version": svc.Version,
- "group": svc.Group,
- "consumerAppName": svc.ConsumerAppName,
- })
+ if svc == nil {
+ continue
}
+ resultSlice = append(resultSlice, map[string]any{
+ "serviceName": svc.ServiceName,
+ "version": svc.Version,
+ "group": svc.Group,
+ "consumerAppName": svc.ConsumerAppName,
+ })
}
return resultSlice, int(result.PageInfo.Total)
}
+
+// GetServiceDetail 获取服务详情
+func GetServiceDetail(ctx consolectx.Context, args map[string]any)
(*common.ToolResult, error) {
+ helper := common.NewArgsHelper(args)
+ serviceName := helper.GetString("serviceName", "")
+ if serviceName == "" {
+ return common.ErrorResult(fmt.Errorf("required parameter
'serviceName' is missing")), nil
+ }
+
+ req := &model.ServiceDetailReq{
+ ServiceName: serviceName,
+ Version: helper.GetString("version", ""),
+ Group: helper.GetString("group", ""),
+ Mesh: common.GetMeshArg(ctx, args),
+ }
+
+ detail, err := service.GetServiceDetail(ctx, req)
+ if err != nil {
+ return common.ErrorResult(err), nil
+ }
+
+ return common.JsonResult(detail)
+}
diff --git a/pkg/mcp/transport/http/sse.go b/pkg/mcp/transport/http/sse.go
index 84a0687f..1c836cd3 100644
--- a/pkg/mcp/transport/http/sse.go
+++ b/pkg/mcp/transport/http/sse.go
@@ -30,9 +30,9 @@ import (
// SSETransport Server-Sent Events传输层
type SSETransport struct {
- server *mcp.Server
- clients map[*SSEClient]bool
- mu sync.RWMutex
+ server *mcp.Server
+ clients map[*SSEClient]bool
+ mu sync.RWMutex
broadcast chan []byte
}
@@ -58,8 +58,8 @@ func NewSSEClient(id string) *SSEClient {
// NewSSETransport 创建SSE传输层
func NewSSETransport(server *mcp.Server) *SSETransport {
return &SSETransport{
- server: server,
- clients: make(map[*SSEClient]bool),
+ server: server,
+ clients: make(map[*SSEClient]bool),
broadcast: make(chan []byte, 256),
}
}
@@ -83,7 +83,11 @@ func (t *SSETransport) HandleSSE(w http.ResponseWriter, r
*http.Request) {
t.sendToClient(client, t.sseEvent("connected", "SSE connection
established"))
// 等待断开连接
- <-client.ctx.Done()
+ select {
+ case <-client.ctx.Done():
+ case <-r.Context().Done():
+ client.done()
+ }
t.mu.Lock()
delete(t.clients, client)