Skip to content

Commit 603603a

Browse files
committed
feat(mcp): add SSE server and multi-server client
Implements MCP server with hello_world tool and client with multi-server connection support
1 parent 19734f4 commit 603603a

File tree

6 files changed

+224
-77
lines changed

6 files changed

+224
-77
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.24.1
55
require (
66
github.com/a-h/templ v0.3.857
77
github.com/mattn/go-sqlite3 v1.14.27
8+
github.com/mark3labs/mcp-go v0.18.0
89
github.com/rs/zerolog v1.34.0
910
go.uber.org/fx v1.23.0
1011
)
@@ -16,4 +17,6 @@ require (
1617
go.uber.org/multierr v1.11.0 // indirect
1718
go.uber.org/zap v1.27.0 // indirect
1819
golang.org/x/sys v0.32.0 // indirect
20+
github.com/google/uuid v1.6.0 // indirect
21+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
1922
)

go.sum

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
66
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
77
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
88
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
9+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
10+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
11+
github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao=
12+
github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
13+
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
914
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
1015
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
1116
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -21,10 +26,12 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
2126
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
2227
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
2328
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
24-
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
25-
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
26-
go.uber.org/dig v1.18.1 h1:rLww6NuajVjeQn+49u5NcezUJEGwd5uXmyoCKW2g5Es=
27-
go.uber.org/dig v1.18.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
29+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
30+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
31+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
32+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
33+
go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw=
34+
go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
2835
go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg=
2936
go.uber.org/fx v1.23.0/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU=
3037
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=

