Skip to content

Commit eac41f7

Browse files
authored
Merge pull request #73 from achetronic/fix/mcp-http-toolset-silent-skip
fix: warn when an agent's MCP reference does not resolve
2 parents 3112084 + 4477545 commit eac41f7

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

‎server/agent/agent.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,8 @@ func buildToolsets(agentDef store.AgentDefinition, mcpServerMap map[string]store
846846
for _, mcpName := range agentDef.MCPServers {
847847
srv, ok := mcpServerMap[mcpName]
848848
if !ok {
849+
slog.Warn("Agent references an MCP server that does not exist; its tools will not be available",
850+
"agent", agentDef.ID, "mcp", mcpName)
849851
continue
850852
}
851853
transport, err := createMCPTransport(&srv)

‎server/agent/mcp_wiring_test.go‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// SPDX-FileCopyrightText: 2026 Alby Hernández <hola@achetronic.com>
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package agent
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"log/slog"
10+
"net/http"
11+
"net/http/httptest"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
"testing"
16+
17+
"github.com/modelcontextprotocol/go-sdk/mcp"
18+
adkagent "google.golang.org/adk/v2/agent"
19+
"google.golang.org/adk/v2/tool"
20+
21+
"github.com/achetronic/magec/server/store"
22+
)
23+
24+
// fetchParams mirrors the shape of the tool the reproduction server exposes.
25+
type fetchParams struct {
26+
URL string `json:"url"`
27+
}
28+
29+
// startMCPServer serves a real streamable-http MCP server with a single
30+
// "fetch" tool. It stands in for any FastMCP-Python server an operator
31+
// might register.
32+
func startMCPServer(t *testing.T) *httptest.Server {
33+
t.Helper()
34+
35+
server := mcp.NewServer(&mcp.Implementation{
36+
Name: "repro-server",
37+
Version: "1.0.0",
38+
}, nil)
39+
mcp.AddTool(server, &mcp.Tool{
40+
Name: "fetch",
41+
Description: "Fetches a URL",
42+
}, func(_ context.Context, _ *mcp.CallToolRequest, params fetchParams) (*mcp.CallToolResult, any, error) {
43+
return &mcp.CallToolResult{}, "ok: " + params.URL, nil
44+
})
45+
46+
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil)
47+
srv := httptest.NewServer(handler)
48+
// No t.Cleanup(srv.Close): the streamable transport holds a long-lived
49+
// SSE stream and Close blocks until it ends. The test binary exits
50+
// right after, so letting the server leak is the honest trade.
51+
return srv
52+
}
53+
54+
// TestNewMCPServerBecomesInvocable reproduces the registration flow from the
55+
// field: register an http MCP server via the store, attach its ID to an
56+
// agent, simulate a full process restart, and assert the agent's toolsets
57+
// actually surface the remote tool. The canary guards the whole path: map
58+
// building, buildToolsets, transport creation and the lazy tool listing.
59+
func TestNewMCPServerBecomesInvocable(t *testing.T) {
60+
// MAGEC_MCP_ENDPOINT points the test at an external streamable-http
61+
// server (e.g. a FastMCP-Python instance) instead of the in-process
62+
// go-sdk one, so the exact third-party stack from the field can be
63+
// exercised without vendoring a Python runtime.
64+
if endpoint := os.Getenv("MAGEC_MCP_ENDPOINT"); endpoint != "" {
65+
runRegistrationRepro(t, endpoint)
66+
return
67+
}
68+
httpSrv := startMCPServer(t)
69+
70+
runRegistrationRepro(t, httpSrv.URL+"/mcp")
71+
}
72+
73+
// runRegistrationRepro drives the reported sequence against one endpoint:
74+
// register the server, attach it to an agent, restart, rebuild the toolsets
75+
// and list the tools.
76+
func runRegistrationRepro(t *testing.T, endpoint string) {
77+
t.Helper()
78+
79+
// Register the MCP server, then attach its ID to an agent: the two
80+
// admin API calls from the report.
81+
s := newStore(t)
82+
mcpDef, err := s.CreateMCPServer(store.MCPServer{
83+
Name: "repro",
84+
Type: "http",
85+
Endpoint: endpoint,
86+
})
87+
if err != nil {
88+
t.Fatalf("CreateMCPServer: %v", err)
89+
}
90+
agentDef, err := s.CreateAgent(store.AgentDefinition{
91+
Name: "repro-agent",
92+
MCPServers: []string{mcpDef.ID},
93+
})
94+
if err != nil {
95+
t.Fatalf("CreateAgent: %v", err)
96+
}
97+
98+
// Simulate systemctl restart: the store is re-read from disk and the
99+
// toolsets rebuilt from scratch.
100+
s2 := reloadStore(t, s)
101+
102+
mcpServerMap := make(map[string]store.MCPServer)
103+
for _, m := range s2.Data().MCPServers {
104+
mcpServerMap[m.ID] = m
105+
}
106+
107+
toolsets, err := buildToolsets(agentDef, mcpServerMap, nil)
108+
if err != nil {
109+
t.Fatalf("buildToolsets: %v", err)
110+
}
111+
if len(toolsets) != 1 {
112+
t.Fatalf("buildToolsets returned %d toolsets, want 1 (memory off, one MCP attached)", len(toolsets))
113+
}
114+
115+
ctx := adkagent.NewStrictContextMock(context.Background())
116+
tools, err := toolsets[0].Tools(&ctx)
117+
if err != nil {
118+
t.Fatalf("Tools(): %v", err)
119+
}
120+
if len(tools) != 1 || tools[0].Name() != "fetch" {
121+
t.Fatalf("Tools() = %v, want exactly one tool named %q", toolNames(tools), "fetch")
122+
}
123+
}
124+
125+
func toolNames(ts []tool.Tool) []string {
126+
names := make([]string, 0, len(ts))
127+
for _, tl := range ts {
128+
names = append(names, tl.Name())
129+
}
130+
return names
131+
}
132+
133+
// newStore creates a persisted store rooted in the test's temp dir.
134+
func newStore(t *testing.T) *store.Store {
135+
t.Helper()
136+
path := filepath.Join(t.TempDir(), "store.json")
137+
s, err := store.New(path, "")
138+
if err != nil {
139+
t.Fatalf("store.New: %v", err)
140+
}
141+
return s
142+
}
143+
144+
// reloadStore re-reads the same file the first store persisted to, the way
145+
// a fresh process does at startup.
146+
func reloadStore(t *testing.T, s *store.Store) *store.Store {
147+
t.Helper()
148+
path := filepath.Join(s.DataDir(), "store.json")
149+
s2, err := store.New(path, "")
150+
if err != nil {
151+
t.Fatalf("store.New on reload: %v", err)
152+
}
153+
return s2
154+
}
155+
156+
// TestBuildToolsetsWarnsOnUnresolvedMCP is the observability canary: an
157+
// agent referencing a nonexistent MCP server is still skipped tolerantly
158+
// (a dead reference must not take the agent down), but the skip now leaves
159+
// a warn log an operator can act on instead of failing in total silence.
160+
func TestBuildToolsetsWarnsOnUnresolvedMCP(t *testing.T) {
161+
var buf bytes.Buffer
162+
prev := slog.Default()
163+
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
164+
t.Cleanup(func() { slog.SetDefault(prev) })
165+
166+
agentDef := store.AgentDefinition{ID: "agent-1", MCPServers: []string{"ghost"}}
167+
toolsets, err := buildToolsets(agentDef, map[string]store.MCPServer{}, nil)
168+
if err != nil {
169+
t.Fatalf("buildToolsets: %v", err)
170+
}
171+
if len(toolsets) != 0 {
172+
t.Fatalf("buildToolsets returned %d toolsets, want 0", len(toolsets))
173+
}
174+
if !strings.Contains(buf.String(), "does not exist") {
175+
t.Fatalf("expected a warn log about the unresolved MCP reference, got %q", buf.String())
176+
}
177+
}

0 commit comments

Comments
 (0)