Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
14 changes: 10 additions & 4 deletions internal/app/agentdock_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
53 changes: 52 additions & 1 deletion internal/app/dynamic_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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{
Expand Down
63 changes: 41 additions & 22 deletions internal/mcp/client/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -513,54 +517,58 @@ 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))
for _, tool := range listed {
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}
}
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
}
state.client = client
state.tools = tools
state.lastError = ""
state.lastErrorCode = ""
state.refreshedAt = time.Now().UTC()
return cloneTools(tools), nil
}
Expand Down Expand Up @@ -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
}
Expand All @@ -609,20 +618,30 @@ 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)
}
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 {
Expand Down
17 changes: 9 additions & 8 deletions internal/mcp/client/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions internal/tool/mcp/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading