Skip to content

Commit 1c9fed0

Browse files
authored
SANDBOX-1567: Add stateless mode configuration (#39)
* Add stateless mode configuration and e2e Signed-off-by: Feny Mehta <fbm3307@gmail.com>
1 parent d4196ff commit 1c9fed0

6 files changed

Lines changed: 232 additions & 23 deletions

File tree

Containerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ ENV ARGOCD_URL=https://argocd-server
3232
ENV ARGOCD_TOKEN=secure-token
3333
ENV ARGOCD_MCP_SERVER_INSECURE=false
3434
ENV ARGOCD_MCP_SERVER_DEBUG=false
35+
ENV ARGOCD_MCP_SERVER_STATELESS=false
3536
ENV ARGOCD_MCP_SERVER_LISTEN_HOST=127.0.0.1
3637
ENV ARGOCD_MCP_SERVER_LISTEN_PORT=8080
3738

@@ -40,4 +41,4 @@ USER 1001
4041

4142
EXPOSE ${ARGOCD_MCP_SERVER_LISTEN_PORT}
4243

43-
CMD /usr/local/bin/argocd-mcp-server --transport http --argocd-url $ARGOCD_URL --argocd-token $ARGOCD_TOKEN --insecure $ARGOCD_MCP_SERVER_INSECURE --debug $ARGOCD_MCP_SERVER_DEBUG --listen $ARGOCD_MCP_SERVER_LISTEN_HOST:$ARGOCD_MCP_SERVER_LISTEN_PORT
44+
CMD /usr/local/bin/argocd-mcp-server --transport http --argocd-url $ARGOCD_URL --argocd-token $ARGOCD_TOKEN --insecure=$ARGOCD_MCP_SERVER_INSECURE --debug=$ARGOCD_MCP_SERVER_DEBUG --stateless=$ARGOCD_MCP_SERVER_STATELESS --listen $ARGOCD_MCP_SERVER_LISTEN_HOST:$ARGOCD_MCP_SERVER_LISTEN_PORT

cmd/start_server.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
)
1818

1919
var transport, listen, argocdURL, argocdToken string
20-
var argocdInsecure, debug bool
20+
var argocdInsecure, debug, stateless bool
2121

2222
func init() {
2323
startServerCmd.Flags().StringVar(&argocdURL, "argocd-url", "", "Specify the URL of the Argo CD server to query (required)")
@@ -30,6 +30,7 @@ func init() {
3030
}
3131
startServerCmd.Flags().BoolVar(&argocdInsecure, "insecure", false, "Allow insecure TLS connections to the Argo CD server")
3232
startServerCmd.Flags().BoolVar(&debug, "debug", false, "Enable debug mode")
33+
startServerCmd.Flags().BoolVar(&stateless, "stateless", false, "Enable stateless mode where the server does not send change notifications (required for multiple replicas)")
3334
startServerCmd.Flags().StringVar(&transport, "transport", "http", "Choose between 'stdio' or 'http' transport")
3435
startServerCmd.Flags().StringVar(&listen, "listen", "127.0.0.1:8080", "Specify the host and port to listen on when using the 'http' transport")
3536
}
@@ -59,15 +60,18 @@ var startServerCmd = &cobra.Command{
5960
logger := slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{
6061
Level: lvl,
6162
}))
62-
logger.Info("starting the Argo CD MCP server", "transport", transport, "listen", listen, "argocd-url", argocdURL, "insecure", argocdInsecure, "debug", debug)
63+
logger.Info("starting the Argo CD MCP server", "transport", transport, "listen", listen, "argocd-url", argocdURL, "insecure", argocdInsecure, "debug", debug, "stateless", stateless)
6364
if debug {
6465
lvl.Set(slog.LevelDebug)
6566
logger.Debug("debug mode enabled")
6667
}
6768
cl := argocd.NewClient(argocdURL, argocdToken, argocdInsecure)
68-
srv := server.New(logger, cl)
69+
srv := server.New(logger, cl, stateless)
6970
switch transport {
7071
case "stdio":
72+
if stateless {
73+
return fmt.Errorf("stateless mode is not supported for stdio transport")
74+
}
7175
t := &mcp.LoggingTransport{
7276
Transport: &mcp.StdioTransport{},
7377
Writer: cmd.ErrOrStderr(),
@@ -79,9 +83,17 @@ var startServerCmd = &cobra.Command{
7983
mux := http.NewServeMux()
8084

8185
// MCP endpoint
86+
// Stateless mode configuration from server settings.
87+
// When Stateless is true, the server will not send notifications to clients
88+
// (e.g., tools/list_changed, prompts/list_changed). This disables dynamic
89+
// tool and prompt updates but is useful for container deployments, load
90+
// balancing, and serverless environments where maintaining client state
91+
// is not desired or possible.
8292
mux.Handle("/mcp", mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
8393
return srv
84-
}, nil))
94+
}, &mcp.StreamableHTTPOptions{
95+
Stateless: stateless,
96+
}))
8597

