Skip to content

Commit d53f636

Browse files
committed
node: move network diagnostics from MCP tools to /debug REST endpoints
Remove the five operator-diagnostic MCP tools (connect_peer, check_connectivity, get_network_info, get_token_info, get_recent_logs) from the agent-facing MCP server and re-expose them as /debug REST endpoints on the sidecar mux, behind withAuth but not withMeshConnection so they keep answering while the mesh is unreachable. get_mesh_info stays as an MCP tool and gains GET /debug/mesh-info for operators. Add a 'sam-node debug' command group with one subcommand per endpoint, talking to the node over its Unix socket so no token is needed. Fixes #318
1 parent 77dbc94 commit d53f636

23 files changed

Lines changed: 506 additions & 344 deletions

cmd/sam-node/debug.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"encoding/json"
21+
"fmt"
22+
"io"
23+
"net"
24+
"net/http"
25+
"net/url"
26+
"strings"
27+
"time"
28+
29+
"github.com/google/sam/internal/node"
30+
"github.com/spf13/cobra"
31+
)
32+
33+
// newDebugCmd groups the operator diagnostics served by a running node's
34+
// /debug endpoints, reached over its Unix socket so no token is involved.
35+
func newDebugCmd() *cobra.Command {
36+
debugCmd := &cobra.Command{
37+
Use: "debug",
38+
Short: "Diagnostics for a running node, over its Unix socket",
39+
}
40+
debugCmd.PersistentFlags().StringVar(&socketPathFlag, "socket-path", "", "Unix socket of the running node (defaults to <data-dir>/"+node.DefaultSocketName+")")
41+
42+
debugCmd.AddCommand(
43+
newDebugGetCmd("mesh-info", "Show connected peers, DHT size, and the router peer ID", "/debug/mesh-info"),
44+
newDebugGetCmd("network-info", "Show local network interfaces and listener addresses", "/debug/network-info"),
45+
newDebugGetCmd("token-info", "Show the local auth token's expiration and status", "/debug/token-info"),
46+
newDebugGetCmd("logs", "Show the last few lines of the node's log output", "/debug/logs"),
47+
)
48+
49+
debugCmd.AddCommand(&cobra.Command{
50+
Use: "connectivity [peer-id]",
51+
Short: "Ping the SAM router, or a specific peer",
52+
Args: cobra.MaximumNArgs(1),
53+
SilenceUsage: true,
54+
RunE: func(cmd *cobra.Command, args []string) error {
55+
path := "/debug/connectivity"
56+
if len(args) == 1 {
57+
path += "?peer_id=" + url.QueryEscape(args[0])
58+
}
59+
return debugRequest(cmd, http.MethodGet, path, nil)
60+
},
61+
})
62+
63+
debugCmd.AddCommand(&cobra.Command{
64+
Use: "connect-peer <multiaddr>",
65+
Short: "Connect the node to a peer by its full multiaddress",
66+
Args: cobra.ExactArgs(1),
67+
SilenceUsage: true,
68+
RunE: func(cmd *cobra.Command, args []string) error {
69+
body, err := json.Marshal(map[string]string{"peer_addr": args[0]})
70+
if err != nil {
71+
return err
72+
}
73+
return debugRequest(cmd, http.MethodPost, "/debug/connect-peer", bytes.NewReader(body))
74+
},
75+
})
76+
77+
return debugCmd
78+
}
79+
80+
// newDebugGetCmd builds a no-argument subcommand that GETs one /debug endpoint.
81+
func newDebugGetCmd(use, short, path string) *cobra.Command {
82+
return &cobra.Command{
83+
Use: use,
84+
Short: short,
85+
Args: cobra.NoArgs,
86+
SilenceUsage: true,
87+
RunE: func(cmd *cobra.Command, args []string) error {
88+
return debugRequest(cmd, http.MethodGet, path, nil)
89+
},
90+
}
91+
}
92+
93+
// debugRequest performs one request against the node's Unix socket and prints
94+
// the JSON response.
95+
func debugRequest(cmd *cobra.Command, method, path string, body io.Reader) error {
96+
socketPath := resolveSocketPath(cmd)
97+
req, err := http.NewRequestWithContext(cmd.Context(), method, "http://localhost"+path, body)
98+
if err != nil {
99+
return err
100+
}
101+
if body != nil {
102+
req.Header.Set("Content-Type", "application/json")
103+
}
104+
resp, err := socketClient(socketPath, 30*time.Second).Do(req)
105+
if err != nil {
106+
return fmt.Errorf("cannot reach the node on %s (is it running with a socket?): %w", socketPath, err)
107+
}
108+
defer func() {
109+
_ = resp.Body.Close()
110+
}()
111+
data, err := io.ReadAll(resp.Body)
112+
if err != nil {
113+
return err
114+
}
115+
if resp.StatusCode != http.StatusOK {
116+
return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
117+
}
118+
fmt.Println(strings.TrimSpace(string(data)))
119+
return nil
120+
}
121+
122+
// socketClient returns an HTTP client that dials the node's Unix socket; the
123+
// URL host is ignored.
124+
func socketClient(path string, timeout time.Duration) *http.Client {
125+
return &http.Client{
126+
Timeout: timeout,
127+
Transport: &http.Transport{
128+
DisableKeepAlives: true,
129+
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
130+
return (&net.Dialer{Timeout: timeout}).DialContext(ctx, "unix", path)
131+
},
132+
},
133+
}
134+
}

