200 lines
7.2 KiB
Go
200 lines
7.2 KiB
Go
|
|
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
|
||
|
|
}
|
||
|
|
|