8698
// Metrics endpoint
8799
mux.Handle("/metrics", promhttp.Handler())

compose.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,35 @@ services:
4040
- ARGOCD_MCP_SERVER_DEBUG=true
4141
- ARGOCD_MCP_SERVER_LISTEN_HOST=0.0.0.0 # here we need to bind to 0.0.0.0 to be able to connect to the server from the host machine
4242
- ARGOCD_MCP_SERVER_LISTEN_PORT=8080
43+
argocd-mcp-server-stateless-replica1:
44+
# instance of the Argo CD MCP server with a valid URL for the Argo CD server and stateless mode enabled
45+
image: argocd-mcp-server
46+
environment:
47+
- ARGOCD_URL=http://argocd-server-mock:50084
48+
- ARGOCD_TOKEN=secure-token
49+
- ARGOCD_MCP_SERVER_INSECURE=true
50+
- ARGOCD_MCP_SERVER_DEBUG=true
51+
- ARGOCD_MCP_SERVER_STATELESS=true
52+
- ARGOCD_MCP_SERVER_LISTEN_HOST=0.0.0.0
53+
- ARGOCD_MCP_SERVER_LISTEN_PORT=8080
54+
argocd-mcp-server-stateless-replica2:
55+
# instance of the Argo CD MCP server with a valid URL for the Argo CD server and stateless mode enabled
56+
image: argocd-mcp-server
57+
environment:
58+
- ARGOCD_URL=http://argocd-server-mock:50084
59+
- ARGOCD_TOKEN=secure-token
60+
- ARGOCD_MCP_SERVER_INSECURE=true
61+
- ARGOCD_MCP_SERVER_DEBUG=true
62+
- ARGOCD_MCP_SERVER_STATELESS=true
63+
- ARGOCD_MCP_SERVER_LISTEN_HOST=0.0.0.0
64+
- ARGOCD_MCP_SERVER_LISTEN_PORT=8080
65+
load-balancer:
66+
# load balancer to balance the traffic between the Argo CD MCP server instances
67+
image: nginx:alpine
68+
ports:
69+
- "50090:80"
70+
volumes:
71+
- ./test/e2e/nginx.conf:/etc/nginx/nginx.conf:ro,z
72+
depends_on:
73+
- argocd-mcp-server-stateless-replica1
74+
- argocd-mcp-server-stateless-replica2

internal/server/server.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,21 @@ import (
99
"github.com/modelcontextprotocol/go-sdk/mcp"
1010
)
1111