cmd/sam-node/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,7 @@ func main() {
951951
rootCmd.AddCommand(joinCmd)
952952
rootCmd.AddCommand(resetCmd)
953953
rootCmd.AddCommand(newSkillCmd())
954+
rootCmd.AddCommand(newDebugCmd())
954955

955956
ctx, cancel := context.WithCancel(context.Background())
956957
defer cancel()

internal/node/debug_handlers.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package node
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"net/http"
22+
"time"
23+
24+
"github.com/libp2p/go-libp2p/core/peer"
25+
"github.com/multiformats/go-multiaddr"
26+
)
27+
28+
// newDebugHandler serves the operator diagnostics under /debug. These were MCP
29+
// tools once; they moved here so agents never see them in their tool list (#318).
30+
func newDebugHandler(n *SamNode) http.Handler {
31+
mux := http.NewServeMux()
32+
mux.HandleFunc("GET /debug/mesh-info", func(w http.ResponseWriter, r *http.Request) {
33+
info, err := n.meshInfo()
34+
if err != nil {
35+
http.Error(w, err.Error(), http.StatusInternalServerError)
36+
return
37+
}
38+
writeDebugJSON(w, info)
39+
})
40+
mux.HandleFunc("GET /debug/connectivity", func(w http.ResponseWriter, r *http.Request) {
41+
writeDebugJSON(w, n.connectivityStats(r.Context(), r.URL.Query().Get("peer_id")))
42+
})
43+
mux.HandleFunc("GET /debug/network-info", func(w http.ResponseWriter, r *http.Request) {
44+
writeDebugJSON(w, n.networkInfo())
45+
})
46+
mux.HandleFunc("GET /debug/token-info", func(w http.ResponseWriter, r *http.Request) {
47+
writeDebugJSON(w, n.tokenInfo())
48+
})
49+
mux.HandleFunc("GET /debug/logs", func(w http.ResponseWriter, r *http.Request) {
50+
writeDebugJSON(w, map[string]any{"logs": GetRecentLogs()})
51+
})
52+
mux.HandleFunc("POST /debug/connect-peer", func(w http.ResponseWriter, r *http.Request) {
53+
var req struct {
54+
PeerAddr string `json:"peer_addr"`
55+
}
56+
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
57+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
58+
http.Error(w, "Invalid request body", http.StatusBadRequest)
59+
return
60+
}
61+
if req.PeerAddr == "" {
62+
http.Error(w, "peer_addr is required", http.StatusBadRequest)
63+
return
64+
}
65+
if err := n.connectPeer(r.Context(), req.PeerAddr); err != nil {
66+
http.Error(w, fmt.Sprintf("Failed to connect: %v", err), http.StatusInternalServerError)
67+
return
68+
}
69+
writeDebugJSON(w, map[string]string{"status": "connected"})
70+
})
71+
return mux
72+
}
73+
74+
func writeDebugJSON(w http.ResponseWriter, v any) {
75+
w.Header().Set("Content-Type", "application/json")
76+
if err := json.NewEncoder(w).Encode(v); err != nil {
77+
logger.Errorf("Failed to encode response: %v", err)
78+
}
79+
}
80+
81+
// meshInfo backs both the get_mesh_info MCP tool and GET /debug/mesh-info.
82+
func (n *SamNode) meshInfo() (map[string]any, error) {
83+
if n == nil {
84+
return nil, fmt.Errorf("node not initialized")
85+
}
86+
87+
peers := n.Host.Network().Peers()
88+
var connectedPeers []string
89+
for _, p := range peers {
90+
connectedPeers = append(connectedPeers, p.String())
91+
}
92+
dhtSize := n.DHT.RoutingTable().Size()
93+
94+
resData := map[string]any{
95+
"peer_id": n.Host.ID().String(),
96+
"connected_peers": connectedPeers,
97+
"dht_size": dhtSize,
98+
"router_peer_id": n.RouterPeerID.String(),
99+
}
100+
if n.BoundSocketPath != "" {
101+
resData["local_api_socket"] = n.BoundSocketPath
102+
}
103+
return resData, nil
104+
}
105+
106+
// connectivityStats backs GET /debug/connectivity: with a peer ID it pings that
107+
// peer, otherwise it pings the SAM router.
108+
func (n *SamNode) connectivityStats(ctx context.Context, peerIDStr string) map[string]any {
109+
stats := map[string]any{
110+
"connected_peers": len(n.Host.Network().Peers()),
111+
"total_known_peers": len(n.Host.Peerstore().Peers()),
112+
}
113+
114+
if peerIDStr != "" {
115+
pid, err := peer.Decode(peerIDStr)
116+
if err == nil {
117+
n.preparePeerAddrs(ctx, pid)
118+
start := time.Now()
119+
err := n.Host.Connect(ctx, peer.AddrInfo{ID: pid})
120+
stats["ping_latency_ms"] = time.Since(start).Milliseconds()
121+
stats["ping_error"] = err != nil
122+
if err != nil {
123+
stats["ping_error_msg"] = err.Error()
124+
}
125+
} else {
126+
stats["ping_error"] = true
127+
stats["ping_error_msg"] = "invalid peer id"
128+
}
129+
} else if n.RouterPeerID != "" {
130+
start := time.Now()
131+
err := n.Host.Connect(ctx, peer.AddrInfo{ID: n.RouterPeerID})
132+
stats["router_latency_ms"] = time.Since(start).Milliseconds()
133+
stats["router_error"] = err != nil
134+
if err != nil {
135+
stats["router_error_msg"] = err.Error()
136+
}
137+
}
138+
139+
return stats
140+
}
141+
142+
// tokenInfo backs GET /debug/token-info.
143+
func (n *SamNode) tokenInfo() map[string]any {
144+
info := map[string]any{
145+
"has_token": false,
146+
}
147+
148+
token, err := n.Store.LoadIdentity()
149+
if err == nil && len(token) > 0 {
150+
info["has_token"] = true
151+
exp, err := n.Store.LoadIdentityExpiration()
152+
if err == nil {
153+
info["expires_in_seconds"] = time.Until(time.Unix(exp, 0)).Seconds()
154+
info["is_expired"] = time.Now().Unix() > exp
155+
}
156+
}
157+
return info
158+
}
159+
160+
// networkInfo backs GET /debug/network-info.
161+
func (n *SamNode) networkInfo() map[string]any {
162+
listenAddrs := []string{}
163+
for _, a := range n.Host.Network().ListenAddresses() {
164+
listenAddrs = append(listenAddrs, a.String())
165+
}
166+
167+
observedAddrs := []string{}
168+
for _, a := range n.Host.Addrs() {
169+
observedAddrs = append(observedAddrs, a.String())
170+
}
171+
172+
return map[string]any{
173+
"listen_addresses": listenAddrs,
174+
"observed_addresses": observedAddrs,
175+
}
176+
}
177+
178+
// connectPeer backs POST /debug/connect-peer.
179+
func (n *SamNode) connectPeer(ctx context.Context, peerAddr string) error {
180+
ma, err := multiaddr.NewMultiaddr(peerAddr)
181+
if err != nil {
182+
return err
183+
}
184+
addrInfo, err := peer.AddrInfoFromP2pAddr(ma)
185+
if err != nil {
186+
return err
187+
}
188+
if n.revokedPeers != nil && n.revokedPeers.Contains(addrInfo.ID.String()) {
189+
return fmt.Errorf("failed to dial: failed to dial %s: gater disallows connection to peer", addrInfo.ID)
190+
}
191+
if n.Store.IsBanned(addrInfo.ID) {
192+
return fmt.Errorf("failed to dial: failed to dial %s: gater disallows connection to peer", addrInfo.ID)
193+
}
194+
conns := n.Host.Network().ConnsToPeer(addrInfo.ID)
195+
connectedness := n.Host.Network().Connectedness(addrInfo.ID)
196+
logger.Debugf("[connect-peer] Target peer %s, connectedness: %v, active conns: %d", addrInfo.ID, connectedness, len(conns))
197+
198+
err = n.Host.Connect(ctx, *addrInfo)
199+
logger.Debugf("[connect-peer] Host.Connect returned error: %v", err)
200+
return err
201+
}

