diff --git a/go.mod b/go.mod index 342c7ccc..cfd2ffd2 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/rogpeppe/go-internal v1.15.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/uvwt/agentdock-protocol v0.6.0 + github.com/uvwt/agentdock-protocol v0.7.0 golang.org/x/sys v0.45.0 ) diff --git a/go.sum b/go.sum index 991a928f..91680e19 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,8 @@ github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2K github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao= github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE= github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ= -github.com/uvwt/agentdock-protocol v0.6.0 h1:HGHMYe/xscxSY7zJdSfdOopd3DWHZePvkCgP3njEsfU= -github.com/uvwt/agentdock-protocol v0.6.0/go.mod h1:yoFrGa/mNuAr3b8fupHCFT0b1Kf4Ni/00T4KnwuiFRk= +github.com/uvwt/agentdock-protocol v0.7.0 h1:p1S37rw+wjGilU+etnmodcMA9sfBIblbypfTJAENPw4= +github.com/uvwt/agentdock-protocol v0.7.0/go.mod h1:yoFrGa/mNuAr3b8fupHCFT0b1Kf4Ni/00T4KnwuiFRk= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= diff --git a/internal/app/agentdock_context.go b/internal/app/agentdock_context.go index e6c83bc6..ccaf533b 100644 --- a/internal/app/agentdock_context.go +++ b/internal/app/agentdock_context.go @@ -141,8 +141,11 @@ type capabilityCommonSkillItem struct { } type capabilityDynamicMCPItem struct { - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + ToolCount int `json:"tool_count"` + LastErrorCode string `json:"last_error_code,omitempty"` } type capabilityACPContext struct { @@ -205,8 +208,11 @@ func (r *Runtime) dynamicMCPCapabilityIndex() []capabilityDynamicMCPItem { items := make([]capabilityDynamicMCPItem, 0, len(servers)) for _, server := range servers { items = append(items, capabilityDynamicMCPItem{ - Name: server.Name, - Description: truncateString(strings.TrimSpace(server.Description), 160), + Name: server.Name, + Description: truncateString(strings.TrimSpace(server.Description), 160), + Status: server.Status, + ToolCount: server.ToolCount, + LastErrorCode: server.LastErrorCode, }) } return items diff --git a/internal/app/dynamic_mcp_test.go b/internal/app/dynamic_mcp_test.go index a828db84..a48ee49b 100644 --- a/internal/app/dynamic_mcp_test.go +++ b/internal/app/dynamic_mcp_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -105,7 +106,7 @@ func TestDynamicMCPToolsStaySeparateAndAppearLightweightInContext(t *testing.T) if err := remarshal(contextResult, &contextData); err != nil { t.Fatal(err) } - if len(contextData.DynamicMCP) != 1 || contextData.DynamicMCP[0].Name != "demo" || contextData.DynamicMCP[0].Description != "Demo external capabilities" { + if len(contextData.DynamicMCP) != 1 || contextData.DynamicMCP[0].Name != "demo" || contextData.DynamicMCP[0].Description != "Demo external capabilities" || contextData.DynamicMCP[0].Status != "idle" || contextData.DynamicMCP[0].ToolCount != 0 { t.Fatalf("dynamic MCP context = %#v", contextData.DynamicMCP) } encodedContext, err := json.Marshal(contextResult) @@ -134,6 +135,17 @@ func TestDynamicMCPToolsStaySeparateAndAppearLightweightInContext(t *testing.T) if search["count"] != 1 { t.Fatalf("unexpected search result: %#v", search) } + readyContextResult, err := runtime.Call(context.Background(), "agentdock_context", map[string]any{}) + if err != nil { + t.Fatal(err) + } + var readyContext capabilityContext + if err := remarshal(readyContextResult, &readyContext); err != nil { + t.Fatal(err) + } + if readyContext.DynamicMCP[0].Status != "ready" || readyContext.DynamicMCP[0].ToolCount != 1 || readyContext.DynamicMCP[0].LastErrorCode != "" { + t.Fatalf("ready dynamic MCP context = %#v", readyContext.DynamicMCP[0]) + } assertToolResultMatchestestOutputSchema(t, "mcp_tool_search", search) inspect, err := runtime.Call(context.Background(), "mcp_tool_inspect", map[string]any{"name": "demo:echo"}) @@ -171,6 +183,45 @@ func TestDynamicMCPToolsStaySeparateAndAppearLightweightInContext(t *testing.T) } } +func TestAgentDockContextReportsDynamicMCPRefreshErrorCode(t *testing.T) { + cfg := config.Config{AgentDockDefaultDir: t.TempDir(), AgentDockHome: filepath.Join(t.TempDir(), ".agentdock")} + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(cfg) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + if _, err := runtime.Call(context.Background(), "mcp_manage", map[string]any{ + "action": "add", "name": "broken", "description": "Missing required host environment", + "transport": "stdio", "command": os.Args[0], + "env_from_env": map[string]any{"REQUIRED": "AGENTDOCK_TEST_MISSING_MCP_ENV"}, + }); err != nil { + t.Fatal(err) + } + if _, err := runtime.Call(context.Background(), "mcp_tool_search", map[string]any{"server": "broken", "query": "anything"}); err == nil { + t.Fatal("mcp_tool_search succeeded with a missing required environment variable") + } + + result, err := runtime.Call(context.Background(), "agentdock_context", map[string]any{}) + if err != nil { + t.Fatal(err) + } + var contextData capabilityContext + if err := remarshal(result, &contextData); err != nil { + t.Fatal(err) + } + if len(contextData.DynamicMCP) != 1 { + t.Fatalf("dynamic MCP context = %#v", contextData.DynamicMCP) + } + item := contextData.DynamicMCP[0] + if item.Name != "broken" || item.Status != "error" || item.ToolCount != 0 || item.LastErrorCode != "MCP_AUTH_REQUIRED" { + t.Fatalf("broken dynamic MCP context = %#v", item) + } +} + func writeDynamicMCPRPCResult(t *testing.T, writer http.ResponseWriter, id any, result any) { t.Helper() if err := json.NewEncoder(writer).Encode(map[string]any{ diff --git a/internal/mcp/client/manager.go b/internal/mcp/client/manager.go index ae271f27..4da0b302 100644 --- a/internal/mcp/client/manager.go +++ b/internal/mcp/client/manager.go @@ -26,11 +26,12 @@ type Manager struct { } type serverState struct { - mu sync.Mutex - client protocolClient - tools map[string]Tool - lastError string - refreshedAt time.Time + mu sync.Mutex + client protocolClient + tools map[string]Tool + lastError string + lastErrorCode string + refreshedAt time.Time } func NewManager(agentDockHome string, provided ...*envstore.Store) (*Manager, error) { @@ -262,6 +263,7 @@ func (m *Manager) Refresh(ctx context.Context, name string) (ServerSummary, []To } runtimeCfg, err := m.runtimeConfig(cfg) if err != nil { + recordStateError(state, err) return ServerSummary{}, nil, err } ctx, cancel := context.WithTimeout(ctx, time.Duration(cfg.TimeoutMS)*time.Millisecond) @@ -378,6 +380,7 @@ func (m *Manager) Call(ctx context.Context, qualifiedName string, arguments map[ if state.client == nil || len(state.tools) == 0 { runtimeCfg, err := m.runtimeConfig(cfg) if err != nil { + recordStateError(state, err) return nil, err } if _, err := refreshStateLocked(ctx, runtimeCfg, state); err != nil { @@ -498,6 +501,7 @@ func (m *Manager) ensureTools(ctx context.Context, name string) (map[string]Tool if state.client == nil || len(state.tools) == 0 { runtimeCfg, err := m.runtimeConfig(cfg) if err != nil { + recordStateError(state, err) return nil, err } return refreshStateLocked(ctx, runtimeCfg, state) @@ -513,18 +517,18 @@ func refreshStateLocked(ctx context.Context, cfg ServerConfig, state *serverStat state.tools = nil client, err := newProtocolClient(cfg) if err != nil { - state.lastError = err.Error() + recordStateError(state, err) return nil, err } if err := client.initialize(ctx); err != nil { _ = client.close() - state.lastError = err.Error() + recordStateError(state, err) return nil, err } listed, err := client.listTools(ctx) if err != nil { _ = client.close() - state.lastError = err.Error() + recordStateError(state, err) return nil, err } tools := make(map[string]Tool, len(listed)) @@ -532,13 +536,15 @@ func refreshStateLocked(ctx context.Context, cfg ServerConfig, state *serverStat tool.Name = strings.TrimSpace(tool.Name) if tool.Name == "" { _ = client.close() - state.lastError = "MCP tools/list returned an empty tool name" - return nil, newError("MCP_INVALID_RESPONSE", state.lastError, false, map[string]any{"server": cfg.Name}, nil) + err := newError("MCP_INVALID_RESPONSE", "MCP tools/list returned an empty tool name", false, map[string]any{"server": cfg.Name}, nil) + recordStateError(state, err) + return nil, err } if _, duplicate := tools[tool.Name]; duplicate { _ = client.close() - state.lastError = "MCP tools/list returned duplicate tool names" - return nil, newError("MCP_INVALID_RESPONSE", state.lastError, false, map[string]any{"server": cfg.Name, "tool": tool.Name}, nil) + err := newError("MCP_INVALID_RESPONSE", "MCP tools/list returned duplicate tool names", false, map[string]any{"server": cfg.Name, "tool": tool.Name}, nil) + recordStateError(state, err) + return nil, err } if tool.InputSchema == nil { tool.InputSchema = map[string]any{"type": "object", "additionalProperties": true} @@ -546,14 +552,15 @@ func refreshStateLocked(ctx context.Context, cfg ServerConfig, state *serverStat validator, err := compileToolInputSchema(tool.InputSchema) if err != nil { _ = client.close() - state.lastError = "MCP tools/list returned an invalid input schema" - return nil, newError( + schemaErr := newError( "MCP_SCHEMA_INVALID", - state.lastError, + "MCP tools/list returned an invalid input schema", false, map[string]any{"server": cfg.Name, "tool": tool.Name, "reason": err.Error()}, err, ) + recordStateError(state, schemaErr) + return nil, schemaErr } tool.inputValidator = validator tools[tool.Name] = tool @@ -561,6 +568,7 @@ func refreshStateLocked(ctx context.Context, cfg ServerConfig, state *serverStat state.client = client state.tools = tools state.lastError = "" + state.lastErrorCode = "" state.refreshedAt = time.Now().UTC() return cloneTools(tools), nil } @@ -589,6 +597,7 @@ func closeState(state *serverState) error { state.client = nil state.tools = nil state.lastError = "" + state.lastErrorCode = "" state.refreshedAt = time.Time{} return err } @@ -609,13 +618,14 @@ func summaryForLocked(cfg ServerConfig, state *serverState) ServerSummary { status = "ready" } item := ServerSummary{ - Name: cfg.Name, - Description: cfg.Description, - Transport: cfg.Transport, - Enabled: cfg.Enabled, - Status: status, - ToolCount: len(state.tools), - LastError: state.lastError, + Name: cfg.Name, + Description: cfg.Description, + Transport: cfg.Transport, + Enabled: cfg.Enabled, + Status: status, + ToolCount: len(state.tools), + LastError: state.lastError, + LastErrorCode: state.lastErrorCode, } if !state.refreshedAt.IsZero() { item.RefreshedAt = state.refreshedAt.Format(time.RFC3339Nano) @@ -623,6 +633,15 @@ func summaryForLocked(cfg ServerConfig, state *serverState) ServerSummary { return item } +func recordStateError(state *serverState, err error) { + state.lastError = err.Error() + state.lastErrorCode = "MCP_ERROR" + var mcpErr *Error + if errors.As(err, &mcpErr) { + state.lastErrorCode = mcpErr.Code + } +} + func summarizeTools(server string, tools map[string]Tool) []ToolSummary { items := make([]ToolSummary, 0, len(tools)) for _, tool := range tools { diff --git a/internal/mcp/client/types.go b/internal/mcp/client/types.go index 8465c038..bdf81b73 100644 --- a/internal/mcp/client/types.go +++ b/internal/mcp/client/types.go @@ -53,14 +53,15 @@ type ToolSummary struct { } type ServerSummary struct { - Name string `json:"name"` - Description string `json:"description"` - Transport string `json:"transport"` - Enabled bool `json:"enabled"` - Status string `json:"status"` - ToolCount int `json:"tool_count"` - LastError string `json:"last_error,omitempty"` - RefreshedAt string `json:"refreshed_at,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Transport string `json:"transport"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + ToolCount int `json:"tool_count"` + LastError string `json:"last_error,omitempty"` + LastErrorCode string `json:"last_error_code,omitempty"` + RefreshedAt string `json:"refreshed_at,omitempty"` } type Error struct { diff --git a/internal/tool/mcp/service.go b/internal/tool/mcp/service.go index e73a1aa8..90832cd7 100644 --- a/internal/tool/mcp/service.go +++ b/internal/tool/mcp/service.go @@ -17,15 +17,21 @@ func New(manager *mcpclient.Manager, envs *envstore.Store) *Service { } type CapabilityItem struct { - Name string - Description string + Name string + Description string + Status string + ToolCount int + LastErrorCode string } func (s *Service) CapabilityItems() []CapabilityItem { servers := s.mcpClients.EnabledIndex() items := make([]CapabilityItem, 0, len(servers)) for _, server := range servers { - items = append(items, CapabilityItem{Name: server.Name, Description: server.Description}) + items = append(items, CapabilityItem{ + Name: server.Name, Description: server.Description, Status: server.Status, + ToolCount: server.ToolCount, LastErrorCode: server.LastErrorCode, + }) } return items }