Skip to content

Commit 7805a68

Browse files
authored
feat(mcp): add HTTP and Stdio client Roots feature (#620)
* feat: client roots feature * feat: finish client roots, pass unit and integration test * client roots http sample code * client roots for stdio and pass integration test * update roots stio client example * add godoc and const of rootlist * update godoc and data format * update examples for client roots * add fallback for demonstration * adjust roots path and signals of examples * update roots http client example * samples: fix unit test and refactor with lint * examples: refactor to adapt windows os and nitpick comments * update for nitpick comments * refactor for nitpick comments
1 parent da6f722 commit 7805a68

File tree

16 files changed

+1100
-5
lines changed

16 files changed

+1100
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
.claude
66
coverage.out
77
coverage.txt
8+
.vscode/launch.json

client/client.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Client struct {
2525
serverCapabilities mcp.ServerCapabilities
2626
protocolVersion string
2727
samplingHandler SamplingHandler
28+
rootsHandler RootsHandler
2829
elicitationHandler ElicitationHandler
2930
}
3031

@@ -45,6 +46,15 @@ func WithSamplingHandler(handler SamplingHandler) ClientOption {
4546
}
4647
}
4748

49+
// WithRootsHandler sets the roots handler for the client.
50+
// WithRootsHandler returns a ClientOption that sets the client's RootsHandler.
51+
// When provided, the client will declare the roots capability (ListChanged) during initialization.
52+
func WithRootsHandler(handler RootsHandler) ClientOption {
53+
return func(c *Client) {
54+
c.rootsHandler = handler
55+
}
56+
}
57+
4858
// WithElicitationHandler sets the elicitation handler for the client.
4959
// When set, the client will declare elicitation capability during initialization.
5060
func WithElicitationHandler(handler ElicitationHandler) ClientOption {
@@ -180,6 +190,13 @@ func (c *Client) Initialize(
180190
if c.samplingHandler != nil {
181191
capabilities.Sampling = &struct{}{}
182192
}
193+
if c.rootsHandler != nil {
194+
capabilities.Roots = &struct {
195+
ListChanged bool `json:"listChanged,omitempty"`
196+
}{
197+
ListChanged: true,
198+
}
199+
}
183200
// Add elicitation capability if handler is configured
184201
if c.elicitationHandler != nil {
185202
capabilities.Elicitation = &struct{}{}
@@ -467,6 +484,28 @@ func (c *Client) Complete(
467484
return &result, nil
468485
}
469486

487+
// RootListChanges sends a roots list-changed notification to the server.
488+
func (c *Client) RootListChanges(
489+
ctx context.Context,
490+
) error {
491+
// Send root list changes notification
492+
notification := mcp.JSONRPCNotification{
493+
JSONRPC: mcp.JSONRPC_VERSION,
494+
Notification: mcp.Notification{
495+
Method: mcp.MethodNotificationRootsListChanged,
496+
},
497+
}
498+
499+
err := c.transport.SendNotification(ctx, notification)
500+
if err != nil {
501+
return fmt.Errorf(
502+
"failed to send root list change notification: %w",
503+
err,
504+
)
505+
}
506+
return nil
507+
}
508+
470509
// handleIncomingRequest processes incoming requests from the server.
471510
// This is the main entry point for server-to-client requests like sampling and elicitation.
472511
func (c *Client) handleIncomingRequest(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
@@ -477,6 +516,8 @@ func (c *Client) handleIncomingRequest(ctx context.Context, request transport.JS
477516
return c.handleElicitationRequestTransport(ctx, request)
478517
case string(mcp.MethodPing):
479518
return c.handlePingRequestTransport(ctx, request)
519+
case string(mcp.MethodListRoots):
520+
return c.handleListRootsRequestTransport(ctx, request)
480521
default:
481522
return nil, fmt.Errorf("unsupported request method: %s", request.Method)
482523
}
@@ -539,6 +580,37 @@ func (c *Client) handleSamplingRequestTransport(ctx context.Context, request tra
539580
return response, nil
540581
}
541582

583+
// handleListRootsRequestTransport handles list roots requests at the transport level.
584+
func (c *Client) handleListRootsRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
585+
if c.rootsHandler == nil {
586+
return nil, fmt.Errorf("no roots handler configured")
587+
}
588+
589+
// Create the MCP request
590+
mcpRequest := mcp.ListRootsRequest{
591+
Request: mcp.Request{
592+
Method: string(mcp.MethodListRoots),
593+
},
594+
}
595+
596+
// Call the list roots handler
597+
result, err := c.rootsHandler.ListRoots(ctx, mcpRequest)
598+
if err != nil {
599+
return nil, err
600+
}
601+
602+
// Marshal the result
603+
resultBytes, err := json.Marshal(result)
604+
if err != nil {
605+
return nil, fmt.Errorf("failed to marshal result: %w", err)
606+
}
607+
608+
// Create the transport response
609+
response := transport.NewJSONRPCResultResponse(request.ID, json.RawMessage(resultBytes))
610+
611+
return response, nil
612+
}
613+
542614
// handleElicitationRequestTransport handles elicitation requests at the transport level.
543615
func (c *Client) handleElicitationRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
544616
if c.elicitationHandler == nil {

client/roots.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package client
2+
3+
import (
4+
"context"
5+
6+
"github.com/mark3labs/mcp-go/mcp"
7+
)
8+
9+
// RootsHandler defines the interface for handling roots requests from servers.
10+
// Clients can implement this interface to provide roots list to servers.
11+
type RootsHandler interface {
12+
// ListRoots handles a list root request from the server and returns the roots list.
13+
// The implementation should:
14+
// 1. Validate input against the requested schema
15+
// 2. Return the appropriate response
16+
ListRoots(ctx context.Context, request mcp.ListRootsRequest) (*mcp.ListRootsResult, error)
17+
}

client/transport/inprocess.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type InProcessTransport struct {
1414
server *server.MCPServer
1515
samplingHandler server.SamplingHandler
1616
elicitationHandler server.ElicitationHandler
17+
rootsHandler server.RootsHandler
1718
session *server.InProcessSession
1819
sessionID string
1920

@@ -37,6 +38,12 @@ func WithElicitationHandler(handler server.ElicitationHandler) InProcessOption {
3738
}
3839
}
3940

41+
func WithRootsHandler(handler server.RootsHandler) InProcessOption {
42+
return func(t *InProcessTransport) {
43+
t.rootsHandler = handler
44+
}
45+
}
46+
4047
func NewInProcessTransport(server *server.MCPServer) *InProcessTransport {
4148
return &InProcessTransport{
4249
server: server,
@@ -66,8 +73,8 @@ func (c *InProcessTransport) Start(ctx context.Context) error {
6673
c.startedMu.Unlock()
6774

6875
// Create and register session if we have handlers
69-
if c.samplingHandler != nil || c.elicitationHandler != nil {
70-
c.session = server.NewInProcessSessionWithHandlers(c.sessionID, c.samplingHandler, c.elicitationHandler)
76+
if c.samplingHandler != nil || c.elicitationHandler != nil || c.rootsHandler != nil {
77+
c.session = server.NewInProcessSessionWithHandlers(c.sessionID, c.samplingHandler, c.elicitationHandler, c.rootsHandler)
7178
if err := c.server.RegisterSession(ctx, c.session); err != nil {
7279
c.startedMu.Lock()
7380
c.started = false

examples/roots_client/main.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"net/url"
8+
"os"
9+
"os/signal"
10+
"path/filepath"
11+
"strings"
12+
"syscall"
13+
14+
"github.com/mark3labs/mcp-go/client"
15+
"github.com/mark3labs/mcp-go/client/transport"
16+
"github.com/mark3labs/mcp-go/mcp"
17+
)
18+
19+
// fileURI returns a file:// URI for both Unix and Windows absolute paths.
20+
func fileURI(p string) string {
21+
p = filepath.ToSlash(p)
22+
if !strings.HasPrefix(p, "/") { // e.g., "C:/Users/..." on Windows
23+
p = "/" + p
24+
}
25+
return (&url.URL{Scheme: "file", Path: p}).String()
26+
}
27+
28+
// MockRootsHandler implements client.RootsHandler for demonstration.
29+
// In a real implementation, this would enumerate workspace/project roots.
30+
type MockRootsHandler struct{}
31+
32+
// ListRoots implements client.RootsHandler by returning example workspace roots.
33+
func (h *MockRootsHandler) ListRoots(ctx context.Context, request mcp.ListRootsRequest) (*mcp.ListRootsResult, error) {
34+
home, err := os.UserHomeDir()
35+
if err != nil {
36+
log.Printf("Warning: failed to get home directory: %v", err)
37+
home = "/tmp" // fallback for demonstration
38+
}
39+
app := filepath.ToSlash(filepath.Join(home, "app"))
40+
proj := filepath.ToSlash(filepath.Join(home, "projects", "test-project"))
41+
result := &mcp.ListRootsResult{
42+
Roots: []mcp.Root{
43+
{
44+
Name: "app",
45+
URI: fileURI(app),
46+
},
47+
{
48+
Name: "test-project",
49+
URI: fileURI(proj),
50+
},
51+
},
52+
}
53+
return result, nil
54+
}
55+
56+
// main starts a mock MCP roots client that communicates with a subprocess over stdio.
57+
// It expects the server command as the first command-line argument, creates a stdio
58+
// transport and an MCP client with a MockRootsHandler, starts and initializes the
59+
// client, logs server info and available tools, notifies the server of root list
60+
// changes, invokes the "roots" tool and prints any text content returned, and
61+
// shuts down the client gracefully on SIGINT or SIGTERM.
62+
func main() {
63+
if len(os.Args) < 2 {
64+
log.Fatal("Usage: roots_client <server_command>")
65+
}
66+
67+
serverCommand := os.Args[1]
68+
serverArgs := os.Args[2:]
69+
70+
// Create stdio transport to communicate with the server
71+
stdio := transport.NewStdio(serverCommand, nil, serverArgs...)
72+
73+
// Create roots handler
74+
rootsHandler := &MockRootsHandler{}
75+
76+
// Create client with roots capability
77+
mcpClient := client.NewClient(stdio, client.WithRootsHandler(rootsHandler))
78+
79+
ctx := context.Background()
80+
81+
// Start the client
82+
if err := mcpClient.Start(ctx); err != nil {
83+
log.Fatalf("Failed to start client: %v", err)
84+
}
85+
86+
// Setup graceful shutdown
87+
sigChan := make(chan os.Signal, 1)
88+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
89+
90+
// Create a context that cancels on signal
91+
ctx, cancel := context.WithCancel(ctx)
92+
go func() {
93+
<-sigChan
94+
log.Println("Received shutdown signal, closing client...")
95+
cancel()
96+
}()
97+
98+
// Move defer after error checking
99+
defer func() {
100+
if err := mcpClient.Close(); err != nil {
101+
log.Printf("Error closing client: %v", err)
102+
}
103+
}()
104+
105+
// Initialize the connection
106+
initResult, err := mcpClient.Initialize(ctx, mcp.InitializeRequest{
107+
Params: mcp.InitializeParams{
108+
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
109+
ClientInfo: mcp.Implementation{
110+
Name: "roots-stdio-client",
111+
Version: "1.0.0",
112+
},
113+
Capabilities: mcp.ClientCapabilities{
114+
// Roots capability will be automatically added by WithRootsHandler
115+
},
116+
},
117+
})
118+
if err != nil {
119+
log.Fatalf("Failed to initialize: %v", err)
120+
}
121+
122+
log.Printf("Connected to server: %s v%s", initResult.ServerInfo.Name, initResult.ServerInfo.Version)
123+
log.Printf("Server capabilities: %+v", initResult.Capabilities)
124+
125+
// list tools
126+
toolsResult, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
127+
if err != nil {
128+
log.Fatalf("Failed to list tools: %v", err)
129+
}
130+
log.Printf("Available tools:")
131+
for _, tool := range toolsResult.Tools {
132+
log.Printf(" - %s: %s", tool.Name, tool.Description)
133+
}
134+
135+
// call server tool
136+
request := mcp.CallToolRequest{}
137+
request.Params.Name = "roots"
138+
request.Params.Arguments = map[string]any{"testonly": "yes"}
139+
result, err := mcpClient.CallTool(ctx, request)
140+
if err != nil {
141+
log.Fatalf("failed to call tool roots: %v", err)
142+
} else if result.IsError {
143+
log.Printf("tool reported error")
144+
} else if len(result.Content) > 0 {
145+
resultStr := ""
146+
for _, content := range result.Content {
147+
switch tc := content.(type) {
148+
case mcp.TextContent:
149+
resultStr += fmt.Sprintf("%s\n", tc.Text)
150+
}
151+
}
152+
fmt.Printf("client call tool result: %s\n", resultStr)
153+
}
154+
155+
// mock the root change
156+
if err := mcpClient.RootListChanges(ctx); err != nil {
157+
log.Printf("failed to notify root list change: %v", err)
158+
}
159+
160+
// Keep running until cancelled by signal
161+
<-ctx.Done()
162+
log.Println("Client context cancelled")
163+
}

0 commit comments

Comments
 (0)