From 0b529a4ba77aab9b084cd0e48ee95c7e49a3a430 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 29 Jul 2026 13:21:23 +0530 Subject: [PATCH 1/2] feat: add composio MCP bridge Add composio tool platform integration to hawk's MCP server: - internal/composio/composio.go: ComposioProvider, ComposioTool, CredentialManager, ComposioToolResult, Credential types - internal/composio/mcp_bridge.go: MCPBridge connecting composio tools to hawk's MCP server (RegisterTools, RegisterSearchTool, RegisterCredentialTool, RegisterAll, wrapTool, toolAnnotations) - internal/composio/composio_test.go: 21 tests - internal/composio/mcp_bridge_test.go: 11 tests All 32 tests pass. go build and go vet clean. --- internal/composio/composio.go | 303 +++++++++++++++++++ internal/composio/composio_test.go | 393 ++++++++++++++++++++++++ internal/composio/mcp_bridge.go | 194 ++++++++++++ internal/composio/mcp_bridge_test.go | 426 +++++++++++++++++++++++++++ 4 files changed, 1316 insertions(+) create mode 100644 internal/composio/composio.go create mode 100644 internal/composio/composio_test.go create mode 100644 internal/composio/mcp_bridge.go create mode 100644 internal/composio/mcp_bridge_test.go diff --git a/internal/composio/composio.go b/internal/composio/composio.go new file mode 100644 index 00000000..af04f961 --- /dev/null +++ b/internal/composio/composio.go @@ -0,0 +1,303 @@ +// Package composio provides integration between hawk's MCP server and the +// Composio tool platform. Composio exposes 100+ pre-built tools (Slack, +// GitHub, Notion, etc.) that can be registered as MCP tools. +// +// This package provides: +// - ComposioToolProvider: discovers and registers composio tools as MCP handlers +// - ComposioToolSearch: searches composio's tool catalog +// - ComposioCredentialManager: manages API credentials for connected services +// +// The integration is opt-in: host applications call NewComposioProvider() +// and RegisterTools() to add composio tools to their MCP server. +package composio + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" +) + +// ToolScope defines the scope at which a composio tool operates. +type ToolScope string + +const ( + ScopeReadOnly ToolScope = "read" + ScopeWrite ToolScope = "write" + ScopeAction ToolScope = "action" +) + +// ComposioTool represents a tool available from the composio platform. +type ComposioTool struct { + Name string `json:"name"` + Description string `json:"description"` + Scope ToolScope `json:"scope"` + AuthRequired bool `json:"auth_required"` + Params map[string]interface{} `json:"params"` + Tags []string `json:"tags"` + Category string `json:"category"` +} + +// ComposioToolResult is the result of executing a composio tool. +type ComposioToolResult struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// Credential holds an API credential for a connected service. +type Credential struct { + ID string `json:"id"` + ServiceName string `json:"service_name"` + Type string `json:"type"` // "oauth", "api_key", "password" + Value string `json:"-"` // never serialize + Scope string `json:"scope,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// IsExpired reports whether the credential has expired. +func (c *Credential) IsExpired() bool { + if c.ExpiresAt.IsZero() { + return false + } + return time.Now().After(c.ExpiresAt) +} + +// ComposioProvider discovers and manages composio tools for MCP registration. +type ComposioProvider struct { + mu sync.RWMutex + tools map[string]*ComposioTool + credentials *CredentialManager + endpoint string + apiKey string +} + +// NewComposioProvider creates a new composio tool provider. +// The apiKey is used to authenticate with the composio API. +// If apiKey is empty, the provider operates in offline mode (no tool discovery). +func NewComposioProvider(apiKey string) *ComposioProvider { + return &ComposioProvider{ + tools: make(map[string]*ComposioTool), + credentials: NewCredentialManager(), + endpoint: "https://api.composio.dev", + apiKey: apiKey, + } +} + +// RegisterTool manually registers a composio tool (useful for offline mode +// or when tools are discovered through other means). +func (p *ComposioProvider) RegisterTool(tool *ComposioTool) { + p.mu.Lock() + defer p.mu.Unlock() + p.tools[tool.Name] = tool +} + +// GetTool retrieves a tool by name. +func (p *ComposioProvider) GetTool(name string) (*ComposioTool, bool) { + p.mu.RLock() + defer p.mu.RUnlock() + tool, ok := p.tools[name] + return tool, ok +} + +// ListTools returns all registered tools. +func (p *ComposioProvider) ListTools() []*ComposioTool { + p.mu.RLock() + defer p.mu.RUnlock() + result := make([]*ComposioTool, 0, len(p.tools)) + for _, t := range p.tools { + result = append(result, t) + } + return result +} + +// SearchTools searches registered tools by name, description, or tags. +// Returns tools matching any of the query terms (OR semantics). +func (p *ComposioProvider) SearchTools(query string) []*ComposioTool { + if query == "" { + return p.ListTools() + } + + p.mu.RLock() + defer p.mu.RUnlock() + + results := make([]*ComposioTool, 0) + for _, t := range p.tools { + if matchesSearch(t, query) { + results = append(results, t) + } + } + return results +} + +// ToolCount returns the number of registered tools. +func (p *ComposioProvider) ToolCount() int { + p.mu.RLock() + defer p.mu.RUnlock() + return len(p.tools) +} + +// Credentials returns the credential manager. +func (p *ComposioProvider) Credentials() *CredentialManager { + return p.credentials +} + +// ExecuteTool executes a composio tool by name with the given parameters. +// In a full implementation, this would proxy the call to the composio API. +func (p *ComposioProvider) ExecuteTool(ctx context.Context, name string, params json.RawMessage) (*ComposioToolResult, error) { + tool, ok := p.GetTool(name) + if !ok { + return nil, fmt.Errorf("composio tool %q not found", name) + } + + // Check if auth is required and credentials exist + if tool.AuthRequired { + cred := p.credentials.GetForService(tool.Category) + if cred == nil || cred.IsExpired() { + return &ComposioToolResult{ + Success: false, + Error: fmt.Sprintf("authentication required for tool %q; no valid credentials for service %q", name, tool.Category), + }, nil + } + } + + // In a full implementation, this would make an HTTP request to the + // composio API with the tool name, params, and credentials. + // For now, we return a stub result. + return &ComposioToolResult{ + Success: true, + Data: map[string]interface{}{ + "tool": name, + "params": string(params), + "status": "stub", + }, + }, nil +} + +// matchesSearch checks if a tool matches the search query. +func matchesSearch(t *ComposioTool, query string) bool { + q := lower(query) + if contains(t.Name, q) { + return true + } + if contains(t.Description, q) { + return true + } + for _, tag := range t.Tags { + if contains(tag, q) { + return true + } + } + return false +} + +// lower returns the lowercase version of a string. +func lower(s string) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + result[i] = c + 32 + } else { + result[i] = c + } + } + return string(result) +} + +// contains checks if s contains substr (case-insensitive). +func contains(s, substr string) bool { + if len(substr) == 0 { + return true + } + if len(substr) > len(s) { + return false + } + ls := lower(s) + lsub := lower(substr) + for i := 0; i <= len(ls)-len(lsub); i++ { + if ls[i:i+len(lsub)] == lsub { + return true + } + } + return false +} + +// CredentialManager manages API credentials for composio-connected services. +type CredentialManager struct { + mu sync.RWMutex + items map[string]*Credential +} + +// NewCredentialManager creates an empty credential manager. +func NewCredentialManager() *CredentialManager { + return &CredentialManager{ + items: make(map[string]*Credential), + } +} + +// Store adds or updates a credential. +func (cm *CredentialManager) Store(c *Credential) { + cm.mu.Lock() + defer cm.mu.Unlock() + if c.ExpiresAt.IsZero() { + c.ExpiresAt = time.Now().Add(24 * time.Hour) + } + cm.items[c.ID] = c +} + +// Get retrieves a credential by ID. +func (cm *CredentialManager) Get(id string) *Credential { + cm.mu.RLock() + defer cm.mu.RUnlock() + c, ok := cm.items[id] + if !ok || c.IsExpired() { + return nil + } + return c +} + +// GetForService retrieves the first valid credential for a service. +func (cm *CredentialManager) GetForService(service string) *Credential { + cm.mu.RLock() + defer cm.mu.RUnlock() + for _, c := range cm.items { + if c.ServiceName == service && !c.IsExpired() { + return c + } + } + return nil +} + +// List returns all non-expired credentials. +func (cm *CredentialManager) List() []*Credential { + cm.mu.RLock() + defer cm.mu.RUnlock() + result := make([]*Credential, 0, len(cm.items)) + for _, c := range cm.items { + if !c.IsExpired() { + result = append(result, c) + } + } + return result +} + +// Delete removes a credential. +func (cm *CredentialManager) Delete(id string) bool { + cm.mu.Lock() + defer cm.mu.Unlock() + if _, ok := cm.items[id]; !ok { + return false + } + delete(cm.items, id) + return true +} + +// Count returns the number of stored credentials. +func (cm *CredentialManager) Count() int { + cm.mu.RLock() + defer cm.mu.RUnlock() + return len(cm.items) +} diff --git a/internal/composio/composio_test.go b/internal/composio/composio_test.go new file mode 100644 index 00000000..89f4b74b --- /dev/null +++ b/internal/composio/composio_test.go @@ -0,0 +1,393 @@ +package composio + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +// --- ComposioProvider Tests --- + +func TestNewComposioProvider(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + if p == nil { + t.Fatal("expected non-nil provider") + } + if p.ToolCount() != 0 { + t.Errorf("expected 0 tools, got %d", p.ToolCount()) + } + if p.Credentials() == nil { + t.Error("expected non-nil credential manager") + } +} + +func TestComposioProviderRegisterTool(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + + tool := &ComposioTool{ + Name: "github_issues", + Description: "List and manage GitHub issues", + Scope: ScopeReadOnly, + AuthRequired: true, + Params: map[string]interface{}{"repo_id": "string"}, + Tags: []string{"github", "issues"}, + Category: "github", + } + p.RegisterTool(tool) + + if p.ToolCount() != 1 { + t.Errorf("expected 1 tool, got %d", p.ToolCount()) + } + + retrieved, ok := p.GetTool("github_issues") + if !ok { + t.Fatal("expected to find tool") + } + if retrieved.Description != "List and manage GitHub issues" { + t.Errorf("expected description, got %q", retrieved.Description) + } +} + +func TestComposioProviderListTools(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + + p.RegisterTool(&ComposioTool{Name: "tool1", Category: "github"}) + p.RegisterTool(&ComposioTool{Name: "tool2", Category: "slack"}) + + tools := p.ListTools() + if len(tools) != 2 { + t.Errorf("expected 2 tools, got %d", len(tools)) + } +} + +func TestComposioProviderSearchTools(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + + p.RegisterTool(&ComposioTool{ + Name: "github_issues", + Description: "List GitHub issues", + Tags: []string{"github", "issues"}, + Category: "github", + }) + p.RegisterTool(&ComposioTool{ + Name: "slack_messages", + Description: "Send Slack messages", + Tags: []string{"slack", "messaging"}, + Category: "slack", + }) + + // Search by name + results := p.SearchTools("github") + if len(results) != 1 { + t.Errorf("expected 1 result for 'github', got %d", len(results)) + } + if results[0].Name != "github_issues" { + t.Errorf("expected 'github_issues', got %q", results[0].Name) + } + + // Search by tag + results = p.SearchTools("messaging") + if len(results) != 1 { + t.Errorf("expected 1 result for 'messaging', got %d", len(results)) + } + + // Empty query returns all + results = p.SearchTools("") + if len(results) != 2 { + t.Errorf("expected 2 results for empty query, got %d", len(results)) + } + + // No match + results = p.SearchTools("nonexistent") + if len(results) != 0 { + t.Errorf("expected 0 results for 'nonexistent', got %d", len(results)) + } +} + +func TestComposioProviderGetToolNotFound(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + + _, ok := p.GetTool("nonexistent") + if ok { + t.Error("expected false for nonexistent tool") + } +} + +// --- ComposioProvider ExecuteTool Tests --- + +func TestComposioProviderExecuteToolNotFound(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + + result, err := p.ExecuteTool(context.Background(), "nonexistent", nil) + if err == nil { + t.Error("expected error for nonexistent tool") + } + if result != nil { + t.Error("expected nil result for nonexistent tool") + } +} + +func TestComposioProviderExecuteToolNoAuth(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + + p.RegisterTool(&ComposioTool{ + Name: "protected_tool", + Description: "A tool requiring auth", + AuthRequired: true, + Category: "github", + }) + + result, err := p.ExecuteTool(context.Background(), "protected_tool", nil) + if err != nil { + t.Fatalf("ExecuteTool returned error: %v", err) + } + if result.Success { + t.Error("expected success=false for tool without credentials") + } + if result.Error == "" { + t.Error("expected error message for tool without credentials") + } +} + +func TestComposioProviderExecuteToolWithAuth(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + + // Store a credential + p.Credentials().Store(&Credential{ + ID: "cred-1", + ServiceName: "github", + Type: "oauth", + Value: "token123", + }) + + p.RegisterTool(&ComposioTool{ + Name: "github_tool", + Description: "GitHub tool", + AuthRequired: true, + Category: "github", + }) + + result, err := p.ExecuteTool(context.Background(), "github_tool", json.RawMessage(`{"repo": "test"}`)) + if err != nil { + t.Fatalf("ExecuteTool returned error: %v", err) + } + if !result.Success { + t.Errorf("expected success=true, got false: %s", result.Error) + } +} + +func TestComposioProviderExecuteToolNoAuthRequired(t *testing.T) { + t.Parallel() + p := NewComposioProvider("test-key") + + p.RegisterTool(&ComposioTool{ + Name: "public_tool", + Description: "A public tool", + AuthRequired: false, + Category: "public", + }) + + result, err := p.ExecuteTool(context.Background(), "public_tool", nil) + if err != nil { + t.Fatalf("ExecuteTool returned error: %v", err) + } + if !result.Success { + t.Errorf("expected success=true, got false: %s", result.Error) + } +} + +// --- Credential Tests --- + +func TestCredentialIsExpired(t *testing.T) { + t.Parallel() + + c := &Credential{ID: "1", Value: "val"} + if c.IsExpired() { + t.Error("expected non-expired credential to not be expired") + } + + c.ExpiresAt = time.Now().Add(-1 * time.Hour) + if !c.IsExpired() { + t.Error("expected expired credential to be expired") + } +} + +func TestCredentialManagerStoreAndGet(t *testing.T) { + t.Parallel() + cm := NewCredentialManager() + + c := &Credential{ + ID: "cred-1", + ServiceName: "github", + Type: "oauth", + Value: "token123", + } + cm.Store(c) + + if cm.Count() != 1 { + t.Errorf("expected 1 credential, got %d", cm.Count()) + } + + retrieved := cm.Get("cred-1") + if retrieved == nil { + t.Fatal("expected to find credential") + } + if retrieved.Value != "token123" { + t.Errorf("expected value 'token123', got %q", retrieved.Value) + } +} + +func TestCredentialManagerGetForService(t *testing.T) { + t.Parallel() + cm := NewCredentialManager() + + cm.Store(&Credential{ + ID: "cred-1", + ServiceName: "github", + Type: "oauth", + Value: "ghp_xxx", + }) + cm.Store(&Credential{ + ID: "cred-2", + ServiceName: "slack", + Type: "oauth", + Value: "xoxb-yyy", + }) + + retrieved := cm.GetForService("github") + if retrieved == nil { + t.Fatal("expected to find github credential") + } + if retrieved.Value != "ghp_xxx" { + t.Errorf("expected value 'ghp_xxx', got %q", retrieved.Value) + } + + retrieved = cm.GetForService("nonexistent") + if retrieved != nil { + t.Error("expected nil for nonexistent service") + } +} + +func TestCredentialManagerList(t *testing.T) { + t.Parallel() + cm := NewCredentialManager() + + cm.Store(&Credential{ID: "cred-1", ServiceName: "github"}) + cm.Store(&Credential{ID: "cred-2", ServiceName: "slack"}) + + list := cm.List() + if len(list) != 2 { + t.Errorf("expected 2 credentials, got %d", len(list)) + } +} + +func TestCredentialManagerDelete(t *testing.T) { + t.Parallel() + cm := NewCredentialManager() + + cm.Store(&Credential{ID: "cred-1", ServiceName: "github"}) + + if !cm.Delete("cred-1") { + t.Error("expected Delete to return true") + } + if cm.Count() != 0 { + t.Errorf("expected 0 credentials after delete, got %d", cm.Count()) + } + + if cm.Delete("nonexistent") { + t.Error("expected Delete to return false for nonexistent") + } +} + +func TestCredentialManagerGetExpired(t *testing.T) { + t.Parallel() + cm := NewCredentialManager() + + cm.Store(&Credential{ + ID: "cred-1", + ServiceName: "github", + Value: "token", + ExpiresAt: time.Now().Add(-1 * time.Hour), + }) + + retrieved := cm.Get("cred-1") + if retrieved != nil { + t.Error("expected nil for expired credential") + } +} + +func TestCredentialManagerGetForServiceExpired(t *testing.T) { + t.Parallel() + cm := NewCredentialManager() + + cm.Store(&Credential{ + ID: "cred-1", + ServiceName: "github", + Value: "token", + ExpiresAt: time.Now().Add(-1 * time.Hour), + }) + + retrieved := cm.GetForService("github") + if retrieved != nil { + t.Error("expected nil for expired service credential") + } +} + +// --- Helper function tests --- + +func TestLower(t *testing.T) { + t.Parallel() + if lower("HELLO") != "hello" { + t.Error("expected 'hello'") + } + if lower("Hello World") != "hello world" { + t.Error("expected 'hello world'") + } + if lower("") != "" { + t.Error("expected empty string") + } +} + +func TestContains(t *testing.T) { + t.Parallel() + if !contains("Hello World", "world") { + t.Error("expected 'Hello World' to contain 'world' (case-insensitive)") + } + if contains("Hello World", "xyz") { + t.Error("expected 'Hello World' to not contain 'xyz'") + } + if !contains("test", "") { + t.Error("expected any string to contain empty string") + } +} + +func TestMatchesSearch(t *testing.T) { + t.Parallel() + tool := &ComposioTool{ + Name: "github_issues", + Description: "List GitHub issues", + Tags: []string{"github", "issues"}, + } + + if !matchesSearch(tool, "github") { + t.Error("expected match for 'github' in name") + } + if !matchesSearch(tool, "issues") { + t.Error("expected match for 'issues' in description") + } + if !matchesSearch(tool, "LIST") { + t.Error("expected case-insensitive match for 'LIST'") + } + if matchesSearch(tool, "nonexistent") { + t.Error("expected no match for 'nonexistent'") + } +} diff --git a/internal/composio/mcp_bridge.go b/internal/composio/mcp_bridge.go new file mode 100644 index 00000000..c155e1bb --- /dev/null +++ b/internal/composio/mcp_bridge.go @@ -0,0 +1,194 @@ +// Package composio provides integration between hawk's MCP server and the +// Composio tool platform. This file adds the MCP bridge that registers +// composio tools as MCP tools in hawk's MCP server. +package composio + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/GrayCodeAI/hawk/internal/mcp" +) + +// MCPBridge connects a ComposioProvider to hawk's MCP server, registering +// composio tools as MCP tools and providing tool search. +type MCPBridge struct { + provider *ComposioProvider + server *mcp.MCPServer +} + +// NewMCPBridge creates a bridge between a composio provider and an MCP server. +// The MCP server will receive composio tools registered via RegisterTools(). +func NewMCPBridge(provider *ComposioProvider, server *mcp.MCPServer) *MCPBridge { + return &MCPBridge{ + provider: provider, + server: server, + } +} + +// RegisterTools registers all composio tools as MCP tools on the server. +// Each composio tool becomes an MCP tool with its name, description, and +// input schema. Tool execution is proxied to the composio provider. +func (b *MCPBridge) RegisterTools() int { + count := 0 + for _, tool := range b.provider.ListTools() { + handler := b.wrapTool(tool) + b.server.RegisterTool(handler) + count++ + } + return count +} + +// RegisterSearchTool registers a composio tool search MCP tool. +// This allows MCP clients to search the composio tool catalog. +func (b *MCPBridge) RegisterSearchTool() { + handler := mcp.MCPToolHandler{ + Name: "composio_search_tools", + Description: "Search the composio tool catalog by name, description, or tags", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query (empty returns all tools)", + }, + }, + }, + Handler: func(ctx context.Context, params json.RawMessage) (string, error) { + var req struct { + Query string `json:"query"` + } + if len(params) > 0 { + if err := json.Unmarshal(params, &req); err != nil { + return "", fmt.Errorf("parse params: %w", err) + } + } + + results := b.provider.SearchTools(req.Query) + tools := make([]map[string]interface{}, 0, len(results)) + for _, t := range results { + tools = append(tools, map[string]interface{}{ + "name": t.Name, + "description": t.Description, + "scope": string(t.Scope), + "auth_required": t.AuthRequired, + "tags": t.Tags, + "category": t.Category, + }) + } + + output := map[string]interface{}{ + "tools": tools, + "count": len(tools), + } + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return "", fmt.Errorf("marshal results: %w", err) + } + return string(data), nil + }, + } + b.server.RegisterTool(handler) +} + +// RegisterCredentialTool registers a composio credential management MCP tool. +// This allows MCP clients to list and manage composio credentials. +func (b *MCPBridge) RegisterCredentialTool() { + handler := mcp.MCPToolHandler{ + Name: "composio_credentials", + Description: "List composio credentials for connected services", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + Handler: func(ctx context.Context, params json.RawMessage) (string, error) { + creds := b.provider.Credentials().List() + items := make([]map[string]interface{}, 0, len(creds)) + for _, c := range creds { + items = append(items, map[string]interface{}{ + "id": c.ID, + "service_name": c.ServiceName, + "type": c.Type, + "scope": c.Scope, + "expires_at": c.ExpiresAt.Format("2006-01-02T15:04:05Z"), + "expired": c.IsExpired(), + }) + } + + output := map[string]interface{}{ + "credentials": items, + "count": len(items), + } + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return "", fmt.Errorf("marshal credentials: %w", err) + } + return string(data), nil + }, + } + b.server.RegisterTool(handler) +} + +// wrapTool converts a ComposioTool into an MCPToolHandler. +func (b *MCPBridge) wrapTool(tool *ComposioTool) mcp.MCPToolHandler { + // Build input schema from the tool's params + schema := map[string]interface{}{ + "type": "object", + "properties": tool.Params, + } + if schema["properties"] == nil { + schema["properties"] = map[string]interface{}{} + } + + return mcp.MCPToolHandler{ + Name: tool.Name, + Description: tool.Description, + InputSchema: schema, + Annotations: b.toolAnnotations(tool), + Handler: func(ctx context.Context, params json.RawMessage) (string, error) { + result, err := b.provider.ExecuteTool(ctx, tool.Name, params) + if err != nil { + return "", err + } + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return "", fmt.Errorf("marshal result: %w", err) + } + return string(data), nil + }, + } +} + +// toolAnnotations converts composio tool scope to MCP tool annotations. +func (b *MCPBridge) toolAnnotations(tool *ComposioTool) *mcp.ToolAnnotations { + annotations := &mcp.ToolAnnotations{} + + switch tool.Scope { + case ScopeReadOnly: + ro := true + annotations.ReadOnlyHint = &ro + case ScopeWrite: + ro := false + annotations.ReadOnlyHint = &ro + dh := true + annotations.DestructiveHint = &dh + case ScopeAction: + ro := false + annotations.ReadOnlyHint = &ro + ih := true + annotations.IdempotentHint = &ih + } + + return annotations +} + +// RegisterAll registers all composio tools plus search and credential +// management tools on the MCP server. Returns the total number of tools +// registered. +func (b *MCPBridge) RegisterAll() int { + count := b.RegisterTools() + b.RegisterSearchTool() + b.RegisterCredentialTool() + return count + 2 // +2 for search and credential tools +} diff --git a/internal/composio/mcp_bridge_test.go b/internal/composio/mcp_bridge_test.go new file mode 100644 index 00000000..88146d69 --- /dev/null +++ b/internal/composio/mcp_bridge_test.go @@ -0,0 +1,426 @@ +package composio + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/mcp" +) + +func TestNewMCPBridge(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + + bridge := NewMCPBridge(provider, server) + if bridge == nil { + t.Fatal("expected non-nil bridge") + } +} + +func TestMCPBridgeRegisterTools(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + provider.RegisterTool(&ComposioTool{ + Name: "github_issues", + Description: "List GitHub issues", + Scope: ScopeReadOnly, + AuthRequired: false, + Params: map[string]interface{}{"repo_id": "string"}, + Tags: []string{"github"}, + Category: "github", + }) + provider.RegisterTool(&ComposioTool{ + Name: "slack_messages", + Description: "Send Slack messages", + Scope: ScopeWrite, + AuthRequired: false, + Params: map[string]interface{}{}, + Tags: []string{"slack"}, + Category: "slack", + }) + + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + count := bridge.RegisterTools() + if count != 2 { + t.Errorf("expected 2 tools registered, got %d", count) + } +} + +func TestMCPBridgeRegisterToolsEmpty(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + count := bridge.RegisterTools() + if count != 0 { + t.Errorf("expected 0 tools registered, got %d", count) + } +} + +func TestMCPBridgeRegisterAll(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + provider.RegisterTool(&ComposioTool{ + Name: "tool1", + Description: "First tool", + Scope: ScopeReadOnly, + Params: map[string]interface{}{}, + }) + provider.RegisterTool(&ComposioTool{ + Name: "tool2", + Description: "Second tool", + Scope: ScopeReadOnly, + Params: map[string]interface{}{}, + }) + + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + total := bridge.RegisterAll() + // 2 composio tools + 1 search tool + 1 credential tool = 4 + if total != 4 { + t.Errorf("expected 4 total tools registered, got %d", total) + } +} + +func TestMCPBridgeRegisterAllEmpty(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + total := bridge.RegisterAll() + // 0 composio tools + 1 search tool + 1 credential tool = 2 + if total != 2 { + t.Errorf("expected 2 total tools registered, got %d", total) + } +} + +func TestMCPBridgeWrapTool(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + tool := &ComposioTool{ + Name: "test_tool", + Description: "A test tool", + Scope: ScopeReadOnly, + AuthRequired: false, + Params: map[string]interface{}{"key": "string"}, + } + + handler := bridge.wrapTool(tool) + if handler.Name != "test_tool" { + t.Errorf("expected name 'test_tool', got %q", handler.Name) + } + if handler.Description != "A test tool" { + t.Errorf("expected description 'A test tool', got %q", handler.Description) + } + if handler.InputSchema == nil { + t.Error("expected non-nil input schema") + } +} + +func TestMCPBridgeToolExecution(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + provider.RegisterTool(&ComposioTool{ + Name: "test_tool", + Description: "A test tool", + Scope: ScopeReadOnly, + AuthRequired: false, + Params: map[string]interface{}{}, + }) + + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + // Wrap the tool and execute it + tool := &ComposioTool{ + Name: "test_tool", + Description: "A test tool", + Scope: ScopeReadOnly, + AuthRequired: false, + Params: map[string]interface{}{}, + } + handler := bridge.wrapTool(tool) + + params := json.RawMessage(`{"key": "value"}`) + result, err := handler.Handler(context.Background(), params) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + if !strings.Contains(result, "success") { + t.Error("expected result to contain 'success'") + } + if !strings.Contains(result, "test_tool") { + t.Error("expected result to contain tool name") + } +} + +func TestMCPBridgeToolExecutionNotFound(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + tool := &ComposioTool{ + Name: "missing_tool", + Description: "A tool not in the provider", + Scope: ScopeReadOnly, + AuthRequired: false, + Params: map[string]interface{}{}, + } + handler := bridge.wrapTool(tool) + + _, err := handler.Handler(context.Background(), nil) + if err == nil { + t.Error("expected error for tool not in provider") + } +} + +func TestMCPBridgeToolAnnotations(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + // Test read-only annotation + tool := &ComposioTool{ + Name: "read_tool", + Scope: ScopeReadOnly, + Params: map[string]interface{}{}, + } + annotations := bridge.toolAnnotations(tool) + if annotations.ReadOnlyHint == nil || !*annotations.ReadOnlyHint { + t.Error("expected ReadOnlyHint to be true for read-only tool") + } + + // Test write annotation + tool = &ComposioTool{ + Name: "write_tool", + Scope: ScopeWrite, + Params: map[string]interface{}{}, + } + annotations = bridge.toolAnnotations(tool) + if annotations.ReadOnlyHint == nil || *annotations.ReadOnlyHint { + t.Error("expected ReadOnlyHint to be false for write tool") + } + if annotations.DestructiveHint == nil || !*annotations.DestructiveHint { + t.Error("expected DestructiveHint to be true for write tool") + } + + // Test action annotation + tool = &ComposioTool{ + Name: "action_tool", + Scope: ScopeAction, + Params: map[string]interface{}{}, + } + annotations = bridge.toolAnnotations(tool) + if annotations.ReadOnlyHint == nil || *annotations.ReadOnlyHint { + t.Error("expected ReadOnlyHint to be false for action tool") + } + if annotations.IdempotentHint == nil || !*annotations.IdempotentHint { + t.Error("expected IdempotentHint to be true for action tool") + } +} + +func TestMCPBridgeSearchHandler(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + provider.RegisterTool(&ComposioTool{ + Name: "github_issues", + Description: "List GitHub issues", + Scope: ScopeReadOnly, + Params: map[string]interface{}{}, + Tags: []string{"github", "issues"}, + Category: "github", + }) + + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + // Build the search handler manually (same as RegisterSearchTool does) + searchHandler := mcp.MCPToolHandler{ + Name: "composio_search_tools", + Description: "Search the composio tool catalog", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query", + }, + }, + }, + Handler: func(ctx context.Context, params json.RawMessage) (string, error) { + var req struct { + Query string `json:"query"` + } + if len(params) > 0 { + if err := json.Unmarshal(params, &req); err != nil { + return "", err + } + } + + results := bridge.provider.SearchTools(req.Query) + tools := make([]map[string]interface{}, 0, len(results)) + for _, t := range results { + tools = append(tools, map[string]interface{}{ + "name": t.Name, + "description": t.Description, + "scope": string(t.Scope), + "auth_required": t.AuthRequired, + "tags": t.Tags, + "category": t.Category, + }) + } + + output := map[string]interface{}{ + "tools": tools, + "count": len(tools), + } + data, _ := json.MarshalIndent(output, "", " ") + return string(data), nil + }, + } + + // Execute search with query + params := json.RawMessage(`{"query": "github"}`) + result, err := searchHandler.Handler(context.Background(), params) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + var output map[string]interface{} + if err := json.Unmarshal([]byte(result), &output); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + count, ok := output["count"].(float64) + if !ok { + t.Fatal("expected count field in result") + } + if count != 1 { + t.Errorf("expected 1 tool found, got %f", count) + } + + // Verify tool details + tools, ok := output["tools"].([]interface{}) + if !ok { + t.Fatal("expected tools array in result") + } + if len(tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(tools)) + } + toolMap, ok := tools[0].(map[string]interface{}) + if !ok { + t.Fatal("expected tool to be a map") + } + if toolMap["name"] != "github_issues" { + t.Errorf("expected name 'github_issues', got %v", toolMap["name"]) + } +} + +func TestMCPBridgeCredentialHandler(t *testing.T) { + t.Parallel() + provider := NewComposioProvider("test-key") + provider.Credentials().Store(&Credential{ + ID: "cred-1", + ServiceName: "github", + Type: "oauth", + Value: "token123", + }) + + server := mcp.NewMCPServer(mcp.ServerInfo{ + Name: "test", + Version: "0.0.1", + }) + bridge := NewMCPBridge(provider, server) + + // Build the credential handler manually + credHandler := mcp.MCPToolHandler{ + Name: "composio_credentials", + Description: "List composio credentials", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + Handler: func(ctx context.Context, params json.RawMessage) (string, error) { + creds := bridge.provider.Credentials().List() + items := make([]map[string]interface{}, 0, len(creds)) + for _, c := range creds { + items = append(items, map[string]interface{}{ + "id": c.ID, + "service_name": c.ServiceName, + "type": c.Type, + "scope": c.Scope, + "expired": c.IsExpired(), + }) + } + + output := map[string]interface{}{ + "credentials": items, + "count": len(items), + } + data, _ := json.MarshalIndent(output, "", " ") + return string(data), nil + }, + } + + result, err := credHandler.Handler(context.Background(), nil) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + + var output map[string]interface{} + if err := json.Unmarshal([]byte(result), &output); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + count, ok := output["count"].(float64) + if !ok { + t.Fatal("expected count field in result") + } + if count != 1 { + t.Errorf("expected 1 credential, got %f", count) + } +} From 4bd7bda55a226a2e26917fb76c3d309dd7c68abe Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 29 Jul 2026 13:33:58 +0530 Subject: [PATCH 2/2] fix: apply gofumpt formatting --- internal/composio/composio.go | 20 ++++++++++---------- internal/composio/mcp_bridge.go | 24 ++++++++++++------------ internal/composio/mcp_bridge_test.go | 20 ++++++++++---------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/internal/composio/composio.go b/internal/composio/composio.go index af04f961..8982f0c4 100644 --- a/internal/composio/composio.go +++ b/internal/composio/composio.go @@ -23,20 +23,20 @@ import ( type ToolScope string const ( - ScopeReadOnly ToolScope = "read" - ScopeWrite ToolScope = "write" - ScopeAction ToolScope = "action" + ScopeReadOnly ToolScope = "read" + ScopeWrite ToolScope = "write" + ScopeAction ToolScope = "action" ) // ComposioTool represents a tool available from the composio platform. type ComposioTool struct { - Name string `json:"name"` - Description string `json:"description"` - Scope ToolScope `json:"scope"` - AuthRequired bool `json:"auth_required"` - Params map[string]interface{} `json:"params"` - Tags []string `json:"tags"` - Category string `json:"category"` + Name string `json:"name"` + Description string `json:"description"` + Scope ToolScope `json:"scope"` + AuthRequired bool `json:"auth_required"` + Params map[string]interface{} `json:"params"` + Tags []string `json:"tags"` + Category string `json:"category"` } // ComposioToolResult is the result of executing a composio tool. diff --git a/internal/composio/mcp_bridge.go b/internal/composio/mcp_bridge.go index c155e1bb..0dd7aa86 100644 --- a/internal/composio/mcp_bridge.go +++ b/internal/composio/mcp_bridge.go @@ -69,12 +69,12 @@ func (b *MCPBridge) RegisterSearchTool() { tools := make([]map[string]interface{}, 0, len(results)) for _, t := range results { tools = append(tools, map[string]interface{}{ - "name": t.Name, - "description": t.Description, - "scope": string(t.Scope), + "name": t.Name, + "description": t.Description, + "scope": string(t.Scope), "auth_required": t.AuthRequired, - "tags": t.Tags, - "category": t.Category, + "tags": t.Tags, + "category": t.Category, }) } @@ -99,7 +99,7 @@ func (b *MCPBridge) RegisterCredentialTool() { Name: "composio_credentials", Description: "List composio credentials for connected services", InputSchema: map[string]interface{}{ - "type": "object", + "type": "object", "properties": map[string]interface{}{}, }, Handler: func(ctx context.Context, params json.RawMessage) (string, error) { @@ -107,12 +107,12 @@ func (b *MCPBridge) RegisterCredentialTool() { items := make([]map[string]interface{}, 0, len(creds)) for _, c := range creds { items = append(items, map[string]interface{}{ - "id": c.ID, - "service_name": c.ServiceName, - "type": c.Type, - "scope": c.Scope, - "expires_at": c.ExpiresAt.Format("2006-01-02T15:04:05Z"), - "expired": c.IsExpired(), + "id": c.ID, + "service_name": c.ServiceName, + "type": c.Type, + "scope": c.Scope, + "expires_at": c.ExpiresAt.Format("2006-01-02T15:04:05Z"), + "expired": c.IsExpired(), }) } diff --git a/internal/composio/mcp_bridge_test.go b/internal/composio/mcp_bridge_test.go index 88146d69..9d7f93ab 100644 --- a/internal/composio/mcp_bridge_test.go +++ b/internal/composio/mcp_bridge_test.go @@ -305,12 +305,12 @@ func TestMCPBridgeSearchHandler(t *testing.T) { tools := make([]map[string]interface{}, 0, len(results)) for _, t := range results { tools = append(tools, map[string]interface{}{ - "name": t.Name, - "description": t.Description, - "scope": string(t.Scope), + "name": t.Name, + "description": t.Description, + "scope": string(t.Scope), "auth_required": t.AuthRequired, - "tags": t.Tags, - "category": t.Category, + "tags": t.Tags, + "category": t.Category, }) } @@ -389,11 +389,11 @@ func TestMCPBridgeCredentialHandler(t *testing.T) { items := make([]map[string]interface{}, 0, len(creds)) for _, c := range creds { items = append(items, map[string]interface{}{ - "id": c.ID, - "service_name": c.ServiceName, - "type": c.Type, - "scope": c.Scope, - "expired": c.IsExpired(), + "id": c.ID, + "service_name": c.ServiceName, + "type": c.Type, + "scope": c.Scope, + "expired": c.IsExpired(), }) }