package bridge_test import ( "context" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "time" "kode.naiv.no/olemd/forgejo-mcp-broker/internal/bridge" brokerlog "kode.naiv.no/olemd/forgejo-mcp-broker/internal/log" "kode.naiv.no/olemd/forgejo-mcp-broker/internal/supervisor" ) // TestBridge_AgainstRealForgejoMCP is the phase-4b acceptance test. It // drives a real `forgejo-mcp --transport stdio` subprocess through the // bridge and runs the canonical MCP handshake plus a tool call that // doesn't require Forgejo network access. // // Skipped under -short. Skipped entirely when no forgejo-mcp binary or // source tree is available — see findOrBuildForgejoMCP for the search. // // Driving the bridge confirms the opaque-pipe assumption holds in // practice: every JSON-RPC message we forward to the child gets a // response routed by id, with no protocol-level surprises. func TestBridge_AgainstRealForgejoMCP(t *testing.T) { if testing.Short() { t.Skip("integration test (~3s); rerun without -short") } binPath := findOrBuildForgejoMCP(t) // forgejo-mcp probes Forgejo (GET /api/v1/user) at startup. Stand up a // minimal fake instead of requiring a real Forgejo deployment. fakeForgejo := newFakeForgejoServer(t) child, err := supervisor.Start(t.Context(), supervisor.Config{ Cmd: []string{binPath, "--transport", "stdio", "--url", fakeForgejo.URL}, Env: []string{ "FORGEJO_ACCESS_TOKEN=integration-test-token", }, OnStderr: func(line string) { t.Logf("forgejo-mcp[stderr]: %s", line) }, }) if err != nil { t.Fatalf("Start forgejo-mcp: %v", err) } t.Cleanup(func() { stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = child.Stop(stopCtx) }) b := bridge.New(child.Stdin, child.Stdout, child.Done(), brokerlog.Discard()) b.Start() srv := httptest.NewServer(http.HandlerFunc(b.HandleSSE)) t.Cleanup(srv.Close) // 1. initialize — kicks off the MCP handshake. initBody := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"fjmcp-broker-test","version":"1"}}}` initResp := postSSEMustOK(t, srv.URL, initBody) if !strings.Contains(initResp, `"protocolVersion"`) { t.Errorf("initialize response missing protocolVersion: %s", initResp) } if !strings.Contains(initResp, `"serverInfo"`) { t.Errorf("initialize response missing serverInfo: %s", initResp) } // 2. notifications/initialized — required by the MCP spec before any // further requests. Bridge returns 204 for notifications. notified := postBody(t, srv.URL, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) if notified.StatusCode != http.StatusNoContent { t.Errorf("notifications/initialized status = %d, want 204", notified.StatusCode) } // 3. tools/list — must include the version tool. listBody := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` listResp := postSSEMustOK(t, srv.URL, listBody) if !strings.Contains(listResp, "get_forgejo_mcp_server_version") { t.Errorf("tools/list missing get_forgejo_mcp_server_version. Body: %s", listResp) } // 4. tools/call get_forgejo_mcp_server_version — does no Forgejo // network work, so the fake instance is irrelevant here. We just // confirm the call succeeds and a result comes back. callBody := `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_forgejo_mcp_server_version","arguments":{}}}` callResp := postSSEMustOK(t, srv.URL, callBody) if !strings.Contains(callResp, `"content"`) { t.Errorf("tools/call response missing content. Body: %s", callResp) } // Sanity: response should not be an error. if strings.Contains(callResp, `"isError":true`) { t.Errorf("tools/call returned isError=true. Body: %s", callResp) } } // findOrBuildForgejoMCP locates a forgejo-mcp binary the test can spawn. // Search order: // 1. $FORGEJO_MCP_BIN // 2. ../../../forgejo-mcp/forgejo-mcp (sibling repo with a built binary) // 3. go build of ../../../forgejo-mcp into a temp dir // Skips the test (not fails) if none of those work. func findOrBuildForgejoMCP(t *testing.T) string { t.Helper() if p := os.Getenv("FORGEJO_MCP_BIN"); p != "" { if _, err := os.Stat(p); err == nil { return p } t.Skipf("FORGEJO_MCP_BIN=%q does not exist", p) } // Sibling-repo binary. if abs, err := filepath.Abs("../../../forgejo-mcp/forgejo-mcp"); err == nil { if _, err := os.Stat(abs); err == nil { return abs } } // Sibling-repo source build. if abs, err := filepath.Abs("../../../forgejo-mcp"); err == nil { if _, err := os.Stat(filepath.Join(abs, "main.go")); err == nil { bin := filepath.Join(t.TempDir(), "forgejo-mcp") build := exec.Command("go", "build", "-o", bin, ".") build.Dir = abs build.Env = os.Environ() out, err := build.CombinedOutput() if err != nil { t.Skipf("go build of sibling forgejo-mcp failed: %v\n%s", err, out) } return bin } } t.Skip("forgejo-mcp binary not found: set $FORGEJO_MCP_BIN or place a sibling repo at ../forgejo-mcp") return "" } // newFakeForgejoServer stands up the minimum Forgejo API surface // forgejo-mcp probes during startup: // - GET /api/v1/version — the SDK requires the server be >= 1.11.0 // - GET /api/v1/user — VerifyConnection's authenticated probe // // Any other path responds 404 with a t.Log so future probes added // upstream show up clearly in test output. func newFakeForgejoServer(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"version":"11.0.0"}`) }) mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"id":1,"login":"integration-tester","username":"integration-tester","full_name":"Integration Tester","email":"itester@example.com","avatar_url":"https://example.com/a.png"}`) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { t.Logf("fake forgejo: unexpected probe %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv } // postSSEMustOK posts a JSON-RPC body, expects a 200 + text/event-stream // response, and returns the raw body. Fails the test on any other shape. func postSSEMustOK(t *testing.T, url, body string) string { t.Helper() resp := postBody(t, url, body) defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("POST %s: status %d, body %s", url, resp.StatusCode, b) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { t.Fatalf("POST %s: Content-Type %q, want text/event-stream", url, ct) } return string(b) } func postBody(t *testing.T, url, body string) *http.Response { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, strings.NewReader(body)) if err != nil { t.Fatalf("build request: %v", err) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST: %v", err) } return resp }