12-
func New(logger *slog.Logger, cl *argocd.Client) *mcp.Server {
12+
func New(logger *slog.Logger, cl *argocd.Client, stateless bool) *mcp.Server {
13+
// Configure server capabilities based on stateless mode
14+
// When stateless is true, disable ListChanged notifications for tools and prompts
15+
// This prevents the server from attempting to send notifications that may not
16+
// reach the client in multi-replica deployments
1317
s := mcp.NewServer(
1418
&mcp.Implementation{
1519
Name: "argocd-mcp-server",
1620
Version: "0.1",
1721
},
1822
&mcp.ServerOptions{
23+
Capabilities: &mcp.ServerCapabilities{
24+
Tools: &mcp.ToolCapabilities{ListChanged: !stateless},
25+
Prompts: &mcp.PromptCapabilities{ListChanged: !stateless},
26+
},
1927
InitializedHandler: func(_ context.Context, ir *mcp.InitializedRequest) {
2028
logger.Debug("initialized", "session_id", ir.Session.ID())
2129
},

test/e2e/nginx.conf

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
events {
2+
worker_connections 1024;
3+
}
4+
5+
http {
6+
# Define upstream servers for round-robin load balancing
7+
upstream mcp_backends {
8+
# Round-robin by default
9+
server argocd-mcp-server-stateless-replica1:8080;
10+
server argocd-mcp-server-stateless-replica2:8080;
11+
}
12+
13+
# Access log for debugging
14+
access_log /var/log/nginx/access.log;
15+
error_log /var/log/nginx/error.log debug;
16+
17+
server {
18+
listen 80;
19+
server_name localhost;
20+
21+
# Proxy all endpoints to backend servers
22+
location / {
23+
proxy_pass http://mcp_backends;
24+
proxy_http_version 1.1;
25+
proxy_set_header Host $host;
26+
proxy_set_header Connection '';
27+
28+
# SSE support for MCP streaming
29+
proxy_buffering off;
30+
proxy_cache off;
31+
chunked_transfer_encoding on;
32+
}
33+
}
34+
}

test/e2e/server_test.go

Lines changed: 139 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ import (
2323
// Note: make sure you ran `task install` before running this test
2424
// ------------------------------------------------------------------------------------------------
2525

26-
func TestServer(t *testing.T) {
26+
// TestServer verifies basic MCP functionality with both stdio and http transports.
27+
// Both transports run in stateful mode (ListChanged enabled) and test:
28+
// - Tool calls (unhealthyApplications, unhealthyApplicationResources)
29+
// - Error handling (argocd-error, unreachable scenarios)
30+
// - Metrics collection (for http transport)
31+
// - Session reuse across multiple tool calls
32+
func TestStatefulServer(t *testing.T) {
2733

2834
testdata := []struct {
2935
name string
@@ -35,11 +41,16 @@ func TestServer(t *testing.T) {
3541
},
3642
{
3743
name: "http",
38-
init: newHTTPSession("http://localhost:50081/mcp"),
44+
init: func(t *testing.T) *mcp.ClientSession {
45+
ctx := context.Background()
46+
session, err := newHTTPSession(ctx, "http://localhost:50081/mcp", "e2e-test-client")
47+
require.NoError(t, err)
48+
return session
49+
},
3950
},
4051
}
4152

42-
// test stdio and http transports with a valid Argo CD client
53+
// Test stdio and http transports with a valid Argo CD client (stateful mode)
4354
for _, td := range testdata {
4455
t.Run(td.name, func(t *testing.T) {
4556
// given
@@ -212,6 +223,11 @@ func TestServer(t *testing.T) {
212223
assert.Equal(t, mcpCallsDurationSecondsInfBucketBefore+1, mcpCallsDurationSecondsInfBucketAfter)
213224
}
214225
})
226+
227+
t.Run("verify/capabilities/listChanged", func(t *testing.T) {
228+
// Both stdio and http transports use stateful mode by default
229+
assertListChanged(t, session, true)
230+
})
215231
})
216232
}
217233

@@ -225,7 +241,12 @@ func TestServer(t *testing.T) {
225241
},
226242
{
227243
name: "http-unreachable",
228-
init: newHTTPSession("http://localhost:50082/mcp"), // invalid URL and token for the Argo CD server
244+
init: func(t *testing.T) *mcp.ClientSession {
245+
ctx := context.Background()
246+
session, err := newHTTPSession(ctx, "http://localhost:50082/mcp", "e2e-test-client")
247+
require.NoError(t, err)
248+
return session
249+
}, // invalid URL and token for the Argo CD server
229250
},
230251
}
231252

@@ -250,6 +271,55 @@ func TestServer(t *testing.T) {
250271
}
251272
}
252273

274+
// TestStateless verifies that multiple stateless server instances work correctly
275+
// with load balancing across replicas. This comprehensive test validates:
276+
// - Initialize response and capabilities with no ListChanged notifications
277+
// - Session reuse across multiple requests (list tools and call tools)
278+
// - Tools functionality with content validation
279+
func TestStatelessServer(t *testing.T) {
280+
ctx := context.Background()
281+
serverURL := "http://localhost:50090/mcp"
282+
283+
// Initialize a single session for the entire test
284+
session, err := newHTTPSession(ctx, serverURL, "e2e-test-stateless")
285+
require.NoError(t, err)
286+
defer session.Close()
287+
288+
// Step 1: Validate initialize response for stateless mode
289+
assertInitializeResponse(t, session, true)
290+
291+
// Step 2: Verify ListChanged is false (no notifications available in stateless mode)
292+
assertListChanged(t, session, false)
293+
294+
// Step 3: Verify session can be reused by listing tools multiple times
295+
for i := 0; i < 5; i++ {
296+
tools, listErr := session.ListTools(ctx, &mcp.ListToolsParams{})
297+
require.NoError(t, listErr, "should list tools on request %d", i)
298+
assert.NotEmpty(t, tools.Tools, "should have tools on request %d", i)
299+
}
300+
301+
// Step 4: Verify tools work correctly with content validation
302+
result, callErr := session.CallTool(ctx, &mcp.CallToolParams{
303+
Name: "unhealthyApplications",
304+
})
305+
require.NoError(t, callErr)
306+
require.False(t, result.IsError, "tool call should succeed")
307+
assert.NotEmpty(t, result.Content, "tool should return content")
308+
309+
// Verify the content is correct
310+
expectedContent := map[string]any{
311+
"degraded": []any{"a-degraded-application", "another-degraded-application"},
312+
"progressing": []any{"a-progressing-application", "another-progressing-application"},
313+
"outOfSync": []any{"an-out-of-sync-application", "another-out-of-sync-application"},
314+
}
315+
expectedContentText, marshalErr := json.Marshal(expectedContent)
316+
require.NoError(t, marshalErr)
317+
318+
resultContent, ok := result.Content[0].(*mcp.TextContent)
319+
require.True(t, ok)
320+
assert.JSONEq(t, string(expectedContentText), resultContent.Text)
321+
}
322+
253323
func getMetrics(t *testing.T, mcpServerURL string, labels map[string]string) (int64, int64) { //nolint:unparam
254324
labelStrings := make([]string, 0, 2*len(labels))
255325
for k, v := range labels {
@@ -287,19 +357,6 @@ func newStdioSession(mcpServerDebug bool, argocdURL string, argocdToken string,
287357
}
288358
}
289359

290-
func newHTTPSession(mcpServerURL string) func(*testing.T) *mcp.ClientSession {
291-
return func(t *testing.T) *mcp.ClientSession {
292-
ctx := context.Background()
293-
cl := mcp.NewClient(&mcp.Implementation{Name: "e2e-test-client", Version: "v1.0.0"}, nil)
294-
session, err := cl.Connect(ctx, &mcp.StreamableClientTransport{
295-
MaxRetries: 5,
296-
Endpoint: mcpServerURL,
297-
}, nil)
298-
require.NoError(t, err)
299-
return session
300-
}
301-
}
302-
303360
func newStdioServerCmd(ctx context.Context, mcpServerDebug bool, argocdURL string, argocdToken string, argocdInsecureURL bool) *exec.Cmd {
304361
return exec.CommandContext(ctx, //nolint:gosec
305362
"argocd-mcp-server",
@@ -310,3 +367,68 @@ func newStdioServerCmd(ctx context.Context, mcpServerDebug bool, argocdURL strin
310367
"--insecure", strconv.FormatBool(argocdInsecureURL),
311368
)
312369
}
370+
371+
func newHTTPSession(ctx context.Context, endpoint, clientName string) (*mcp.ClientSession, error) {
372+
client := mcp.NewClient(&mcp.Implementation{
373+
Name: clientName,
374+
Version: "1.0.0",
375+
}, nil)
376+
return client.Connect(ctx, &mcp.StreamableClientTransport{
377+
MaxRetries: 5,
378+
Endpoint: endpoint,
379+
}, nil)
380+
}
381+
382+
func assertListChanged(t *testing.T, session *mcp.ClientSession, expected bool) {
383+
t.Helper()
384+
initResult := session.InitializeResult()
385+
require.NotNil(t, initResult, "should have initialize result")
386+
require.NotNil(t, initResult.Capabilities, "should have capabilities")
387+
388+
if initResult.Capabilities.Tools != nil {
389+
assert.Equal(t, expected, initResult.Capabilities.Tools.ListChanged,
390+
"Tools.ListChanged should be %t", expected)
391+
}
392+
if initResult.Capabilities.Prompts != nil {
393+
assert.Equal(t, expected, initResult.Capabilities.Prompts.ListChanged,
394+
"Prompts.ListChanged should be %t", expected)
395+
}
396+
}
397+
398+
// assertInitializeResponse performs comprehensive validation of the initialize response
399+
func assertInitializeResponse(t *testing.T, session *mcp.ClientSession, stateless bool) {
400+
t.Helper()
401+
402+
initResult := session.InitializeResult()
403+
require.NotNil(t, initResult, "should have initialize result")
404+
405+
// Verify server info exists
406+
require.NotNil(t, initResult.ServerInfo, "should have server info")
407+
assert.NotEmpty(t, initResult.ServerInfo.Name, "server name should not be empty")
408+
assert.NotEmpty(t, initResult.ServerInfo.Version, "server version should not be empty")
409+
410+
// Verify protocol version exists
411+
assert.NotEmpty(t, initResult.ProtocolVersion, "protocol version should not be empty")
412+
413+
// Verify capabilities
414+
require.NotNil(t, initResult.Capabilities, "should have capabilities")
415+
416+
// In stateless mode: ListChanged should be false (no notifications)
417+
// In stateful mode: ListChanged should be true (notifications enabled)
418+
419+
// Tools capability
420+
require.NotNil(t, initResult.Capabilities.Tools, "should have tools capability")
421+
if stateless {
422+
assert.False(t, initResult.Capabilities.Tools.ListChanged, "stateless mode should have Tools.ListChanged=false")
423+
} else {
424+
assert.True(t, initResult.Capabilities.Tools.ListChanged, "stateful mode should have Tools.ListChanged=true")
425+
}
426+
427+
// Prompts capability
428+
require.NotNil(t, initResult.Capabilities.Prompts, "should have prompts capability")
429+
if stateless {
430+
assert.False(t, initResult.Capabilities.Prompts.ListChanged, "stateless mode should have Prompts.ListChanged=false")
431+
} else {
432+
assert.True(t, initResult.Capabilities.Prompts.ListChanged, "stateful mode should have Prompts.ListChanged=true")
433+
}
434+
}

0 commit comments

Comments
 (0)