diff --git a/cmd/synapbus/admin.go b/cmd/synapbus/admin.go index cec53d8..d1e0207 100644 --- a/cmd/synapbus/admin.go +++ b/cmd/synapbus/admin.go @@ -308,7 +308,31 @@ func addAdminCommands(rootCmd *cobra.Command) { agentRevokeKeyCmd.Flags().StringVar(&agentRevokeKeyName, "name", "", "Agent name") agentRevokeKeyCmd.MarkFlagRequired("name") - agentCmd.AddCommand(agentListCmd, agentCreateCmd, agentDeleteCmd, agentRevokeKeyCmd) + var ( + agentUpdateCapsName string + agentUpdateCapsJSON string + ) + agentUpdateCapsCmd := &cobra.Command{ + Use: "update-capabilities", + Short: "Update an agent's capabilities JSON", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := adminRequest("agent.update_capabilities", map[string]interface{}{ + "name": agentUpdateCapsName, + "capabilities": json.RawMessage(agentUpdateCapsJSON), + }) + if err != nil { + return err + } + printJSON(resp["data"]) + return nil + }, + } + agentUpdateCapsCmd.Flags().StringVar(&agentUpdateCapsName, "name", "", "Agent name") + agentUpdateCapsCmd.Flags().StringVar(&agentUpdateCapsJSON, "capabilities", "", "Capabilities JSON (e.g. '{\"role\":\"researcher\"}')") + agentUpdateCapsCmd.MarkFlagRequired("name") + agentUpdateCapsCmd.MarkFlagRequired("capabilities") + + agentCmd.AddCommand(agentListCmd, agentCreateCmd, agentDeleteCmd, agentRevokeKeyCmd, agentUpdateCapsCmd) // ----- audit commands ----- auditCmd := &cobra.Command{ diff --git a/cmd/synapbus/main.go b/cmd/synapbus/main.go index 1c1b48f..3da9f47 100644 --- a/cmd/synapbus/main.go +++ b/cmd/synapbus/main.go @@ -23,6 +23,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/spf13/cobra" + "github.com/synapbus/synapbus/internal/a2a" "github.com/synapbus/synapbus/internal/actions" "github.com/synapbus/synapbus/internal/admin" "github.com/synapbus/synapbus/internal/agents" @@ -528,6 +529,14 @@ func runServe(cmd *cobra.Command, args []string) error { // OAuth metadata (public, per RFC 8414) r.Get("/.well-known/oauth-authorization-server", authHandlers.HandleOAuthMetadata) + // A2A Agent Card discovery (public, no auth required) + agentCardBaseURL := authCfg.IssuerURL // reuse the same base URL config + r.Get("/.well-known/agent-card.json", a2a.NewAgentCardHandler( + &a2aAgentListerAdapter{agentService: agentService}, + agentCardBaseURL, + version, + )) + // OAuth endpoints r.Get("/oauth/authorize", authHandlers.HandleAuthorizeGet) r.Post("/oauth/authorize", authHandlers.HandleAuthorizePost) @@ -760,6 +769,28 @@ func generateRandomPassword() string { return hex.EncodeToString(b) } +// a2aAgentListerAdapter adapts agents.AgentService to a2a.AgentLister. +type a2aAgentListerAdapter struct { + agentService *agents.AgentService +} + +func (a *a2aAgentListerAdapter) ListAllActiveAgents(ctx context.Context) ([]a2a.AgentInfo, error) { + agentsList, err := a.agentService.ListAllActiveAgents(ctx) + if err != nil { + return nil, err + } + result := make([]a2a.AgentInfo, 0, len(agentsList)) + for _, agent := range agentsList { + result = append(result, a2a.AgentInfo{ + Name: agent.Name, + DisplayName: agent.DisplayName, + Type: agent.Type, + Capabilities: agent.Capabilities, + }) + } + return result, nil +} + // agentListerAdapter adapts agents.AgentService to auth.AgentLister. type agentListerAdapter struct { agentService *agents.AgentService diff --git a/internal/a2a/agentcard.go b/internal/a2a/agentcard.go new file mode 100644 index 0000000..3b824f5 --- /dev/null +++ b/internal/a2a/agentcard.go @@ -0,0 +1,133 @@ +// Package a2a provides A2A Agent Card discovery for SynapBus. +// The Agent Card is a JSON document that describes the hub and its registered +// agents, following the A2A Agent Card specification. +package a2a + +import "encoding/json" + +// AgentCard is the A2A Agent Card document returned by the discovery endpoint. +type AgentCard struct { + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + SupportedInterfaces []AgentInterface `json:"supported_interfaces"` + Capabilities AgentCapabilities `json:"capabilities"` + Skills []AgentSkill `json:"skills"` + SecuritySchemes map[string]any `json:"security_schemes"` + DefaultInputModes []string `json:"default_input_modes"` + DefaultOutputModes []string `json:"default_output_modes"` +} + +// AgentInterface describes a protocol endpoint the hub supports. +type AgentInterface struct { + URL string `json:"url"` + ProtocolBinding string `json:"protocol_binding"` +} + +// AgentCapabilities declares hub-level capabilities. +type AgentCapabilities struct { + Streaming bool `json:"streaming"` + PushNotifications bool `json:"push_notifications"` +} + +// AgentSkill represents a single agent registered on the hub, mapped as an +// A2A skill. +type AgentSkill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +// AgentInfo is a lightweight struct used to pass agent data from the registry +// into the card generator without leaking internal types. +type AgentInfo struct { + Name string + DisplayName string + Type string + Capabilities json.RawMessage +} + +// GenerateAgentCard builds an AgentCard from the hub configuration and a list +// of registered agents. +func GenerateAgentCard(baseURL string, version string, agents []AgentInfo) *AgentCard { + skills := make([]AgentSkill, 0, len(agents)) + for _, a := range agents { + skill := AgentSkill{ + ID: a.Name, + Name: a.DisplayName, + } + if skill.Name == "" { + skill.Name = a.Name + } + + // Parse capabilities JSON for description and tags. + if len(a.Capabilities) > 0 { + var caps map[string]interface{} + if json.Unmarshal(a.Capabilities, &caps) == nil { + if desc, ok := caps["description"].(string); ok { + skill.Description = desc + } + if role, ok := caps["role"].(string); ok { + skill.Tags = append(skill.Tags, role) + } + if tagsRaw, ok := caps["tags"]; ok { + switch v := tagsRaw.(type) { + case []interface{}: + for _, t := range v { + if s, ok := t.(string); ok { + skill.Tags = append(skill.Tags, s) + } + } + } + } + } + } + + // Add agent type as a tag. + if a.Type != "" { + skill.Tags = append(skill.Tags, a.Type) + } + + skills = append(skills, skill) + } + + return &AgentCard{ + Name: "SynapBus Hub", + Description: "MCP-native agent-to-agent messaging hub", + Version: version, + SupportedInterfaces: []AgentInterface{ + { + URL: baseURL + "/a2a", + ProtocolBinding: "JSONRPC", + }, + }, + Capabilities: AgentCapabilities{ + Streaming: true, + PushNotifications: false, + }, + Skills: skills, + SecuritySchemes: map[string]any{ + "apiKey": map[string]any{ + "type": "apiKey", + "in": "header", + "name": "Authorization", + "scheme": "Bearer", + }, + "oauth2": map[string]any{ + "type": "oauth2", + "flows": map[string]any{ + "authorizationCode": map[string]any{ + "authorizationUrl": baseURL + "/oauth/authorize", + "tokenUrl": baseURL + "/oauth/token", + "scopes": map[string]string{ + "mcp": "MCP protocol access", + }, + }, + }, + }, + }, + DefaultInputModes: []string{"text"}, + DefaultOutputModes: []string{"text"}, + } +} diff --git a/internal/a2a/agentcard_test.go b/internal/a2a/agentcard_test.go new file mode 100644 index 0000000..b61ae2c --- /dev/null +++ b/internal/a2a/agentcard_test.go @@ -0,0 +1,218 @@ +package a2a + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGenerateAgentCard_WithAgents(t *testing.T) { + agents := []AgentInfo{ + {Name: "research-bot", DisplayName: "Research Bot", Type: "ai", Capabilities: json.RawMessage(`{"role":"researcher","description":"Searches the web"}`)}, + {Name: "social-commenter", DisplayName: "Social Commenter", Type: "ai", Capabilities: json.RawMessage(`{"tags":["social","marketing"]}`)}, + {Name: "data-analyst", DisplayName: "", Type: "ai", Capabilities: json.RawMessage(`{}`)}, + } + + card := GenerateAgentCard("http://localhost:8080", "1.0.0", agents) + + if card.Name != "SynapBus Hub" { + t.Errorf("name = %q, want %q", card.Name, "SynapBus Hub") + } + if card.Version != "1.0.0" { + t.Errorf("version = %q, want %q", card.Version, "1.0.0") + } + if len(card.Skills) != 3 { + t.Fatalf("skills count = %d, want 3", len(card.Skills)) + } + + // Verify first skill has description and tags from capabilities + s0 := card.Skills[0] + if s0.ID != "research-bot" { + t.Errorf("skill[0].id = %q, want %q", s0.ID, "research-bot") + } + if s0.Name != "Research Bot" { + t.Errorf("skill[0].name = %q, want %q", s0.Name, "Research Bot") + } + if s0.Description != "Searches the web" { + t.Errorf("skill[0].description = %q, want %q", s0.Description, "Searches the web") + } + // Should have "researcher" from role + "ai" from type + if len(s0.Tags) < 2 { + t.Errorf("skill[0].tags = %v, expected at least 2 tags", s0.Tags) + } + + // Second skill should have tags from capabilities "tags" field + s1 := card.Skills[1] + if s1.ID != "social-commenter" { + t.Errorf("skill[1].id = %q, want %q", s1.ID, "social-commenter") + } + foundSocial := false + for _, tag := range s1.Tags { + if tag == "social" { + foundSocial = true + } + } + if !foundSocial { + t.Errorf("skill[1].tags = %v, expected 'social' tag", s1.Tags) + } + + // Third skill should use name as display name (since DisplayName is empty) + s2 := card.Skills[2] + if s2.Name != "data-analyst" { + t.Errorf("skill[2].name = %q, want %q (fallback to Name)", s2.Name, "data-analyst") + } + + // Verify JSON serialization round-trips cleanly + data, err := json.Marshal(card) + if err != nil { + t.Fatalf("marshal card: %v", err) + } + var decoded AgentCard + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal card: %v", err) + } + if decoded.Name != card.Name { + t.Errorf("round-trip name mismatch") + } + if len(decoded.Skills) != 3 { + t.Errorf("round-trip skills count = %d, want 3", len(decoded.Skills)) + } +} + +func TestGenerateAgentCard_WithCapabilities_TagsPopulated(t *testing.T) { + agents := []AgentInfo{ + { + Name: "smart-agent", + DisplayName: "Smart Agent", + Type: "ai", + Capabilities: json.RawMessage(`{"role":"analyst","description":"Analyzes data","tags":["ml","data"]}`), + }, + } + + card := GenerateAgentCard("http://example.com", "2.0.0", agents) + + if len(card.Skills) != 1 { + t.Fatalf("skills count = %d, want 1", len(card.Skills)) + } + + skill := card.Skills[0] + if skill.Description != "Analyzes data" { + t.Errorf("description = %q, want %q", skill.Description, "Analyzes data") + } + + // Expect tags: "analyst" (from role), "ml", "data" (from tags), "ai" (from type) + expectedTags := map[string]bool{"analyst": false, "ml": false, "data": false, "ai": false} + for _, tag := range skill.Tags { + if _, ok := expectedTags[tag]; ok { + expectedTags[tag] = true + } + } + for tag, found := range expectedTags { + if !found { + t.Errorf("missing expected tag %q in %v", tag, skill.Tags) + } + } +} + +func TestGenerateAgentCard_NoAgents(t *testing.T) { + card := GenerateAgentCard("http://localhost:8080", "0.1.0", nil) + + if card.Name != "SynapBus Hub" { + t.Errorf("name = %q, want %q", card.Name, "SynapBus Hub") + } + if len(card.Skills) != 0 { + t.Errorf("skills count = %d, want 0", len(card.Skills)) + } + if len(card.SupportedInterfaces) != 1 { + t.Fatalf("interfaces count = %d, want 1", len(card.SupportedInterfaces)) + } + if card.SupportedInterfaces[0].URL != "http://localhost:8080/a2a" { + t.Errorf("interface url = %q, want %q", card.SupportedInterfaces[0].URL, "http://localhost:8080/a2a") + } + if card.SecuritySchemes == nil { + t.Error("security_schemes should not be nil") + } +} + +// mockAgentLister implements AgentLister for handler tests. +type mockAgentLister struct { + agents []AgentInfo + err error +} + +func (m *mockAgentLister) ListAllActiveAgents(_ context.Context) ([]AgentInfo, error) { + return m.agents, m.err +} + +func TestHandler_Returns200WithCorrectContentType(t *testing.T) { + lister := &mockAgentLister{ + agents: []AgentInfo{ + {Name: "bot-1", DisplayName: "Bot One", Type: "ai"}, + {Name: "bot-2", DisplayName: "Bot Two", Type: "ai"}, + }, + } + + handler := NewAgentCardHandler(lister, "http://localhost:8080", "1.0.0") + req := httptest.NewRequest(http.MethodGet, "/.well-known/agent-card.json", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rr.Code, http.StatusOK) + } + ct := rr.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/json") + } + + var card AgentCard + if err := json.NewDecoder(rr.Body).Decode(&card); err != nil { + t.Fatalf("decode response: %v", err) + } + if card.Name != "SynapBus Hub" { + t.Errorf("card.name = %q, want %q", card.Name, "SynapBus Hub") + } + if len(card.Skills) != 2 { + t.Errorf("card.skills count = %d, want 2", len(card.Skills)) + } +} + +func TestHandler_DerivesBaseURLFromRequest(t *testing.T) { + lister := &mockAgentLister{agents: nil} + + // Empty configuredBaseURL — should derive from request + handler := NewAgentCardHandler(lister, "", "1.0.0") + req := httptest.NewRequest(http.MethodGet, "/.well-known/agent-card.json", nil) + req.Host = "myhost:9090" + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + var card AgentCard + if err := json.NewDecoder(rr.Body).Decode(&card); err != nil { + t.Fatalf("decode: %v", err) + } + if card.SupportedInterfaces[0].URL != "http://myhost:9090/a2a" { + t.Errorf("interface url = %q, want %q", card.SupportedInterfaces[0].URL, "http://myhost:9090/a2a") + } +} + +func TestHandler_RejectsNonGET(t *testing.T) { + lister := &mockAgentLister{agents: nil} + handler := NewAgentCardHandler(lister, "http://localhost:8080", "1.0.0") + req := httptest.NewRequest(http.MethodPost, "/.well-known/agent-card.json", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", rr.Code, http.StatusMethodNotAllowed) + } +} diff --git a/internal/a2a/handler.go b/internal/a2a/handler.go new file mode 100644 index 0000000..4e04708 --- /dev/null +++ b/internal/a2a/handler.go @@ -0,0 +1,56 @@ +package a2a + +import ( + "context" + "encoding/json" + "net/http" +) + +// AgentLister abstracts the operation of listing active non-human agents. +type AgentLister interface { + ListAllActiveAgents(ctx context.Context) ([]AgentInfo, error) +} + +// NewAgentCardHandler returns an http.HandlerFunc that serves the A2A Agent +// Card JSON document at /.well-known/agent-card.json. +// +// The handler is public (no auth required) because Agent Cards are meant for +// discovery. If configuredBaseURL is empty the base URL is derived from the +// incoming request (respecting X-Forwarded-* headers). +func NewAgentCardHandler(agentLister AgentLister, configuredBaseURL string, version string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Derive base URL from request if not configured. + baseURL := configuredBaseURL + if baseURL == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + host := r.Host + if fwdHost := r.Header.Get("X-Forwarded-Host"); fwdHost != "" { + host = fwdHost + } + baseURL = scheme + "://" + host + } + + agents, err := agentLister.ListAllActiveAgents(r.Context()) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + card := GenerateAgentCard(baseURL, version, agents) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=60") + json.NewEncoder(w).Encode(card) + } +} diff --git a/internal/admin/socket.go b/internal/admin/socket.go index 8f3bb70..eb71af0 100644 --- a/internal/admin/socket.go +++ b/internal/admin/socket.go @@ -139,6 +139,8 @@ func (s *AdminServer) dispatch(req Request) Response { return s.handleAgentDelete(ctx, req.Args) case "agent.revoke_key": return s.handleAgentRevokeKey(ctx, req.Args) + case "agent.update_capabilities": + return s.handleAgentUpdateCapabilities(ctx, req.Args) // --- audit commands --- case "audit.list": @@ -446,6 +448,35 @@ func (s *AdminServer) handleAgentRevokeKey(ctx context.Context, args json.RawMes }} } +func (s *AdminServer) handleAgentUpdateCapabilities(ctx context.Context, args json.RawMessage) Response { + var p struct { + Name string `json:"name"` + Capabilities json.RawMessage `json:"capabilities"` + } + if err := json.Unmarshal(args, &p); err != nil { + return Response{OK: false, Error: "invalid args: " + err.Error()} + } + if p.Name == "" { + return Response{OK: false, Error: "name is required"} + } + if len(p.Capabilities) == 0 { + return Response{OK: false, Error: "capabilities is required"} + } + if !json.Valid(p.Capabilities) { + return Response{OK: false, Error: "capabilities must be valid JSON"} + } + + agent, err := s.services.Agents.UpdateAgent(ctx, p.Name, "", p.Capabilities) + if err != nil { + return Response{OK: false, Error: err.Error()} + } + + return Response{OK: true, Data: map[string]interface{}{ + "name": agent.Name, + "capabilities": json.RawMessage(agent.Capabilities), + }} +} + // ---------- audit handlers ---------- func (s *AdminServer) handleAuditList(ctx context.Context, args json.RawMessage) Response { diff --git a/internal/agents/service.go b/internal/agents/service.go index 1b96092..83b045f 100644 --- a/internal/agents/service.go +++ b/internal/agents/service.go @@ -245,6 +245,12 @@ func (s *AgentService) ListAgents(ctx context.Context, ownerID int64) ([]*Agent, return s.store.ListAgentsByOwner(ctx, ownerID) } +// ListAllActiveAgents returns all active non-human agents across all owners. +// Used for the A2A Agent Card discovery endpoint. +func (s *AgentService) ListAllActiveAgents(ctx context.Context) ([]*Agent, error) { + return s.store.ListAllActiveAgents(ctx) +} + // RevokeKey generates a new API key for an agent. Only the owner can do this. // Returns the agent and the new raw API key (shown once). func (s *AgentService) RevokeKey(ctx context.Context, name string, ownerID int64) (*Agent, string, error) { diff --git a/internal/agents/store.go b/internal/agents/store.go index 4945332..d7158e1 100644 --- a/internal/agents/store.go +++ b/internal/agents/store.go @@ -15,6 +15,7 @@ type AgentStore interface { UpdateAgent(ctx context.Context, agent *Agent) error DeactivateAgent(ctx context.Context, name string) error ListActiveAgents(ctx context.Context) ([]*Agent, error) + ListAllActiveAgents(ctx context.Context) ([]*Agent, error) ListAgentsByOwner(ctx context.Context, ownerID int64) ([]*Agent, error) SearchAgentsByCapability(ctx context.Context, query string) ([]*Agent, error) GetHumanAgentByOwner(ctx context.Context, ownerID int64) (*Agent, error) @@ -112,6 +113,18 @@ func (s *SQLiteAgentStore) ListActiveAgents(ctx context.Context) ([]*Agent, erro return s.scanAgents(rows) } +func (s *SQLiteAgentStore) ListAllActiveAgents(ctx context.Context) ([]*Agent, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at + FROM agents WHERE status = 'active' AND type != 'human' ORDER BY name`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return s.scanAgents(rows) +} + func (s *SQLiteAgentStore) ListAgentsByOwner(ctx context.Context, ownerID int64) ([]*Agent, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at