Files
synapbus/tests/integration/mcp_e2e_test.go
T
Algis DumbrisandClaude Opus 4.6 fa25487290 feat: MCP tool fixes + LinkedIn approval workflow (013)
SynapBus MCP improvements:
- react tool now returns workflow_state + reactions in response
- list_by_state properly filters by computed state (fixes
  cross-contamination bug)
- list_by_state supports include_messages parameter
- New get_replies MCP tool for thread reading
- New threads action category in registry

Deployment:
- v0.12.0-013 deployed to kubic
- #approve-linkedin-comment channel created with workflow enabled
- E2E tested: approve/reject reactions, state transitions, threading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 09:19:26 +02:00

730 lines
21 KiB
Go

// Package integration provides end-to-end tests for SynapBus that exercise the
// real MCP protocol over HTTP. Each test starts an in-process server with an
// in-memory SQLite database, registers agents, and makes JSON-RPC 2.0 calls
// to /mcp exactly as a real MCP client would.
package integration
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/actions"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/apikeys"
"github.com/synapbus/synapbus/internal/attachments"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/console"
"github.com/synapbus/synapbus/internal/jsruntime"
mcpserver "github.com/synapbus/synapbus/internal/mcp"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/search"
"github.com/synapbus/synapbus/internal/storage"
"github.com/synapbus/synapbus/internal/trace"
)
// ---------------------------------------------------------------------------
// Test harness
// ---------------------------------------------------------------------------
// testEnv holds a fully wired test server and helpers.
type testEnv struct {
server *httptest.Server
agentService *agents.AgentService
t *testing.T
}
// agentCreds stores an agent name and its raw API key.
type agentCreds struct {
Name string
APIKey string
}
// setupEnv creates an in-memory SynapBus server with all services wired up
// and returns a testEnv ready for MCP calls. The server is closed automatically
// when the test finishes.
func setupEnv(t *testing.T) *testEnv {
t.Helper()
ctx := context.Background()
// Quiet logging during tests
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
// In-memory SQLite with a unique name per test to avoid shared-cache collisions.
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
// Seed a test user (owner_id=1) so agent registration works.
if _, err := db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name)
VALUES (1, 'testowner', 'hash', 'Test Owner')`); err != nil {
t.Fatalf("seed user: %v", err)
}
// Create services
tracer := trace.NewTracer(db)
t.Cleanup(func() { tracer.Close() })
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, tracer)
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, tracer)
channelStore := channels.NewSQLiteChannelStore(db)
channelService := channels.NewService(channelStore, msgService, tracer)
taskStore := channels.NewSQLiteTaskStore(db)
swarmService := channels.NewSwarmService(taskStore, channelStore, tracer)
// Attachment service needs a temp dir for CAS.
tmpDir := t.TempDir()
cas, err := attachments.NewCAS(filepath.Join(tmpDir, "attachments"), slog.Default())
if err != nil {
t.Fatalf("create CAS: %v", err)
}
attStore := attachments.NewSQLiteStore(db, slog.Default())
attService := attachments.NewService(attStore, cas, slog.Default())
// Search service (FTS-only, no embedding provider).
searchService := search.NewService(db, nil, nil, msgService)
// API key service
apiKeyStore := apikeys.NewSQLiteStore(db)
apiKeyService := apikeys.NewService(apiKeyStore)
// Console printer (discard output during tests)
con := console.NewWithWriter(io.Discard)
// Create JS runtime pool and action registry
jsPool := jsruntime.NewPool(5)
t.Cleanup(func() { jsPool.Close() })
actionRegistry := actions.NewRegistry()
actionIndex := actions.NewIndex(actionRegistry.List())
// Create MCP server with 4 hybrid tools
mcpSrv := mcpserver.NewMCPServer(msgService, agentService, channelService, swarmService, attService, searchService, nil, nil, con, jsPool, actionRegistry, actionIndex, db)
t.Cleanup(func() {
mcpSrv.Shutdown(context.Background())
})
// Wire chi router -- same middleware as production
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(agents.OptionalAuthMiddlewareWithAPIKeys(agentService, apiKeyService))
r.Mount("/mcp", mcpSrv.Handler())
})
ts := httptest.NewServer(r)
t.Cleanup(ts.Close)
return &testEnv{
server: ts,
agentService: agentService,
t: t,
}
}
// registerAgent creates an agent via the service layer and returns its credentials.
func (e *testEnv) registerAgent(name, displayName string) agentCreds {
e.t.Helper()
_, apiKey, err := e.agentService.Register(context.Background(), name, displayName, "ai", nil, 1)
if err != nil {
e.t.Fatalf("register agent %q: %v", name, err)
}
return agentCreds{Name: name, APIKey: apiKey}
}
// ---------------------------------------------------------------------------
// MCP JSON-RPC 2.0 client helpers
// ---------------------------------------------------------------------------
// jsonRPCRequest is a JSON-RPC 2.0 request envelope.
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
// jsonRPCResponse is a JSON-RPC 2.0 response envelope.
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// mcpClient wraps an HTTP client with MCP protocol helpers.
type mcpClient struct {
baseURL string
apiKey string
sessionID string
nextID int
t *testing.T
}
// newMCPClient creates a client that speaks JSON-RPC 2.0 to the MCP endpoint.
func newMCPClient(t *testing.T, baseURL, apiKey string) *mcpClient {
t.Helper()
return &mcpClient{
baseURL: baseURL,
apiKey: apiKey,
nextID: 1,
t: t,
}
}
// Initialize performs the MCP initialize handshake and stores the session ID.
func (c *mcpClient) Initialize() {
c.t.Helper()
resp := c.rawCall("initialize", map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "integration-test",
"version": "1.0.0",
},
})
if resp.Error != nil {
c.t.Fatalf("initialize failed: code=%d message=%s", resp.Error.Code, resp.Error.Message)
}
}
// CallTool calls an MCP tool and returns the parsed text content.
func (c *mcpClient) CallTool(toolName string, args map[string]any) map[string]any {
c.t.Helper()
resp := c.rawCall("tools/call", map[string]any{
"name": toolName,
"arguments": args,
})
if resp.Error != nil {
c.t.Fatalf("tools/call %s: JSON-RPC error code=%d message=%s", toolName, resp.Error.Code, resp.Error.Message)
}
return c.parseToolResult(toolName, resp.Result)
}
// CallToolExpectError calls an MCP tool and expects an error result (isError=true).
func (c *mcpClient) CallToolExpectError(toolName string, args map[string]any) string {
c.t.Helper()
resp := c.rawCall("tools/call", map[string]any{
"name": toolName,
"arguments": args,
})
if resp.Error != nil {
c.t.Fatalf("tools/call %s: unexpected JSON-RPC error code=%d message=%s",
toolName, resp.Error.Code, resp.Error.Message)
}
// Parse result to see isError flag
var result struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(resp.Result, &result); err != nil {
c.t.Fatalf("unmarshal tool result: %v", err)
}
if !result.IsError {
c.t.Fatalf("expected tool error from %s, got success", toolName)
}
if len(result.Content) > 0 {
return result.Content[0].Text
}
return ""
}
// ListTools calls tools/list and returns the tool names.
func (c *mcpClient) ListTools() []string {
c.t.Helper()
resp := c.rawCall("tools/list", map[string]any{})
if resp.Error != nil {
c.t.Fatalf("tools/list: JSON-RPC error code=%d message=%s", resp.Error.Code, resp.Error.Message)
}
var result struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if err := json.Unmarshal(resp.Result, &result); err != nil {
c.t.Fatalf("unmarshal tools/list: %v", err)
}
names := make([]string, len(result.Tools))
for i, t := range result.Tools {
names[i] = t.Name
}
return names
}
// rawCall sends a JSON-RPC 2.0 request and returns the response.
func (c *mcpClient) rawCall(method string, params any) jsonRPCResponse {
c.t.Helper()
reqID := c.nextID
c.nextID++
body, err := json.Marshal(jsonRPCRequest{
JSONRPC: "2.0",
ID: reqID,
Method: method,
Params: params,
})
if err != nil {
c.t.Fatalf("marshal request: %v", err)
}
req, err := http.NewRequest("POST", c.baseURL+"/mcp", bytes.NewReader(body))
if err != nil {
c.t.Fatalf("create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
if c.sessionID != "" {
req.Header.Set("Mcp-Session-Id", c.sessionID)
}
httpResp, err := http.DefaultClient.Do(req)
if err != nil {
c.t.Fatalf("HTTP %s: %v", method, err)
}
defer httpResp.Body.Close()
// Store session ID from response header
if sid := httpResp.Header.Get("Mcp-Session-Id"); sid != "" {
c.sessionID = sid
}
respBody, err := io.ReadAll(httpResp.Body)
if err != nil {
c.t.Fatalf("read response: %v", err)
}
if httpResp.StatusCode != http.StatusOK {
c.t.Fatalf("HTTP %s status %d: %s", method, httpResp.StatusCode, string(respBody))
}
var rpcResp jsonRPCResponse
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
c.t.Fatalf("unmarshal response for %s: %v\nbody: %s", method, err, string(respBody))
}
return rpcResp
}
// parseToolResult extracts the JSON content from an MCP tool result.
func (c *mcpClient) parseToolResult(toolName string, raw json.RawMessage) map[string]any {
c.t.Helper()
var result struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(raw, &result); err != nil {
c.t.Fatalf("unmarshal tool result for %s: %v", toolName, err)
}
if result.IsError {
msg := ""
if len(result.Content) > 0 {
msg = result.Content[0].Text
}
c.t.Fatalf("tool %s returned error: %s", toolName, msg)
}
if len(result.Content) == 0 {
c.t.Fatalf("tool %s returned empty content", toolName)
}
var data map[string]any
if err := json.Unmarshal([]byte(result.Content[0].Text), &data); err != nil {
c.t.Fatalf("unmarshal tool content for %s: %v\nraw: %s", toolName, err, result.Content[0].Text)
}
return data
}
// unwrapCallResult extracts the inner bridge result from an execute tool response.
// Execute returns { result: { ok, result: <data> }, calls, duration } — this returns <data>.
func unwrapCallResult(t *testing.T, resp map[string]any) map[string]any {
t.Helper()
callEnvelope, ok := resp["result"].(map[string]any)
if !ok {
t.Fatalf("expected result to be map, got %T", resp["result"])
}
inner, ok := callEnvelope["result"].(map[string]any)
if !ok {
t.Fatalf("expected call result to be map, got %T (ok=%v)", callEnvelope["result"], callEnvelope["ok"])
}
return inner
}
// ---------------------------------------------------------------------------
// Tests -- updated for 4 hybrid tools
// ---------------------------------------------------------------------------
func TestE2E_DirectMessage(t *testing.T) {
env := setupEnv(t)
alice := env.registerAgent("alice", "Alice")
bob := env.registerAgent("bob", "Bob")
// Alice connects via MCP and sends a DM to Bob using the send_message tool.
aliceClient := newMCPClient(t, env.server.URL, alice.APIKey)
aliceClient.Initialize()
sendResult := aliceClient.CallTool("send_message", map[string]any{
"to": "bob",
"body": "Hey Bob, how are you?",
})
msgID := sendResult["message_id"]
if msgID == nil || msgID.(float64) == 0 {
t.Fatal("expected non-zero message_id")
}
// Bob connects and reads his inbox via execute tool.
bobClient := newMCPClient(t, env.server.URL, bob.APIKey)
bobClient.Initialize()
inbox := bobClient.CallTool("execute", map[string]any{
"code": `call("read_inbox", {})`,
})
resultData := unwrapCallResult(t, inbox)
count := resultData["count"].(float64)
if count != 1 {
t.Fatalf("Bob's inbox count = %v, want 1", count)
}
messages := resultData["messages"].([]any)
firstMsg := messages[0].(map[string]any)
if firstMsg["from_agent"] != "alice" {
t.Errorf("from_agent = %v, want alice", firstMsg["from_agent"])
}
if firstMsg["body"] != "Hey Bob, how are you?" {
t.Errorf("body = %v, want 'Hey Bob, how are you?'", firstMsg["body"])
}
}
func TestE2E_ChannelMessaging(t *testing.T) {
env := setupEnv(t)
alice := env.registerAgent("alice", "Alice")
bob := env.registerAgent("bob", "Bob")
aliceClient := newMCPClient(t, env.server.URL, alice.APIKey)
aliceClient.Initialize()
bobClient := newMCPClient(t, env.server.URL, bob.APIKey)
bobClient.Initialize()
// Alice creates a channel via execute.
createResult := aliceClient.CallTool("execute", map[string]any{
"code": `call("create_channel", { name: "project-x", description: "Channel for Project X" })`,
})
createData := unwrapCallResult(t, createResult)
channelID := createData["channel_id"].(float64)
if channelID == 0 {
t.Fatal("expected non-zero channel_id")
}
// Bob joins the channel via execute.
joinResult := bobClient.CallTool("execute", map[string]any{
"code": `call("join_channel", { channel_name: "project-x" })`,
})
joinData := unwrapCallResult(t, joinResult)
if joinData["status"] != "joined" {
t.Errorf("join status = %v, want joined", joinData["status"])
}
// Alice sends a message to the channel via send_message (channel path).
sendResult := aliceClient.CallTool("send_message", map[string]any{
"channel": "project-x",
"body": "Welcome to Project X!",
})
if sendResult["status"] != "sent" {
t.Errorf("send status = %v, want sent", sendResult["status"])
}
// Bob reads channel messages (DMs only sent for @mentions).
chMsgs := bobClient.CallTool("execute", map[string]any{
"code": `call("get_channel_messages", { channel_name: "project-x", limit: 10 })`,
})
chData := unwrapCallResult(t, chMsgs)
count := chData["count"].(float64)
if count < 1 {
t.Fatalf("channel message count = %v, want >= 1", count)
}
messages := chData["messages"].([]any)
found := false
for _, m := range messages {
msg := m.(map[string]any)
if msg["body"] == "Welcome to Project X!" {
found = true
break
}
}
if !found {
t.Error("channel message not found via get_channel_messages")
}
}
func TestE2E_SearchMessages(t *testing.T) {
env := setupEnv(t)
alice := env.registerAgent("alice", "Alice")
bob := env.registerAgent("bob", "Bob")
aliceClient := newMCPClient(t, env.server.URL, alice.APIKey)
aliceClient.Initialize()
bobClient := newMCPClient(t, env.server.URL, bob.APIKey)
bobClient.Initialize()
// Alice sends several messages to Bob.
aliceClient.CallTool("send_message", map[string]any{
"to": "bob",
"body": "The deployment pipeline is broken",
})
aliceClient.CallTool("send_message", map[string]any{
"to": "bob",
"body": "Database backup completed successfully",
})
aliceClient.CallTool("send_message", map[string]any{
"to": "bob",
"body": "Please review the pull request",
})
// Bob searches for "deployment" via execute.
searchResult := bobClient.CallTool("execute", map[string]any{
"code": `call("search_messages", { query: "deployment" })`,
})
searchData := unwrapCallResult(t, searchResult)
count := searchData["count"].(float64)
if count != 1 {
t.Errorf("search count for 'deployment' = %v, want 1", count)
}
// Verify search mode is fulltext (no embedding provider configured).
if mode := searchData["search_mode"]; mode != "fulltext" {
t.Errorf("search_mode = %v, want fulltext", mode)
}
}
func TestE2E_ClaimAndMarkDone(t *testing.T) {
env := setupEnv(t)
alice := env.registerAgent("alice", "Alice")
bob := env.registerAgent("bob", "Bob")
aliceClient := newMCPClient(t, env.server.URL, alice.APIKey)
aliceClient.Initialize()
bobClient := newMCPClient(t, env.server.URL, bob.APIKey)
bobClient.Initialize()
// Alice sends a task to Bob.
sendResult := aliceClient.CallTool("send_message", map[string]any{
"to": "bob",
"body": "Process this report",
})
msgID := sendResult["message_id"].(float64)
// Bob claims the message via execute.
claimResult := bobClient.CallTool("execute", map[string]any{
"code": `call("claim_messages", { limit: 1 })`,
})
claimData := unwrapCallResult(t, claimResult)
claimCount := claimData["count"].(float64)
if claimCount != 1 {
t.Fatalf("claimed count = %v, want 1", claimCount)
}
// Bob marks the message as done via execute.
doneResult := bobClient.CallTool("execute", map[string]any{
"code": fmt.Sprintf(`call("mark_done", { message_id: %d, status: "done" })`, int(msgID)),
})
doneData := unwrapCallResult(t, doneResult)
if doneData["status"] != "done" {
t.Errorf("mark_done status = %v, want done", doneData["status"])
}
}
func TestE2E_ListTools(t *testing.T) {
env := setupEnv(t)
alice := env.registerAgent("alice", "Alice")
aliceClient := newMCPClient(t, env.server.URL, alice.APIKey)
aliceClient.Initialize()
tools := aliceClient.ListTools()
if len(tools) != 5 {
t.Fatalf("expected exactly 5 tools, got %d: %v", len(tools), tools)
}
// Verify the 5 hybrid tools are present.
toolSet := make(map[string]bool)
for _, name := range tools {
toolSet[name] = true
}
expectedTools := []string{
"my_status",
"send_message",
"search",
"execute",
"get_replies",
}
for _, name := range expectedTools {
if !toolSet[name] {
t.Errorf("expected tool %q in tools/list", name)
}
}
}
func TestE2E_UnauthenticatedAccess(t *testing.T) {
env := setupEnv(t)
// A client with no API key.
anonClient := newMCPClient(t, env.server.URL, "")
anonClient.Initialize()
// Calling a tool that requires auth should return an error result.
errMsg := anonClient.CallToolExpectError("send_message", map[string]any{
"to": "nobody",
"body": "should fail",
})
if errMsg == "" {
t.Error("expected error message for unauthenticated send_message")
}
// execute should also require auth.
errMsg2 := anonClient.CallToolExpectError("execute", map[string]any{
"code": `call("discover_agents", {})`,
})
if errMsg2 == "" {
t.Error("expected error message for unauthenticated execute")
}
}
func TestE2E_AgentDiscovery(t *testing.T) {
env := setupEnv(t)
_ = env.registerAgent("search-bot", "Search Bot")
_ = env.registerAgent("code-bot", "Code Bot")
charlie := env.registerAgent("charlie", "Charlie")
// Register agents with specific capabilities via the service directly.
ctx := context.Background()
env.agentService.UpdateAgent(ctx, "search-bot", "", json.RawMessage(`{"skills":["web-search","summarize"]}`))
env.agentService.UpdateAgent(ctx, "code-bot", "", json.RawMessage(`{"skills":["code-review","testing"]}`))
charlieClient := newMCPClient(t, env.server.URL, charlie.APIKey)
charlieClient.Initialize()
// Discover all agents via execute.
allAgents := charlieClient.CallTool("execute", map[string]any{
"code": `call("discover_agents", {})`,
})
agentsData := unwrapCallResult(t, allAgents)
allCount := agentsData["count"].(float64)
if allCount < 3 {
t.Errorf("discover_agents count = %v, want >= 3", allCount)
}
// Discover agents by capability keyword.
searchBots := charlieClient.CallTool("execute", map[string]any{
"code": `call("discover_agents", { query: "web-search" })`,
})
searchData := unwrapCallResult(t, searchBots)
searchCount := searchData["count"].(float64)
if searchCount != 1 {
t.Errorf("discover_agents(web-search) count = %v, want 1", searchCount)
}
}
func TestE2E_SearchActions(t *testing.T) {
env := setupEnv(t)
alice := env.registerAgent("alice", "Alice")
aliceClient := newMCPClient(t, env.server.URL, alice.APIKey)
aliceClient.Initialize()
// Search for channel-related actions.
searchResult := aliceClient.CallTool("search", map[string]any{
"query": "create channel",
})
count := searchResult["count"].(float64)
if count == 0 {
t.Error("expected at least one action result for 'create channel'")
}
actionsList := searchResult["actions"].([]any)
firstAction := actionsList[0].(map[string]any)
if firstAction["name"] == nil {
t.Error("expected name in action result")
}
if firstAction["examples"] == nil {
t.Error("expected examples in action result")
}
}
func TestE2E_MyStatus(t *testing.T) {
env := setupEnv(t)
alice := env.registerAgent("alice", "Alice")
aliceClient := newMCPClient(t, env.server.URL, alice.APIKey)
aliceClient.Initialize()
status := aliceClient.CallTool("my_status", map[string]any{})
// Verify agent info
agentInfo := status["agent"].(map[string]any)
if agentInfo["name"] != "alice" {
t.Errorf("agent name = %v, want alice", agentInfo["name"])
}
// Verify usage instructions
usage := status["usage"].(string)
if usage == "" {
t.Error("expected usage instructions in my_status response")
}
// Verify stats
stats := status["stats"].(map[string]any)
if stats == nil {
t.Error("expected stats in my_status response")
}
}