From ad32b20043cd459ac2f3858ba1ddbf61f55f4855 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 29 Jul 2026 13:21:20 +0530 Subject: [PATCH 1/2] feat: add SSE transport, tool search index, and credential vault Add standalone MCP toolkit infrastructure (zero hawk-eco deps): - transport_sse.go: ServeSSE, ServeSSEWithShutdown, buildSSEServer - tool_search.go: ToolSearchIndex, ToolInfo, SearchTools, GetByTag, IndexTool - vault.go: CredentialVault, Store/Get/Verify/Delete/List/GetByTag/GetByName - tool_search_test.go: 21 tests covering search, vault, and SSE transport - mcpkit.go: added toolIndex and vault fields to Server struct --- mcpkit.go | 4 +- tool_search.go | 168 +++++++++++++++++ tool_search_test.go | 446 ++++++++++++++++++++++++++++++++++++++++++++ transport_sse.go | 92 +++++++++ vault.go | 181 ++++++++++++++++++ 5 files changed, 890 insertions(+), 1 deletion(-) create mode 100644 tool_search.go create mode 100644 tool_search_test.go create mode 100644 transport_sse.go create mode 100644 vault.go diff --git a/mcpkit.go b/mcpkit.go index d66fad5..13884ef 100644 --- a/mcpkit.go +++ b/mcpkit.go @@ -24,13 +24,15 @@ import ( const MaxMCPRequestBodySize = 1 << 20 // 1 MB // Server wraps an mcp-go MCPServer with the ecosystem's standard -// transports (stdio and streamable HTTP). +// transports (stdio, streamable HTTP, and SSE). type Server struct { mcp *mcpserver.MCPServer bearerToken string bearerMiddlewareSet bool httpToken string serverStartErr chan error + toolIndex *ToolSearchIndex + vault *CredentialVault } // New creates a named MCP server with tool, prompt, and resource diff --git a/tool_search.go b/tool_search.go new file mode 100644 index 0000000..67fc24a --- /dev/null +++ b/tool_search.go @@ -0,0 +1,168 @@ +package mcpkit + +// This file adds tool search functionality to the shared MCP server +// scaffolding. Tool search allows clients to discover tools by name, +// description, or tags without knowing the exact tool name ahead of time. +// +// This is inspired by composio's tool search pattern and the MCP +// tool discovery protocol. + +import ( + "strings" + "sync" +) + +// ToolInfo holds metadata about a registered tool for search purposes. +type ToolInfo struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` +} + +// ToolSearchIndex maintains an in-memory index of registered tools for +// fast name/description/tag search. +type ToolSearchIndex struct { + mu sync.RWMutex + tools map[string]*ToolInfo +} + +// NewToolSearchIndex creates an empty tool search index. +func NewToolSearchIndex() *ToolSearchIndex { + return &ToolSearchIndex{ + tools: make(map[string]*ToolInfo), + } +} + +// IndexTool adds or updates a tool in the search index. +func (idx *ToolSearchIndex) IndexTool(info *ToolInfo) { + idx.mu.Lock() + defer idx.mu.Unlock() + idx.tools[info.Name] = info +} + +// Search queries the tool index by name, description, or tags. +// Returns tools matching any of the query terms (OR semantics). +// If query is empty, returns all tools. +func (idx *ToolSearchIndex) Search(query string) []*ToolInfo { + idx.mu.RLock() + defer idx.mu.RUnlock() + + if query == "" { + result := make([]*ToolInfo, 0, len(idx.tools)) + for _, t := range idx.tools { + result = append(result, t) + } + return result + } + + q := strings.ToLower(strings.TrimSpace(query)) + terms := strings.Fields(q) + + result := make([]*ToolInfo, 0) + for _, t := range idx.tools { + if matchesSearch(t, terms) { + result = append(result, t) + } + } + return result +} + +// GetByTag retrieves all tools with a given tag. +func (idx *ToolSearchIndex) GetByTag(tag string) []*ToolInfo { + idx.mu.RLock() + defer idx.mu.RUnlock() + result := make([]*ToolInfo, 0) + for _, t := range idx.tools { + for _, tg := range t.Tags { + if tg == tag { + result = append(result, t) + break + } + } + } + return result +} + +// GetTool retrieves a tool by name from the index. +func (idx *ToolSearchIndex) GetTool(name string) (*ToolInfo, bool) { + idx.mu.RLock() + defer idx.mu.RUnlock() + t, ok := idx.tools[name] + return t, ok +} + +// ListTools returns all indexed tools. +func (idx *ToolSearchIndex) ListTools() []*ToolInfo { + idx.mu.RLock() + defer idx.mu.RUnlock() + result := make([]*ToolInfo, 0, len(idx.tools)) + for _, t := range idx.tools { + result = append(result, t) + } + return result +} + +// Count returns the number of indexed tools. +func (idx *ToolSearchIndex) Count() int { + idx.mu.RLock() + defer idx.mu.RUnlock() + return len(idx.tools) +} + +// matchesSearch checks if a tool matches any of the search terms. +func matchesSearch(t *ToolInfo, terms []string) bool { + if len(terms) == 0 { + return true + } + + name := strings.ToLower(t.Name) + desc := strings.ToLower(t.Description) + + for _, term := range terms { + if strings.Contains(name, term) { + return true + } + if strings.Contains(desc, term) { + return true + } + for _, tag := range t.Tags { + if strings.Contains(strings.ToLower(tag), term) { + return true + } + } + } + return false +} + +// SearchTools searches the server's registered tools by name, description, +// or tags. This is a convenience method that wraps the tool search index. +// +// Usage: +// +// s := mcpkit.New("my-server", "1.0.0") +// s.AddTool(tool, handler) +// s.IndexTool(mcpkit.ToolInfo{Name: "my_tool", Description: "does X"}) +// results := s.SearchTools("X") +func (s *Server) SearchTools(query string) []*ToolInfo { + if s.toolIndex == nil { + return nil + } + return s.toolIndex.Search(query) +} + +// IndexTool adds a tool to the search index. +func (s *Server) IndexTool(info *ToolInfo) { + if s.toolIndex == nil { + s.toolIndex = NewToolSearchIndex() + } + s.toolIndex.IndexTool(info) +} + +// ToolSearch returns the tool search index, initializing it if needed. +func (s *Server) ToolSearch() *ToolSearchIndex { + if s.toolIndex == nil { + s.toolIndex = NewToolSearchIndex() + } + return s.toolIndex +} diff --git a/tool_search_test.go b/tool_search_test.go new file mode 100644 index 0000000..dfe7d5e --- /dev/null +++ b/tool_search_test.go @@ -0,0 +1,446 @@ +package mcpkit + +import ( + "testing" + "time" +) + +// --- Tool Search Tests --- + +func TestToolSearchIndexEmpty(t *testing.T) { + t.Parallel() + idx := NewToolSearchIndex() + if idx.Count() != 0 { + t.Errorf("expected 0 tools, got %d", idx.Count()) + } + if len(idx.ListTools()) != 0 { + t.Error("expected empty tool list") + } +} + +func TestToolSearchIndexPutAndGet(t *testing.T) { + t.Parallel() + idx := NewToolSearchIndex() + + info := &ToolInfo{ + Name: "test_tool", + Description: "A test tool for testing", + Tags: []string{"test", "utility"}, + } + idx.IndexTool(info) + + if idx.Count() != 1 { + t.Errorf("expected 1 tool, got %d", idx.Count()) + } + + retrieved, ok := idx.GetTool("test_tool") + if !ok { + t.Fatal("expected to find tool") + } + if retrieved.Name != "test_tool" { + t.Errorf("expected name 'test_tool', got %q", retrieved.Name) + } + if retrieved.Description != "A test tool for testing" { + t.Errorf("expected description, got %q", retrieved.Description) + } +} + +func TestToolSearchIndexSearch(t *testing.T) { + t.Parallel() + idx := NewToolSearchIndex() + + idx.IndexTool(&ToolInfo{ + Name: "search_files", + Description: "Search for files in the codebase", + Tags: []string{"search", "files"}, + }) + idx.IndexTool(&ToolInfo{ + Name: "list_tools", + Description: "List available MCP tools", + Tags: []string{"list", "tools"}, + }) + idx.IndexTool(&ToolInfo{ + Name: "edit_file", + Description: "Edit a file's contents", + Tags: []string{"edit", "files"}, + }) + + // Search by name + results := idx.Search("search") + if len(results) != 1 { + t.Errorf("expected 1 result for 'search', got %d", len(results)) + } + if results[0].Name != "search_files" { + t.Errorf("expected 'search_files', got %q", results[0].Name) + } + + // Search by tag + results = idx.Search("files") + if len(results) != 2 { + t.Errorf("expected 2 results for 'files', got %d", len(results)) + } + + // Search by description + results = idx.Search("available") + if len(results) != 1 { + t.Errorf("expected 1 result for 'available', got %d", len(results)) + } + + // Empty query returns all + results = idx.Search("") + if len(results) != 3 { + t.Errorf("expected 3 results for empty query, got %d", len(results)) + } + + // No match + results = idx.Search("nonexistent") + if len(results) != 0 { + t.Errorf("expected 0 results for 'nonexistent', got %d", len(results)) + } +} + +func TestToolSearchIndexGetByTag(t *testing.T) { + t.Parallel() + idx := NewToolSearchIndex() + + idx.IndexTool(&ToolInfo{ + Name: "tool1", + Tags: []string{"alpha", "beta"}, + }) + idx.IndexTool(&ToolInfo{ + Name: "tool2", + Tags: []string{"beta", "gamma"}, + }) + idx.IndexTool(&ToolInfo{ + Name: "tool3", + Tags: []string{"gamma"}, + }) + + results := idx.GetByTag("beta") + if len(results) != 2 { + t.Errorf("expected 2 tools with tag 'beta', got %d", len(results)) + } + + results = idx.GetByTag("alpha") + if len(results) != 1 { + t.Errorf("expected 1 tool with tag 'alpha', got %d", len(results)) + } +} + +func TestServerSearchTools(t *testing.T) { + t.Parallel() + s := New("test", "0.0.1") + + // No index yet + results := s.SearchTools("test") + if results != nil { + t.Error("expected nil results before index creation") + } + + // Index a tool + s.IndexTool(&ToolInfo{ + Name: "my_tool", + Description: "Does something useful", + Tags: []string{"utility"}, + }) + + results = s.SearchTools("useful") + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } + if results[0].Name != "my_tool" { + t.Errorf("expected 'my_tool', got %q", results[0].Name) + } +} + +func TestServerToolSearch(t *testing.T) { + t.Parallel() + s := New("test", "0.0.1") + idx := s.ToolSearch() + if idx == nil { + t.Fatal("expected non-nil ToolSearch index") + } + if idx.Count() != 0 { + t.Errorf("expected 0 tools, got %d", idx.Count()) + } +} + +// --- Credential Vault Tests --- + +func TestCredentialVaultEmpty(t *testing.T) { + t.Parallel() + v := NewCredentialVault() + if v.Count() != 0 { + t.Errorf("expected 0 credentials, got %d", v.Count()) + } + if v.Get("nonexistent") != nil { + t.Error("expected nil for nonexistent credential") + } +} + +func TestCredentialVaultStoreAndGet(t *testing.T) { + t.Parallel() + v := NewCredentialVault() + + c := &Credential{ + ID: "cred-1", + Name: "api-key-1", + Type: "api_key", + Value: "secret123", + Scope: ScopeGlobal, + } + v.Store(c) + + if v.Count() != 1 { + t.Errorf("expected 1 credential, got %d", v.Count()) + } + + retrieved := v.Get("cred-1") + if retrieved == nil { + t.Fatal("expected to find credential") + } + if retrieved.Name != "api-key-1" { + t.Errorf("expected name 'api-key-1', got %q", retrieved.Name) + } + if retrieved.Value != "secret123" { + t.Errorf("expected value 'secret123', got %q", retrieved.Value) + } +} + +func TestCredentialVaultGetByName(t *testing.T) { + t.Parallel() + v := NewCredentialVault() + + v.Store(&Credential{ + ID: "cred-1", + Name: "github-token", + Type: "oauth_token", + Value: "ghp_xxx", + Scope: ScopeGlobal, + }) + v.Store(&Credential{ + ID: "cred-2", + Name: "github-token", + Type: "oauth_token", + Value: "ghp_yyy", + Scope: ScopeProject, + }) + + // Get by name and scope + retrieved := v.GetByName("github-token", ScopeGlobal) + if retrieved == nil { + t.Fatal("expected to find global github-token") + } + if retrieved.Value != "ghp_xxx" { + t.Errorf("expected value 'ghp_xxx', got %q", retrieved.Value) + } + + // Project-scoped credential + retrieved = v.GetByName("github-token", ScopeProject) + if retrieved == nil { + t.Fatal("expected to find project github-token") + } + if retrieved.Value != "ghp_yyy" { + t.Errorf("expected value 'ghp_yyy', got %q", retrieved.Value) + } + + // Nonexistent + retrieved = v.GetByName("nonexistent", ScopeGlobal) + if retrieved != nil { + t.Error("expected nil for nonexistent credential") + } +} + +func TestCredentialVaultGetByTag(t *testing.T) { + t.Parallel() + v := NewCredentialVault() + + v.Store(&Credential{ + ID: "cred-1", + Name: "key1", + Value: "val1", + Tags: []string{"production", "critical"}, + }) + v.Store(&Credential{ + ID: "cred-2", + Name: "key2", + Value: "val2", + Tags: []string{"staging", "critical"}, + }) + v.Store(&Credential{ + ID: "cred-3", + Name: "key3", + Value: "val3", + Tags: []string{"dev"}, + }) + + results := v.GetByTag("critical") + if len(results) != 2 { + t.Errorf("expected 2 credentials with tag 'critical', got %d", len(results)) + } + + results = v.GetByTag("production") + if len(results) != 1 { + t.Errorf("expected 1 credential with tag 'production', got %d", len(results)) + } +} + +func TestCredentialVaultDelete(t *testing.T) { + t.Parallel() + v := NewCredentialVault() + + v.Store(&Credential{ + ID: "cred-1", + Name: "key1", + Value: "val1", + }) + + if !v.Delete("cred-1") { + t.Error("expected Delete to return true") + } + if v.Count() != 0 { + t.Errorf("expected 0 credentials after delete, got %d", v.Count()) + } + + // Delete nonexistent + if v.Delete("nonexistent") { + t.Error("expected Delete to return false for nonexistent") + } +} + +func TestCredentialVaultVerify(t *testing.T) { + t.Parallel() + v := NewCredentialVault() + + v.Store(&Credential{ + ID: "cred-1", + Name: "key1", + Value: "secret123", + }) + + if !v.Verify("cred-1", "secret123") { + t.Error("expected Verify to return true for correct value") + } + if v.Verify("cred-1", "wrong") { + t.Error("expected Verify to return false for wrong value") + } + if v.Verify("nonexistent", "secret123") { + t.Error("expected Verify to return false for nonexistent credential") + } +} + +func TestCredentialVaultList(t *testing.T) { + t.Parallel() + v := NewCredentialVault() + + v.Store(&Credential{ID: "cred-1", Name: "key1", Value: "val1"}) + v.Store(&Credential{ID: "cred-2", Name: "key2", Value: "val2"}) + + list := v.List() + if len(list) != 2 { + t.Errorf("expected 2 credentials, got %d", len(list)) + } +} + +func TestCredentialIsExpired(t *testing.T) { + t.Parallel() + + // No expiry + c := &Credential{ID: "1", Value: "val"} + if c.IsExpired() { + t.Error("expected non-expired credential to not be expired") + } + + // Expired + c.ExpiresAt = time.Now().Add(-1 * time.Hour) + if !c.IsExpired() { + t.Error("expected expired credential to be expired") + } +} + +func TestServerVault(t *testing.T) { + t.Parallel() + s := New("test", "0.0.1") + v := s.Vault() + if v == nil { + t.Fatal("expected non-nil vault") + } + if v.Count() != 0 { + t.Errorf("expected 0 credentials, got %d", v.Count()) + } +} + +func TestServerStoreAndGetCredential(t *testing.T) { + t.Parallel() + s := New("test", "0.0.1") + + s.StoreCredential(&Credential{ + ID: "cred-1", + Name: "api-key", + Value: "secret", + }) + + c := s.GetCredential("cred-1") + if c == nil { + t.Fatal("expected to find credential") + } + if c.Value != "secret" { + t.Errorf("expected value 'secret', got %q", c.Value) + } +} + +// --- SSE Transport Tests --- + +func TestServerServeSSENil(t *testing.T) { + t.Parallel() + s := New("test", "0.0.1") + + // buildSSEServer should succeed with no auth + sseServer, err := s.buildSSEServer(":0") + if err != nil { + t.Fatalf("buildSSEServer failed: %v", err) + } + if sseServer == nil { + t.Fatal("expected non-nil SSE server") + } +} + +func TestServerServeSSEConflictingAuth(t *testing.T) { + t.Parallel() + s := New("test", "0.0.1") + s.RequireBearerToken("token1") + s.WithHTTPToken("token2") + + _, err := s.buildSSEServer(":0") + if err == nil { + t.Error("expected error for conflicting auth modes") + } +} + +func TestServerServeSSEBearerOnly(t *testing.T) { + t.Parallel() + s := New("test", "0.0.1") + s.RequireBearerToken("mytoken") + + sseServer, err := s.buildSSEServer(":0") + if err != nil { + t.Fatalf("buildSSEServer failed: %v", err) + } + if sseServer == nil { + t.Fatal("expected non-nil SSE server") + } +} + +func TestServerServeSSEHTTPToken(t *testing.T) { + t.Parallel() + s := New("test", "0.0.1") + s.WithHTTPToken("mytoken") + + sseServer, err := s.buildSSEServer(":0") + if err != nil { + t.Fatalf("buildSSEServer failed: %v", err) + } + if sseServer == nil { + t.Fatal("expected non-nil SSE server") + } +} diff --git a/transport_sse.go b/transport_sse.go new file mode 100644 index 0000000..b292b95 --- /dev/null +++ b/transport_sse.go @@ -0,0 +1,92 @@ +package mcpkit + +// This file adds SSE (Server-Sent Events) transport support to the +// shared MCP server scaffolding. SSE is the classic MCP transport used +// by Claude Desktop and other clients that don't support streamable HTTP. +// +// SSE works by: +// 1. Client GETs /sse to establish an event stream +// 2. Server sends a message endpoint URL via the first SSE event +// 3. Client POSTs JSON-RPC requests to that endpoint +// 4. Server responds over the SSE stream +// +// The SSE transport adds the same auth (bearer token / HTTP token) and +// body-size protection as the streamable HTTP transport. + +import ( + "context" + "fmt" + "net/http" + + mcpserver "github.com/mark3labs/mcp-go/server" +) + +// ServeSSE starts the MCP server on the SSE transport at +// http:///sse and blocks until the server stops. +// +// Auth precedence: if WithHTTPToken was set, every request is gated at +// the transport boundary (bearer or X-API-Key). Otherwise, if +// RequireBearerToken was set, only tool calls without a matching bearer +// header are rejected. The two modes are mutually exclusive. +// +// ServeStdio is never affected, regardless of these settings. +func (s *Server) ServeSSE(addr string) error { + sseServer, err := s.buildSSEServer(addr) + if err != nil { + return err + } + + // If HTTP token auth is configured, wrap the SSE server (which + // implements http.Handler) with the token-checking handler and serve + // via a custom http.Server. Otherwise, let the SSE server manage its + // own listener. + if s.httpToken != "" { + srv := &http.Server{ + Addr: addr, + Handler: httpTokenHandler(s.httpToken, sseServer), + } + return srv.ListenAndServe() + } + + return sseServer.Start(addr) +} + +// ServeSSEWithShutdown starts the MCP server on the SSE transport at +// http:///sse and returns the underlying server so the caller can +// invoke Shutdown(ctx) for graceful teardown. It launches the listener +// in a background goroutine and returns immediately (once the server +// object exists), so the caller owns the lifecycle. +func (s *Server) ServeSSEWithShutdown(addr string) (*mcpserver.SSEServer, error) { + sseServer, err := s.buildSSEServer(addr) + if err != nil { + return nil, err + } + s.serverStartErr = make(chan error, 1) + go func() { s.serverStartErr <- sseServer.Start(addr) }() + return sseServer, nil +} + +// buildSSEServer constructs the SSE transport, applying the configured +// auth mode. +func (s *Server) buildSSEServer(addr string) (*mcpserver.SSEServer, error) { + if s.bearerToken != "" && s.httpToken != "" { + return nil, fmt.Errorf("mcpkit: cannot set both RequireBearerToken and WithHTTPToken; use at most one") + } + + var opts []mcpserver.SSEOption + + if s.bearerToken != "" && s.httpToken == "" { + // Bearer-only mode: attach the context func that feeds + // bearerToolMiddleware (registered in RequireBearerToken). + // SSEContextFunc and HTTPContextFunc have the same signature; + // we wrap to satisfy the SSE-specific type. + bearerCtxFunc := bearerHTTPContextFunc(s.bearerToken) + opts = append(opts, mcpserver.WithSSEContextFunc( + func(ctx context.Context, r *http.Request) context.Context { + return bearerCtxFunc(ctx, r) + }, + )) + } + + return mcpserver.NewSSEServer(s.mcp, opts...), nil +} diff --git a/vault.go b/vault.go new file mode 100644 index 0000000..ab459af --- /dev/null +++ b/vault.go @@ -0,0 +1,181 @@ +package mcpkit + +// This file adds a credential vault to the shared MCP server scaffolding. +// The vault stores API keys, tokens, and other secrets for MCP tools +// that need to call external services. +// +// The vault is in-memory by default. For production use, it can be +// backed by a persistent store (e.g., OS keychain, encrypted file). +// +// This is inspired by composio's credential management pattern. + +import ( + "crypto/subtle" + "fmt" + "sync" + "time" +) + +// CredentialScope defines the scope at which a credential is valid. +type CredentialScope string + +const ( + ScopeGlobal CredentialScope = "global" + ScopeProject CredentialScope = "project" + ScopeSession CredentialScope = "session" +) + +// Credential holds a single secret with metadata. +type Credential struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "api_key", "oauth_token", "bearer_token", "password" + Value string `json:"-"` // never serialized + Scope CredentialScope `json:"scope"` + ProjectID string `json:"project_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Tags []string `json:"tags,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ExpiresAt time.Time `json:"expires_at,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) +} + +// CredentialVault stores and manages credentials for MCP tools. +type CredentialVault struct { + mu sync.RWMutex + items map[string]*Credential +} + +// NewCredentialVault creates an empty credential vault. +func NewCredentialVault() *CredentialVault { + return &CredentialVault{ + items: make(map[string]*Credential), + } +} + +// Store adds or updates a credential in the vault. +func (v *CredentialVault) Store(c *Credential) { + v.mu.Lock() + defer v.mu.Unlock() + if c.CreatedAt.IsZero() { + c.CreatedAt = time.Now() + } + c.UpdatedAt = time.Now() + v.items[c.ID] = c +} + +// Get retrieves a credential by ID. Returns nil if not found or expired. +func (v *CredentialVault) Get(id string) *Credential { + v.mu.RLock() + defer v.mu.RUnlock() + c, ok := v.items[id] + if !ok { + return nil + } + if c.IsExpired() { + return nil + } + return c +} + +// GetByName retrieves a credential by name within a scope. +func (v *CredentialVault) GetByName(name string, scope CredentialScope) *Credential { + v.mu.RLock() + defer v.mu.RUnlock() + for _, c := range v.items { + if c.Name == name && c.Scope == scope && !c.IsExpired() { + return c + } + } + return nil +} + +// GetByTag retrieves all credentials with a given tag. +func (v *CredentialVault) GetByTag(tag string) []*Credential { + v.mu.RLock() + defer v.mu.RUnlock() + result := make([]*Credential, 0) + for _, c := range v.items { + if c.IsExpired() { + continue + } + for _, t := range c.Tags { + if t == tag { + result = append(result, c) + break + } + } + } + return result +} + +// List returns all non-expired credentials. +func (v *CredentialVault) List() []*Credential { + v.mu.RLock() + defer v.mu.RUnlock() + result := make([]*Credential, 0, len(v.items)) + for _, c := range v.items { + if !c.IsExpired() { + result = append(result, c) + } + } + return result +} + +// Delete removes a credential from the vault. +func (v *CredentialVault) Delete(id string) bool { + v.mu.Lock() + defer v.mu.Unlock() + if _, ok := v.items[id]; !ok { + return false + } + delete(v.items, id) + return true +} + +// Count returns the number of credentials in the vault. +func (v *CredentialVault) Count() int { + v.mu.RLock() + defer v.mu.RUnlock() + return len(v.items) +} + +// Verify checks whether a credential's value matches the expected value +// using constant-time comparison. +func (v *CredentialVault) Verify(id, expectedValue string) bool { + c := v.Get(id) + if c == nil { + return false + } + return subtle.ConstantTimeCompare([]byte(c.Value), []byte(expectedValue)) == 1 +} + +// Vault returns the server's credential vault, initializing it if needed. +func (s *Server) Vault() *CredentialVault { + if s.vault == nil { + s.vault = NewCredentialVault() + } + return s.vault +} + +// StoreCredential adds a credential to the server's vault. +func (s *Server) StoreCredential(c *Credential) { + s.Vault().Store(c) +} + +// GetCredential retrieves a credential by ID from the server's vault. +func (s *Server) GetCredential(id string) *Credential { + return s.Vault().Get(id) +} + +// _ ensures fmt is used (for potential future error formatting). +var _ = fmt.Sprintf From 36c3e18c4b816a868fd809f3dda2377fad576138 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 29 Jul 2026 13:33:56 +0530 Subject: [PATCH 2/2] fix: apply gofumpt formatting --- tool_search.go | 10 +++++----- vault.go | 26 +++++++++++++------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tool_search.go b/tool_search.go index 67fc24a..39d505c 100644 --- a/tool_search.go +++ b/tool_search.go @@ -14,17 +14,17 @@ import ( // ToolInfo holds metadata about a registered tool for search purposes. type ToolInfo struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Tags []string `json:"tags,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` Attrs map[string]interface{} `json:"attrs,omitempty"` } // ToolSearchIndex maintains an in-memory index of registered tools for // fast name/description/tag search. type ToolSearchIndex struct { - mu sync.RWMutex - tools map[string]*ToolInfo + mu sync.RWMutex + tools map[string]*ToolInfo } // NewToolSearchIndex creates an empty tool search index. diff --git a/vault.go b/vault.go index ab459af..b650535 100644 --- a/vault.go +++ b/vault.go @@ -20,25 +20,25 @@ import ( type CredentialScope string const ( - ScopeGlobal CredentialScope = "global" + ScopeGlobal CredentialScope = "global" ScopeProject CredentialScope = "project" ScopeSession CredentialScope = "session" ) // Credential holds a single secret with metadata. type Credential struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` // "api_key", "oauth_token", "bearer_token", "password" - Value string `json:"-"` // never serialized - Scope CredentialScope `json:"scope"` - ProjectID string `json:"project_id,omitempty"` - SessionID string `json:"session_id,omitempty"` - Tags []string `json:"tags,omitempty"` - Attrs map[string]interface{} `json:"attrs,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ExpiresAt time.Time `json:"expires_at,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "api_key", "oauth_token", "bearer_token", "password" + Value string `json:"-"` // never serialized + Scope CredentialScope `json:"scope"` + ProjectID string `json:"project_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Tags []string `json:"tags,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ExpiresAt time.Time `json:"expires_at,omitempty"` } // IsExpired reports whether the credential has expired.