diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 8d038aaf05..bbbbaa0e75 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -2206,7 +2206,7 @@ jobs: path: /tmp/gh-aw/aw_info.json if-no-files-found: warn - name: Run AI Inference - uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v1 + uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4 env: GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 4cdc5646f7..da587dae86 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -6021,13 +6021,13 @@ jobs: - name: Download Go modules run: go mod download - name: Generate SBOM (SPDX format) - uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.10 + uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 with: artifact-name: sbom.spdx.json format: spdx-json output-file: sbom.spdx.json - name: Generate SBOM (CycloneDX format) - uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.10 + uses: anchore/sbom-action@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 with: artifact-name: sbom.cdx.json format: cyclonedx-json @@ -6234,7 +6234,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Release with gh-extension-precompile - uses: cli/gh-extension-precompile@9e2237c30f869ad3bcaed6a4be2cd43564dd421b # v2 + uses: cli/gh-extension-precompile@9e2237c30f869ad3bcaed6a4be2cd43564dd421b # v2.1.0 with: build_script_override: scripts/build-release.sh go_version_file: go.mod diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 3393e02f2e..f1fe7c40a4 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -176,7 +176,7 @@ jobs: ORGANIZATION: ${{ env.ORGANIZATION }} id: stale-repos name: Run stale_repos tool - uses: github/stale-repos@a21e55567b83cf3c3f3f9085d3038dc6cee02598 # v3 + uses: github/stale-repos@a21e55567b83cf3c3f3f9085d3038dc6cee02598 # v3.0.2 - env: INACTIVE_REPOS: ${{ steps.stale-repos.outputs.inactiveRepos }} name: Save stale repos output diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index 5824283b2c..129ba416e5 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -6151,7 +6151,7 @@ jobs: persist-credentials: false - name: Super-linter id: super-linter - uses: super-linter/super-linter@47984f49b4e87383eed97890fe2dca6063bbd9c3 # v8.2.1 + uses: super-linter/super-linter@47984f49b4e87383eed97890fe2dca6063bbd9c3 # v8.3.1 env: CREATE_LOG_FILE: "true" DEFAULT_BRANCH: main diff --git a/pkg/awmg/gateway.go b/pkg/awmg/gateway.go index 567ccfca4f..40fcdd1246 100644 --- a/pkg/awmg/gateway.go +++ b/pkg/awmg/gateway.go @@ -58,6 +58,7 @@ type GatewaySettings struct { type MCPGatewayServer struct { config *MCPGatewayConfig sessions map[string]*mcp.ClientSession + servers map[string]*mcp.Server // Proxy servers for each session mu sync.RWMutex logDir string } @@ -148,6 +149,7 @@ func runMCPGateway(configFiles []string, port int, logDir string) error { gateway := &MCPGatewayServer{ config: config, sessions: make(map[string]*mcp.ClientSession), + servers: make(map[string]*mcp.Server), logDir: logDir, } @@ -489,6 +491,12 @@ func (g *MCPGatewayServer) initializeSessions() error { g.sessions[serverName] = session g.mu.Unlock() + // Create a proxy MCP server that forwards calls to this session + proxyServer := g.createProxyServer(serverName, session) + g.mu.Lock() + g.servers[serverName] = proxyServer + g.mu.Unlock() + successCount++ gatewayLog.Printf("Successfully initialized session for %s (%d/%d)", serverName, successCount, len(g.config.MCPServers)) fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully initialized session for %s (%d/%d)", serverName, successCount, len(g.config.MCPServers)))) @@ -604,6 +612,100 @@ func (g *MCPGatewayServer) createMCPSession(serverName string, config MCPServerC return nil, fmt.Errorf("invalid server configuration: must specify command, url, or container") } +// createProxyServer creates a proxy MCP server that forwards all calls to the backend session +func (g *MCPGatewayServer) createProxyServer(serverName string, session *mcp.ClientSession) *mcp.Server { + gatewayLog.Printf("Creating proxy MCP server for %s", serverName) + + // Create a server that will proxy requests to the backend session + server := mcp.NewServer(&mcp.Implementation{ + Name: fmt.Sprintf("gateway-proxy-%s", serverName), + Version: GetVersion(), + }, &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{ + Tools: &mcp.ToolCapabilities{ + ListChanged: false, + }, + Resources: &mcp.ResourceCapabilities{ + Subscribe: false, + ListChanged: false, + }, + Prompts: &mcp.PromptCapabilities{ + ListChanged: false, + }, + }, + Logger: logger.NewSlogLoggerWithHandler(gatewayLog), + }) + + // Query backend for its tools and register them on the proxy server + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // List tools from backend + toolsResult, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + gatewayLog.Printf("Warning: Failed to list tools from backend %s: %v", serverName, err) + } else { + // Register each tool on the proxy server + for _, tool := range toolsResult.Tools { + toolCopy := tool // Capture for closure + gatewayLog.Printf("Registering tool %s from backend %s", tool.Name, serverName) + + server.AddTool(toolCopy, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + gatewayLog.Printf("Proxy %s: Calling tool %s on backend", serverName, req.Params.Name) + return session.CallTool(ctx, &mcp.CallToolParams{ + Name: req.Params.Name, + Arguments: req.Params.Arguments, + }) + }) + } + gatewayLog.Printf("Registered %d tools from backend %s", len(toolsResult.Tools), serverName) + } + + // List resources from backend + resourcesResult, err := session.ListResources(ctx, &mcp.ListResourcesParams{}) + if err != nil { + gatewayLog.Printf("Warning: Failed to list resources from backend %s: %v", serverName, err) + } else { + // Register each resource on the proxy server + for _, resource := range resourcesResult.Resources { + resourceCopy := resource // Capture for closure + gatewayLog.Printf("Registering resource %s from backend %s", resource.URI, serverName) + + server.AddResource(resourceCopy, func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + gatewayLog.Printf("Proxy %s: Reading resource %s from backend", serverName, req.Params.URI) + return session.ReadResource(ctx, &mcp.ReadResourceParams{ + URI: req.Params.URI, + }) + }) + } + gatewayLog.Printf("Registered %d resources from backend %s", len(resourcesResult.Resources), serverName) + } + + // List prompts from backend + promptsResult, err := session.ListPrompts(ctx, &mcp.ListPromptsParams{}) + if err != nil { + gatewayLog.Printf("Warning: Failed to list prompts from backend %s: %v", serverName, err) + } else { + // Register each prompt on the proxy server + for _, prompt := range promptsResult.Prompts { + promptCopy := prompt // Capture for closure + gatewayLog.Printf("Registering prompt %s from backend %s", prompt.Name, serverName) + + server.AddPrompt(promptCopy, func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + gatewayLog.Printf("Proxy %s: Getting prompt %s from backend", serverName, req.Params.Name) + return session.GetPrompt(ctx, &mcp.GetPromptParams{ + Name: req.Params.Name, + Arguments: req.Params.Arguments, + }) + }) + } + gatewayLog.Printf("Registered %d prompts from backend %s", len(promptsResult.Prompts), serverName) + } + + gatewayLog.Printf("Proxy MCP server created for %s", serverName) + return server +} + // startHTTPServer starts the HTTP server for the gateway func (g *MCPGatewayServer) startHTTPServer() error { port := g.config.Gateway.Port @@ -617,41 +719,62 @@ func (g *MCPGatewayServer) startHTTPServer() error { fmt.Fprintf(w, "OK") }) - // MCP endpoint for each server + // List servers endpoint + mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) { + g.handleListServers(w, r) + }) + + // Create StreamableHTTPHandler for each MCP server for serverName := range g.config.MCPServers { serverNameCopy := serverName // Capture for closure path := fmt.Sprintf("/mcp/%s", serverName) - gatewayLog.Printf("Registering endpoint: %s", path) - - mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - g.handleMCPRequest(w, r, serverNameCopy) + gatewayLog.Printf("Registering StreamableHTTPHandler endpoint: %s", path) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Registering StreamableHTTPHandler endpoint: %s", path))) + + // Create streamable HTTP handler for this server + handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { + // Get the proxy server for this backend + g.mu.RLock() + defer g.mu.RUnlock() + server, exists := g.servers[serverNameCopy] + if !exists { + gatewayLog.Printf("Server not found in handler: %s", serverNameCopy) + return nil + } + gatewayLog.Printf("Returning proxy server for: %s", serverNameCopy) + return server + }, &mcp.StreamableHTTPOptions{ + SessionTimeout: 2 * time.Hour, // Close idle sessions after 2 hours + Logger: logger.NewSlogLoggerWithHandler(gatewayLog), }) - } - // List servers endpoint - mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) { - g.handleListServers(w, r) - }) + // Add authentication middleware if API key is configured + if g.config.Gateway.APIKey != "" { + wrappedHandler := g.withAuth(handler, serverNameCopy) + mux.Handle(path, wrappedHandler) + } else { + mux.Handle(path, handler) + } + } - server := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: mux, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + ReadHeaderTimeout: 30 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, } fmt.Fprintf(os.Stderr, "%s\n", console.FormatSuccessMessage(fmt.Sprintf("MCP gateway listening on http://localhost:%d", port))) - gatewayLog.Printf("HTTP server ready on port %d", port) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Using StreamableHTTPHandler for MCP protocol")) + gatewayLog.Printf("HTTP server ready on port %d with StreamableHTTPHandler", port) - return server.ListenAndServe() + return httpServer.ListenAndServe() } -// handleMCPRequest handles an MCP protocol request for a specific server -func (g *MCPGatewayServer) handleMCPRequest(w http.ResponseWriter, r *http.Request, serverName string) { - gatewayLog.Printf("Handling MCP request for server: %s", serverName) - - // Check API key if configured - if g.config.Gateway.APIKey != "" { +// withAuth wraps an HTTP handler with authentication if API key is configured +func (g *MCPGatewayServer) withAuth(handler http.Handler, serverName string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") expectedAuth := fmt.Sprintf("Bearer %s", g.config.Gateway.APIKey) if authHeader != expectedAuth { @@ -659,142 +782,8 @@ func (g *MCPGatewayServer) handleMCPRequest(w http.ResponseWriter, r *http.Reque http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - } - - // Get the session - g.mu.RLock() - session, exists := g.sessions[serverName] - g.mu.RUnlock() - - if !exists { - gatewayLog.Printf("Server not found: %s", serverName) - http.Error(w, fmt.Sprintf("Server not found: %s", serverName), http.StatusNotFound) - return - } - - // Parse request body - var reqBody map[string]any - if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { - gatewayLog.Printf("Failed to decode request: %v", err) - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - method, _ := reqBody["method"].(string) - gatewayLog.Printf("MCP method: %s for server: %s", method, serverName) - - // Handle different MCP methods - var response any - var err error - - switch method { - case "initialize": - response, err = g.handleInitialize(session) - case "tools/list": - response, err = g.handleListTools(session) - case "tools/call": - response, err = g.handleCallTool(session, reqBody) - case "resources/list": - response, err = g.handleListResources(session) - case "prompts/list": - response, err = g.handleListPrompts(session) - default: - gatewayLog.Printf("Unsupported method: %s", method) - http.Error(w, fmt.Sprintf("Unsupported method: %s", method), http.StatusBadRequest) - return - } - - if err != nil { - gatewayLog.Printf("Error handling %s: %v", method, err) - http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusInternalServerError) - return - } - - // Send response - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - gatewayLog.Printf("Failed to encode JSON response: %v", err) - } -} - -// handleInitialize handles the initialize method -func (g *MCPGatewayServer) handleInitialize(session *mcp.ClientSession) (any, error) { - // Return server capabilities - return map[string]any{ - "protocolVersion": "2024-11-05", - "capabilities": map[string]any{ - "tools": map[string]any{}, - "resources": map[string]any{}, - "prompts": map[string]any{}, - }, - "serverInfo": map[string]any{ - "name": "mcp-gateway", - "version": GetVersion(), - }, - }, nil -} - -// handleListTools handles the tools/list method -func (g *MCPGatewayServer) handleListTools(session *mcp.ClientSession) (any, error) { - ctx := context.Background() - result, err := session.ListTools(ctx, &mcp.ListToolsParams{}) - if err != nil { - return nil, fmt.Errorf("failed to list tools: %w", err) - } - - return map[string]any{ - "tools": result.Tools, - }, nil -} - -// handleCallTool handles the tools/call method -func (g *MCPGatewayServer) handleCallTool(session *mcp.ClientSession, reqBody map[string]any) (any, error) { - params, ok := reqBody["params"].(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid params") - } - - name, _ := params["name"].(string) - arguments := params["arguments"] - - ctx := context.Background() - result, err := session.CallTool(ctx, &mcp.CallToolParams{ - Name: name, - Arguments: arguments, + handler.ServeHTTP(w, r) }) - if err != nil { - return nil, fmt.Errorf("failed to call tool: %w", err) - } - - return map[string]any{ - "content": result.Content, - }, nil -} - -// handleListResources handles the resources/list method -func (g *MCPGatewayServer) handleListResources(session *mcp.ClientSession) (any, error) { - ctx := context.Background() - result, err := session.ListResources(ctx, &mcp.ListResourcesParams{}) - if err != nil { - return nil, fmt.Errorf("failed to list resources: %w", err) - } - - return map[string]any{ - "resources": result.Resources, - }, nil -} - -// handleListPrompts handles the prompts/list method -func (g *MCPGatewayServer) handleListPrompts(session *mcp.ClientSession) (any, error) { - ctx := context.Background() - result, err := session.ListPrompts(ctx, &mcp.ListPromptsParams{}) - if err != nil { - return nil, fmt.Errorf("failed to list prompts: %w", err) - } - - return map[string]any{ - "prompts": result.Prompts, - }, nil } // handleListServers handles the /servers endpoint diff --git a/pkg/awmg/gateway_streamable_http_test.go b/pkg/awmg/gateway_streamable_http_test.go index bbec08c96c..8e55f13573 100644 --- a/pkg/awmg/gateway_streamable_http_test.go +++ b/pkg/awmg/gateway_streamable_http_test.go @@ -104,62 +104,48 @@ func TestStreamableHTTPTransport_GatewayConnection(t *testing.T) { } t.Logf("✓ Gateway has %d server(s): %v", len(servers), servers) - // Test 2: Test the MCP endpoint directly using HTTP POST + // Test 2: Connect to the MCP endpoint using StreamableClientTransport mcpURL := "http://localhost:8091/mcp/gh-aw" - t.Logf("Testing MCP endpoint: %s", mcpURL) + t.Logf("Testing MCP endpoint with StreamableClientTransport: %s", mcpURL) - // Send initialize request - initReq := map[string]any{ - "method": "initialize", - "params": map[string]any{}, + // Create streamable client transport + transport := &mcp.StreamableClientTransport{ + Endpoint: mcpURL, } - initReqJSON, _ := json.Marshal(initReq) - resp, err := http.Post(mcpURL, "application/json", strings.NewReader(string(initReqJSON))) - if err != nil { - t.Fatalf("Failed to send initialize request: %v", err) - } - defer resp.Body.Close() + // Create MCP client + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, nil) - if resp.StatusCode != http.StatusOK { - t.Fatalf("Initialize request failed: status=%d", resp.StatusCode) - } + // Connect to the gateway + connectCtx, connectCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer connectCancel() - var initResponse map[string]any - if err := json.NewDecoder(resp.Body).Decode(&initResponse); err != nil { - t.Fatalf("Failed to decode initialize response: %v", err) + session, err := client.Connect(connectCtx, transport, nil) + if err != nil { + cancel() + t.Fatalf("Failed to connect via StreamableClientTransport: %v", err) } - t.Logf("✓ Initialize response: %v", initResponse) + defer session.Close() - // Test 3: Send tools/list request - listToolsReq := map[string]any{ - "method": "tools/list", - "params": map[string]any{}, - } - listToolsReqJSON, _ := json.Marshal(listToolsReq) + t.Log("✓ Successfully connected via StreamableClientTransport") - toolsResp, err := http.Post(mcpURL, "application/json", strings.NewReader(string(listToolsReqJSON))) - if err != nil { - t.Fatalf("Failed to send tools/list request: %v", err) - } - defer toolsResp.Body.Close() + // Test listing tools + toolsCtx, toolsCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer toolsCancel() - if toolsResp.StatusCode != http.StatusOK { - t.Fatalf("tools/list request failed: status=%d", toolsResp.StatusCode) + toolsResult, err := session.ListTools(toolsCtx, &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("Failed to list tools: %v", err) } - var toolsResponse map[string]any - if err := json.NewDecoder(toolsResp.Body).Decode(&toolsResponse); err != nil { - t.Fatalf("Failed to decode tools/list response: %v", err) + if len(toolsResult.Tools) == 0 { + t.Error("Expected at least one tool from backend") } - t.Logf("✓ Tools/list response received with %d tools", len(toolsResponse)) - // Verify the response contains tools array - if tools, ok := toolsResponse["tools"].([]any); ok { - t.Logf("✓ Found %d tools in response", len(tools)) - } else { - t.Logf("Note: Tools response format: %v", toolsResponse) - } + t.Logf("✓ Found %d tools from backend via gateway", len(toolsResult.Tools)) t.Log("✓ All streamable HTTP transport tests completed successfully") @@ -438,3 +424,152 @@ This workflow tests the streamable HTTP transport via mcp inspect. t.Log("✓ MCP inspect test for streamable HTTP completed successfully") } + +// TestStreamableHTTPTransport_GatewayWithSDKClient tests that the gateway properly +// exposes backend servers via StreamableHTTPHandler and that we can connect to them +// using the go-sdk StreamableClientTransport. +func TestStreamableHTTPTransport_GatewayWithSDKClient(t *testing.T) { + // Get absolute path to binary + binaryPath, err := filepath.Abs(filepath.Join("..", "..", "gh-aw")) + if err != nil { + t.Fatalf("Failed to get absolute path: %v", err) + } + + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + t.Skipf("Skipping test: gh-aw binary not found at %s. Run 'make build' first.", binaryPath) + } + + // Create temporary directory for config + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "gateway-config.json") + + // Create gateway config with the gh-aw MCP server + config := MCPGatewayConfig{ + MCPServers: map[string]MCPServerConfig{ + "gh-aw": { + Command: binaryPath, + Args: []string{"mcp-server"}, + }, + }, + Gateway: GatewaySettings{ + Port: 8092, // Use a different port to avoid conflicts + }, + } + + configJSON, err := json.Marshal(config) + if err != nil { + t.Fatalf("Failed to marshal config: %v", err) + } + + if err := os.WriteFile(configFile, configJSON, 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Start the gateway in background + _, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + gatewayErrChan := make(chan error, 1) + go func() { + gatewayErrChan <- runMCPGateway([]string{configFile}, 8092, tmpDir) + }() + + // Wait for gateway to start + t.Log("Waiting for MCP gateway to start...") + time.Sleep(3 * time.Second) + + // Verify gateway health + healthResp, err := http.Get("http://localhost:8092/health") + if err != nil { + cancel() + t.Fatalf("Failed to connect to gateway health endpoint: %v", err) + } + healthResp.Body.Close() + + if healthResp.StatusCode != http.StatusOK { + cancel() + t.Fatalf("Gateway health check failed: status=%d", healthResp.StatusCode) + } + t.Log("✓ Gateway health check passed") + + // Now test connecting to the gateway using StreamableClientTransport + gatewayURL := "http://localhost:8092/mcp/gh-aw" + t.Logf("Connecting to gateway via StreamableClientTransport: %s", gatewayURL) + + // Create streamable client transport + transport := &mcp.StreamableClientTransport{ + Endpoint: gatewayURL, + } + + // Create MCP client + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, nil) + + // Connect to the gateway + connectCtx, connectCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer connectCancel() + + session, err := client.Connect(connectCtx, transport, nil) + if err != nil { + cancel() + t.Fatalf("Failed to connect to gateway via StreamableClientTransport: %v", err) + } + defer session.Close() + + t.Log("✓ Successfully connected to gateway via StreamableClientTransport") + + // Test listing tools + toolsCtx, toolsCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer toolsCancel() + + toolsResult, err := session.ListTools(toolsCtx, &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("Failed to list tools: %v", err) + } + + if len(toolsResult.Tools) == 0 { + t.Error("Expected at least one tool from gh-aw MCP server") + } + + t.Logf("✓ Successfully listed %d tools from backend via gateway", len(toolsResult.Tools)) + for i, tool := range toolsResult.Tools { + if i < 3 { // Log first 3 tools + t.Logf(" - %s: %s", tool.Name, tool.Description) + } + } + + // Test calling a tool (status tool should be available) + callCtx, callCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer callCancel() + + // Create a simple test by calling the status tool + callResult, err := session.CallTool(callCtx, &mcp.CallToolParams{ + Name: "status", + Arguments: map[string]any{}, + }) + if err != nil { + t.Logf("Note: Failed to call status tool (may not be in test environment): %v", err) + } else { + t.Logf("✓ Successfully called status tool via gateway") + if len(callResult.Content) > 0 { + t.Logf(" Tool returned %d content items", len(callResult.Content)) + } + } + + t.Log("✓ All StreamableHTTPHandler gateway tests completed successfully") + + // Clean up + cancel() + + // Wait for gateway to stop + select { + case err := <-gatewayErrChan: + if err != nil && err != http.ErrServerClosed && !strings.Contains(err.Error(), "context canceled") { + t.Logf("Gateway stopped with error: %v", err) + } + case <-time.After(3 * time.Second): + t.Log("Gateway shutdown timed out") + } +}