internal/node/mcp.go

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,6 @@ func NewMCPServer(node *SamNode) *mcp.Server {
111111
Description: "Call an MCP tool on a remote agent",
112112
}, node.handleCallRemoteTool)
113113

114-
// Add the connect_peer tool.
115-
mcp.AddTool(mcpServer, &mcp.Tool{
116-
Name: "connect_peer",
117-
Description: "Connect to a peer in the mesh",
118-
}, node.handleConnectPeer)
119-
120114
// Add the find_remote_tools tool.
121115
mcp.AddTool(mcpServer, &mcp.Tool{
122116
Name: "find_remote_tools",
@@ -129,30 +123,6 @@ func NewMCPServer(node *SamNode) *mcp.Server {
129123
Description: "Return the description, input schema, and output schema for a specific aggregated tool on a specific peer. peer_id and tool_name are both required; tool_name must be a namespaced 'scheme://service/tool' name as returned by find_remote_tools.",
130124
}, node.handleDescribeRemoteTool)
131125

132-
// Add the check_connectivity tool.
133-
mcp.AddTool(mcpServer, &mcp.Tool{
134-
Name: "check_connectivity",
135-
Description: "Diagnose the node's ability to communicate with the SAM routers and the broader mesh network.",
136-
}, node.handleCheckConnectivity)
137-
138-
// Add the get_token_info tool.
139-
mcp.AddTool(mcpServer, &mcp.Tool{
140-
Name: "get_token_info",
141-
Description: "Inspects the local auth token, returns its expiration time and status.",
142-
}, node.handleGetTokenInfo)
143-
144-
// Add the get_network_info tool.
145-
mcp.AddTool(mcpServer, &mcp.Tool{
146-
Name: "get_network_info",
147-
Description: "Returns local network interfaces and listener addresses.",
148-
}, node.handleGetNetworkInfo)
149-
150-
// Add the get_recent_logs tool.
151-
mcp.AddTool(mcpServer, &mcp.Tool{
152-
Name: "get_recent_logs",
153-
Description: "Returns the last few lines of the node's log output.",
154-
}, node.handleGetRecentLogs)
155-
156126
return mcpServer
157127
}
158128

0 commit comments

Comments
 (0)