internal/app/app.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import (
1111
"github.com/co-browser/agent-browser/internal/config"
1212
"github.com/co-browser/agent-browser/internal/events"
1313
"github.com/co-browser/agent-browser/internal/log"
14-
"github.com/co-browser/agent-browser/internal/mcp/client"
15-
"github.com/co-browser/agent-browser/internal/updater"
14+
"github.com/co-browser/agent-browser/internal/mcp"
1615
"github.com/co-browser/agent-browser/internal/web"
1716
"github.com/co-browser/agent-browser/internal/web/handlers"
1817

@@ -87,8 +86,11 @@ var BackendModule = fx.Module("backend",
8786
// MCPClientModule provides the MCP client (distinct from server frontend).
8887
var MCPClientModule = fx.Module("mcp_client",
8988
fx.Provide(
90-
client.NewHTTPClient,
89+
mcp.NewServer,
9190
),
91+
fx.Invoke(func(server *mcp.Server, lc fx.Lifecycle) error {
92+
return server.Start(lc)
93+
}),
9294
)
9395

9496
// UpdaterModule provides the tool and server updater service.

internal/mcp/client/client.go

Lines changed: 5 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,76 +2,11 @@
22
package client
33

44
import (
5-
"encoding/json"
6-
"fmt"
7-
"net/http"
8-
"time"
9-
10-
"github.com/co-browser/agent-browser/internal/backend/models"
11-
"github.com/co-browser/agent-browser/internal/log" // Import log
5+
"github.com/mark3labs/mcp-go/client"
126
)
137

14-
// Client defines the interface for fetching tool metadata from an MCP server.
15-
type Client interface {
16-
// FetchTools retrieves the list of tools from the given MCP server URL.
17-
// It should return an error if the fetch fails or the response is invalid.
18-
FetchTools(serverURL string) ([]models.FetchedTool, error)
19-
}
20-
21-
// httpClient implements the Client interface using standard net/http.
22-
type httpClient struct {
23-
client *http.Client
24-
logger log.Logger // Inject logger
25-
}
26-
27-
// NewHTTPClient creates a new MCP client with a default HTTP timeout.
28-
func NewHTTPClient(logger log.Logger) Client { // Accept logger
29-
return &httpClient{
30-
client: &http.Client{
31-
Timeout: 15 * time.Second, // Sensible default timeout
32-
},
33-
logger: logger, // Store logger
34-
}
35-
}
36-
37-
// FetchTools implements the Client interface.
38-
// This assumes the MCP server exposes a simple GET endpoint (e.g., /tools) returning JSON.
39-
// This might need significant adjustments based on the actual MCP protocol.
40-
func (c *httpClient) FetchTools(serverURL string) ([]models.FetchedTool, error) {
41-
// Assume a standard endpoint like /tools, trim trailing slash if present
42-
baseURL := serverURL
43-
if baseURL[len(baseURL)-1] == '/' {
44-
baseURL = baseURL[:len(baseURL)-1]
45-
}
46-
targetURL := baseURL + "/tools" // TODO: Make this configurable or discoverable?
47-
48-
c.logger.Debug().Str("url", targetURL).Msg("Fetching tools") // Use logger
49-
req, err := http.NewRequest("GET", targetURL, nil)
50-
if err != nil {
51-
// Log error if desired, but return wrapped error regardless
52-
return nil, fmt.Errorf("failed to create request for %s: %w", targetURL, err)
53-
}
54-
// TODO: Add any necessary headers (e.g., Accept: application/json)
55-
req.Header.Set("Accept", "application/json")
56-
57-
resp, err := c.client.Do(req)
58-
if err != nil {
59-
return nil, fmt.Errorf("failed to fetch tools from %s: %w", targetURL, err)
60-
}
61-
defer func() {
62-
_ = resp.Body.Close() // Explicitly ignore Close error
63-
}()
64-
65-
if resp.StatusCode != http.StatusOK {
66-
// TODO: Read response body for more error details?
67-
return nil, fmt.Errorf("failed to fetch tools from %s: received status code %d", targetURL, resp.StatusCode)
68-
}
69-
70-
var fetchedTools []models.FetchedTool
71-
if err := json.NewDecoder(resp.Body).Decode(&fetchedTools); err != nil {
72-
return nil, fmt.Errorf("failed to decode tools response from %s: %w", targetURL, err)
73-
}
8+
// Re-export the SSEMCPClient from mark3labs/mcp-go/client
9+
type SSEMCPClient = client.SSEMCPClient
7410

75-
c.logger.Info().Int("count", len(fetchedTools)).Str("url", targetURL).Msg("Successfully fetched tools") // Use logger
76-
return fetchedTools, nil
77-
}
11+
// NewSSEMCPClient creates a new SSE MCP client
12+
var NewSSEMCPClient = client.NewSSEMCPClient

internal/mcp/client/client_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package client
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
"github.com/mark3labs/mcp-go/mcp"
10+
)
11+
12+
// MCPConnection represents a connection to an MCP server
13+
type MCPConnection struct {
14+
client *SSEMCPClient
15+
url string
16+
ctx context.Context
17+
cancel context.CancelFunc
18+
}
19+
20+
func setupMCPClient(t *testing.T, url string) *MCPConnection {
21+
// Create a context for this specific connection
22+
ctx, cancel := context.WithCancel(context.Background())
23+
24+
client, err := NewSSEMCPClient(url)
25+
if err != nil {
26+
cancel()
27+
t.Fatalf("Failed to create client for %s: %v", url, err)
28+
}
29+
30+
if err := client.Start(ctx); err != nil {
31+
cancel()
32+
t.Fatalf("Failed to start client for %s: %v", url, err)
33+
}
34+
35+
// Initialize the client
36+
initRequest := mcp.InitializeRequest{}
37+
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
38+
initRequest.Params.ClientInfo = mcp.Implementation{
39+
Name: "test-client",
40+
Version: "1.0.0",
41+
}
42+
43+
result, err := client.Initialize(ctx, initRequest)
44+
if err != nil {
45+
cancel()
46+
t.Fatalf("Failed to initialize client for %s: %v", url, err)
47+
}
48+
t.Logf("Connected to server at %s: %s", url, result.ServerInfo.Name)
49+
50+
return &MCPConnection{
51+
client: client,
52+
url: url,
53+
ctx: ctx,
54+
cancel: cancel,
55+
}
56+
}
57+
58+
func TestMultipleMCPServers(t *testing.T) {
59+
// Define our MCP servers
60+
servers := []string{
61+
"http://0.0.0.0:8000/sse",
62+
}
63+
64+
// Create connections to all servers
65+
connections := make([]*MCPConnection, 0, len(servers))
66+
for _, url := range servers {
67+
conn := setupMCPClient(t, url)
68+
connections = append(connections, conn)
69+
// Ensure we clean up each connection properly
70+
defer func(conn *MCPConnection) {
71+
conn.cancel() // Cancel the context first
72+
conn.client.Close() // Then close the client
73+
}(conn)
74+
}
75+
76+
// Create a WaitGroup to wait for all concurrent operations
77+
var wg sync.WaitGroup
78+
79+
// Test ListTools on all servers concurrently
80+
for _, conn := range connections {
81+
wg.Add(1)
82+
go func(conn *MCPConnection) {
83+
defer wg.Done()
84+
85+
// Create a timeout context for this specific operation
86+
opCtx, opCancel := context.WithTimeout(conn.ctx, 5*time.Second)
87+
defer opCancel()
88+
89+
// Test ListTools
90+
toolsRequest := mcp.ListToolsRequest{}
91+
toolsResult, err := conn.client.ListTools(opCtx, toolsRequest)
92+
if err != nil {
93+
t.Errorf("Failed to list tools for %s: %v", conn.url, err)
94+
return
95+
}
96+
97+
// Log the available tools
98+
t.Logf("Available tools for %s:", conn.url)
99+
for _, tool := range toolsResult.Tools {
100+
t.Logf("- %s: %s", tool.Name, tool.Description)
101+
}
102+
}(conn)
103+
}
104+
105+
// Wait for all operations to complete
106+
wg.Wait()
107+
}

internal/mcp/server.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,95 @@
11
// Package mcp implements the MCP server logic.
22
package mcp
3+
4+
import (
5+
"context"
6+
"errors"
7+
"fmt"
8+
9+
"github.com/co-browser/agent-browser/internal/log"
10+
"github.com/mark3labs/mcp-go/mcp"
11+
"github.com/mark3labs/mcp-go/server"
12+
"go.uber.org/fx"
13+
)
14+
15+
// Server wraps the MCP server instance
16+
type Server struct {
17+
mcpServer *server.MCPServer
18+
sseServer *server.SSEServer
19+
logger log.Logger
20+
addr string
21+
}
22+
23+
// ServerParams contains the parameters for creating a new Server
24+
type ServerParams struct {
25+
fx.In
26+
Logger log.Logger
27+
}
28+
29+
// NewServer creates a new MCP server with configured tools
30+
func NewServer(p ServerParams) *Server {
31+
// Create MCP server
32+
mcpServer := server.NewMCPServer(
33+
"CoBrowser Agent 🚀",
34+
"1.0.0",
35+
server.WithToolCapabilities(true),
36+
)
37+
38+
// Add hello world tool
39+
tool := mcp.NewTool("hello_world",
40+
mcp.WithDescription("Say hello to someone"),
41+
mcp.WithString("name",
42+
mcp.Required(),
43+
mcp.Description("Name of the person to greet"),
44+
),
45+
)
46+
47+
// Add tool handler
48+
mcpServer.AddTool(tool, helloHandler)
49+
50+
// Create SSE server
51+
sseServer := server.NewSSEServer(mcpServer)
52+
53+
return &Server{
54+
mcpServer: mcpServer,
55+
sseServer: sseServer,
56+
logger: p.Logger,
57+
addr: ":8087", // Default to same port as web server
58+
}
59+
}
60+
61+
// SetAddr sets the address for the server to listen on
62+
func (s *Server) SetAddr(addr string) {
63+
s.addr = addr
64+
}
65+
66+
// Start starts the MCP server
67+
func (s *Server) Start(lc fx.Lifecycle) error {
68+
lc.Append(fx.Hook{
69+
OnStart: func(ctx context.Context) error {
70+
// Start the server in a goroutine
71+
go func() {
72+
s.logger.Info().Str("addr", s.addr).Msg("Starting MCP SSE server")
73+
if err := s.sseServer.Start(s.addr); err != nil {
74+
s.logger.Fatal().Err(err).Msg("MCP SSE server Start() failed")
75+
}
76+
}()
77+
return nil
78+
},
79+
OnStop: func(ctx context.Context) error {
80+
s.logger.Info().Msg("Shutting down MCP server...")
81+
// The SSE server doesn't provide a way to gracefully shutdown yet
82+
return nil
83+
},
84+
})
85+
return nil
86+
}
87+
88+
func helloHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
89+
name, ok := request.Params.Arguments["name"].(string)
90+
if !ok {
91+
return nil, errors.New("name must be a string")
92+
}
93+
94+
return mcp.NewToolResultText(fmt.Sprintf("Hello, %s!", name)), nil
95+
}

0 commit comments

Comments
 (0)