package api import ( "bytes" "crypto/rand" "encoding/base32" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/johanj/clawvault/lib" "github.com/pquerna/otp/totp" ) // Handlers holds dependencies for HTTP handlers. type Handlers struct { DB *lib.DB Cfg *lib.Config } // NewHandlers creates a new Handlers instance. func NewHandlers(db *lib.DB, cfg *lib.Config) *Handlers { return &Handlers{DB: db, Cfg: cfg} } // --------------------------------------------------------------------------- // Health & Setup // --------------------------------------------------------------------------- // Health returns server status. func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { count, _ := lib.EntryCount(h.DB) JSONResponse(w, http.StatusOK, map[string]any{ "status": "ok", "entries": count, "time": time.Now().UTC().Format(time.RFC3339), }) } // Setup creates the initial session (first-time setup). func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { // Create a web session session, err := lib.SessionCreate(h.DB, h.Cfg.SessionTTL, lib.ActorWeb) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session") return } lib.AuditLog(h.DB, &lib.AuditEvent{ Action: "setup", Actor: lib.ActorWeb, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, map[string]string{ "token": session.Token, }) } // --------------------------------------------------------------------------- // Entry CRUD // --------------------------------------------------------------------------- // ListEntries returns all entries (tree structure). func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) parentID := r.URL.Query().Get("parent_id") var parent *string if parentID != "" { parent = &parentID } entries, err := lib.EntryList(h.DB, h.Cfg, parent) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries") return } if entries == nil { entries = []lib.Entry{} } // For MCP, strip L2 field values if actor == lib.ActorMCP { for i := range entries { if entries[i].VaultData != nil { stripL2Fields(entries[i].VaultData) } } } JSONResponse(w, http.StatusOK, entries) } // GetEntry returns a single entry. func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) entryID := chi.URLParam(r, "id") entry, err := lib.EntryGet(h.DB, h.Cfg, entryID) if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") return } if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry") return } // Check if soft-deleted if entry.DeletedAt != nil { ErrorResponse(w, http.StatusNotFound, "deleted", "Entry has been deleted") return } // For MCP, strip L2 field values if actor == lib.ActorMCP && entry.VaultData != nil { stripL2Fields(entry.VaultData) } lib.AuditLog(h.DB, &lib.AuditEvent{ EntryID: entry.EntryID, Title: entry.Title, Action: lib.ActionRead, Actor: actor, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, entry) } // CreateEntry creates a new entry. func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) var req struct { Type string `json:"type"` Title string `json:"title"` ParentID string `json:"parent_id"` Data *lib.VaultData `json:"data"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if req.Title == "" { ErrorResponse(w, http.StatusBadRequest, "missing_title", "Title is required") return } if req.Type == "" { req.Type = lib.TypeCredential } entry := &lib.Entry{ Type: req.Type, Title: req.Title, ParentID: req.ParentID, DataLevel: lib.DataLevelL1, VaultData: req.Data, } if err := lib.EntryCreate(h.DB, h.Cfg, entry); err != nil { ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create entry") return } lib.AuditLog(h.DB, &lib.AuditEvent{ EntryID: entry.EntryID, Title: entry.Title, Action: lib.ActionCreate, Actor: actor, IPAddr: realIP(r), }) JSONResponse(w, http.StatusCreated, entry) } // UpdateEntry updates an existing entry. func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) entryID := chi.URLParam(r, "id") var req struct { Type string `json:"type"` Title string `json:"title"` ParentID string `json:"parent_id"` Version int `json:"version"` Data *lib.VaultData `json:"data"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } // Get existing entry existing, err := lib.EntryGet(h.DB, h.Cfg, entryID) if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") return } if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry") return } // Update fields if req.Title != "" { existing.Title = req.Title } if req.Type != "" { existing.Type = req.Type } existing.ParentID = req.ParentID existing.Version = req.Version if req.Data != nil { existing.VaultData = req.Data } if err := lib.EntryUpdate(h.DB, h.Cfg, existing); err != nil { if err == lib.ErrVersionConflict { ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error()) return } ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update entry") return } lib.AuditLog(h.DB, &lib.AuditEvent{ EntryID: existing.EntryID, Title: existing.Title, Action: lib.ActionUpdate, Actor: actor, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, existing) } // DeleteEntry soft-deletes an entry. func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) entryID := chi.URLParam(r, "id") // Get entry for audit log entry, _ := lib.EntryGet(h.DB, h.Cfg, entryID) if err := lib.EntryDelete(h.DB, entryID); err != nil { if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") return } ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete entry") return } title := "" if entry != nil { title = entry.Title } lib.AuditLog(h.DB, &lib.AuditEvent{ EntryID: entryID, Title: title, Action: lib.ActionDelete, Actor: actor, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) } // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- // SearchEntries searches entries by title. func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) query := r.URL.Query().Get("q") if query == "" { ErrorResponse(w, http.StatusBadRequest, "missing_query", "Query parameter 'q' is required") return } // Use fuzzy search for practicality entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Search failed") return } if entries == nil { entries = []lib.Entry{} } // For MCP, strip L2 field values if actor == lib.ActorMCP { for i := range entries { if entries[i].VaultData != nil { stripL2Fields(entries[i].VaultData) } } } JSONResponse(w, http.StatusOK, entries) } // --------------------------------------------------------------------------- // Password Generator // --------------------------------------------------------------------------- // GeneratePassword generates a random password. func (h *Handlers) GeneratePassword(w http.ResponseWriter, r *http.Request) { lengthStr := r.URL.Query().Get("length") length := 20 if lengthStr != "" { if l, err := strconv.Atoi(lengthStr); err == nil && l > 0 && l <= 128 { length = l } } symbols := r.URL.Query().Get("symbols") != "false" words := r.URL.Query().Get("words") == "true" var password string if words { password = generatePassphrase(4) } else { password = generatePassword(length, symbols) } JSONResponse(w, http.StatusOK, map[string]string{ "password": password, }) } func generatePassword(length int, symbols bool) string { const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const digits = "0123456789" const syms = "!@#$%^&*()_+-=[]{}|;:,.<>?" charset := letters + digits if symbols { charset += syms } b := make([]byte, length) rand.Read(b) for i := range b { b[i] = charset[int(b[i])%len(charset)] } return string(b) } func generatePassphrase(words int) string { wordList := []string{ "correct", "horse", "battery", "staple", "cloud", "mountain", "river", "forest", "castle", "dragon", "phoenix", "crystal", "shadow", "thunder", "whisper", "harvest", "journey", "compass", "anchor", "beacon", "bridge", "canyon", "desert", "empire", } b := make([]byte, words) rand.Read(b) parts := make([]string, words) for i := range parts { parts[i] = wordList[int(b[i])%len(wordList)] } return strings.Join(parts, "-") } // --------------------------------------------------------------------------- // Extension API // --------------------------------------------------------------------------- // GetTOTP generates a live TOTP code for an entry. func (h *Handlers) GetTOTP(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) entryID := chi.URLParam(r, "id") entry, err := lib.EntryGet(h.DB, h.Cfg, entryID) if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") return } if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry") return } if entry.VaultData == nil { ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field") return } // Find TOTP field var totpSeed string var isL2 bool for _, field := range entry.VaultData.Fields { if field.Kind == "totp" { if field.L2 { isL2 = true } else { totpSeed = field.Value } break } } if isL2 { JSONResponse(w, http.StatusOK, map[string]any{ "l2": true, }) return } if totpSeed == "" { ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field") return } // Normalize seed (remove spaces, uppercase) totpSeed = strings.ToUpper(strings.ReplaceAll(totpSeed, " ", "")) // Generate TOTP code code, err := totp.GenerateCode(totpSeed, time.Now()) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_totp", "Invalid TOTP seed") return } // Calculate time until expiry (30 second window) now := time.Now().Unix() expiresIn := 30 - (now % 30) lib.AuditLog(h.DB, &lib.AuditEvent{ EntryID: entry.EntryID, Title: entry.Title, Action: "totp", Actor: actor, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, map[string]any{ "code": code, "expires_in": expiresIn, "l2": false, }) } // MatchURL finds credentials matching a URL (for extension popup). func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) { urlStr := r.URL.Query().Get("url") if urlStr == "" { ErrorResponse(w, http.StatusBadRequest, "missing_url", "URL parameter required") return } // Extract domain from URL domain := extractDomain(urlStr) // Get all entries and filter by URL entries, err := lib.EntryList(h.DB, h.Cfg, nil) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries") return } var matches []lib.Entry for _, entry := range entries { if entry.VaultData == nil { continue } for _, u := range entry.VaultData.URLs { if strings.Contains(u, domain) || strings.Contains(domain, extractDomain(u)) { matches = append(matches, entry) break } } } if matches == nil { matches = []lib.Entry{} } JSONResponse(w, http.StatusOK, matches) } // MapFields uses LLM to map vault fields to form fields. func (h *Handlers) MapFields(w http.ResponseWriter, r *http.Request) { if h.Cfg.FireworksAPIKey == "" { ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "LLM not configured") return } var req struct { EntryID string `json:"entry_id"` PageFields []struct { Selector string `json:"selector"` Label string `json:"label"` Type string `json:"type"` Placeholder string `json:"placeholder"` } `json:"page_fields"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } entry, err := lib.EntryGet(h.DB, h.Cfg, req.EntryID) if err != nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") return } if entry.VaultData == nil { ErrorResponse(w, http.StatusBadRequest, "no_data", "Entry has no data") return } // Build field lists for LLM var vaultFields []string for _, f := range entry.VaultData.Fields { if !f.L2 { // Only include L1 fields vaultFields = append(vaultFields, f.Label) } } var formFields []string for _, f := range req.PageFields { desc := f.Selector if f.Label != "" { desc = f.Label + " (" + f.Selector + ")" } formFields = append(formFields, desc) } // Call LLM prompt := fmt.Sprintf(`Map these vault fields to these HTML form fields. Return JSON object mapping vault_field_label to css_selector. Vault fields: %s Form fields: %s Return ONLY valid JSON, no explanation. Example: {"Username":"#email","Password":"#password"}`, strings.Join(vaultFields, ", "), strings.Join(formFields, ", ")) llmResp, err := callLLM(h.Cfg, "You are a field mapping assistant. Map credential fields to form fields.", prompt) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "llm_failed", "LLM request failed") return } // Parse LLM response var mapping map[string]string if err := json.Unmarshal([]byte(llmResp), &mapping); err != nil { ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response") return } JSONResponse(w, http.StatusOK, mapping) } // --------------------------------------------------------------------------- // Import // --------------------------------------------------------------------------- // ImportEntries handles LLM-powered import from any format. func (h *Handlers) ImportEntries(w http.ResponseWriter, r *http.Request) { if h.Cfg.FireworksAPIKey == "" { ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "LLM not configured") return } actor := ActorFromContext(r.Context()) // Parse multipart form (max 10MB) if err := r.ParseMultipartForm(10 << 20); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_form", "Failed to parse form") return } file, _, err := r.FormFile("file") if err != nil { ErrorResponse(w, http.StatusBadRequest, "missing_file", "File is required") return } defer file.Close() content, err := io.ReadAll(file) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "read_failed", "Failed to read file") return } // Call LLM to parse prompt := fmt.Sprintf(`Parse this password manager export into an array of VaultData objects. For each entry, create a JSON object with: - title: string - type: one of "credential", "card", "identity", "note", "ssh_key", "totp" - fields: array of {label, value, kind, l2} - kind: "text", "password", "totp", "url" - l2: true for sensitive fields (card numbers, CVV/CVC, SSN, passport numbers, private keys, TOTP seeds) - urls: array of URLs if applicable - notes: any notes Mark l2:true on these sensitive field types: - Card numbers, CVV/CVC codes - SSN, passport numbers - Private keys, secret keys - TOTP seeds/secrets Return ONLY valid JSON array, no explanation. File content: %s`, string(content)) llmResp, err := callLLM(h.Cfg, "You are a data parser. Parse password manager exports into structured JSON.", prompt) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "llm_failed", "LLM request failed: "+err.Error()) return } // Parse LLM response var entries []lib.VaultData if err := json.Unmarshal([]byte(llmResp), &entries); err != nil { // Try to extract JSON from response start := strings.Index(llmResp, "[") end := strings.LastIndex(llmResp, "]") if start >= 0 && end > start { if json.Unmarshal([]byte(llmResp[start:end+1]), &entries) != nil { ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response") return } } else { ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response") return } } lib.AuditLog(h.DB, &lib.AuditEvent{ Action: lib.ActionImport, Actor: actor, IPAddr: realIP(r), Title: fmt.Sprintf("%d entries parsed", len(entries)), }) JSONResponse(w, http.StatusOK, map[string]any{ "entries": entries, "count": len(entries), }) } // ImportConfirm confirms and saves imported entries. func (h *Handlers) ImportConfirm(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) var req struct { Entries []lib.VaultData `json:"entries"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } var created int for _, vd := range req.Entries { entry := &lib.Entry{ Type: vd.Type, Title: vd.Title, DataLevel: lib.DataLevelL1, VaultData: &vd, } if err := lib.EntryCreate(h.DB, h.Cfg, entry); err == nil { created++ } } lib.AuditLog(h.DB, &lib.AuditEvent{ Action: lib.ActionImport, Actor: actor, IPAddr: realIP(r), Title: fmt.Sprintf("%d entries imported", created), }) JSONResponse(w, http.StatusOK, map[string]int{"imported": created}) } // --------------------------------------------------------------------------- // Audit Log // --------------------------------------------------------------------------- // GetAuditLog returns recent audit events. func (h *Handlers) GetAuditLog(w http.ResponseWriter, r *http.Request) { limitStr := r.URL.Query().Get("limit") limit := 100 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } } events, err := lib.AuditList(h.DB, limit) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list audit events") return } if events == nil { events = []lib.AuditEvent{} } JSONResponse(w, http.StatusOK, events) } // --------------------------------------------------------------------------- // MCP Endpoint // --------------------------------------------------------------------------- // MCPHandler handles JSON-RPC 2.0 MCP protocol requests. func (h *Handlers) MCPHandler(w http.ResponseWriter, r *http.Request) { var req struct { JSONRPC string `json:"jsonrpc"` ID any `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { mcpError(w, nil, -32700, "Parse error") return } if req.JSONRPC != "2.0" { mcpError(w, req.ID, -32600, "Invalid Request") return } var result any var err error switch req.Method { case "tools/list": result = h.mcpToolsList() case "tools/call": result, err = h.mcpToolsCall(r, req.Params) default: mcpError(w, req.ID, -32601, "Method not found") return } if err != nil { mcpError(w, req.ID, -32000, err.Error()) return } mcpSuccess(w, req.ID, result) } func (h *Handlers) mcpToolsList() map[string]any { return map[string]any{ "tools": []map[string]any{ { "name": "get_credential", "description": "Search and return a credential from the vault. L2 fields are omitted.", "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]string{"type": "string", "description": "Search query (title or URL)"}, }, "required": []string{"query"}, }, }, { "name": "list_credentials", "description": "List all credentials in the vault (titles, types, URLs only).", "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{ "filter": map[string]string{"type": "string", "description": "Optional type filter"}, }, }, }, { "name": "get_totp", "description": "Get a live TOTP code for an entry. Only works for L1 TOTP fields.", "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]string{"type": "string", "description": "Entry title or ID"}, }, "required": []string{"query"}, }, }, { "name": "search_vault", "description": "Search the vault by title.", "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]string{"type": "string", "description": "Search query"}, }, "required": []string{"query"}, }, }, { "name": "check_expiring", "description": "Check for entries with expiring credentials.", "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{ "days": map[string]any{"type": "number", "description": "Days to check (default 30)"}, }, }, }, }, } } func (h *Handlers) mcpToolsCall(r *http.Request, params json.RawMessage) (any, error) { var call struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` } if err := json.Unmarshal(params, &call); err != nil { return nil, fmt.Errorf("invalid params") } switch call.Name { case "get_credential", "search_vault": query, _ := call.Arguments["query"].(string) if query == "" { return nil, fmt.Errorf("query is required") } entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query) if err != nil { return nil, err } // Strip L2 fields for i := range entries { if entries[i].VaultData != nil { stripL2Fields(entries[i].VaultData) } } if len(entries) == 0 { return map[string]any{"content": []map[string]string{{"type": "text", "text": "No credentials found"}}}, nil } // For get_credential, return best match if call.Name == "get_credential" { result, _ := json.Marshal(entries[0]) return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil } result, _ := json.Marshal(entries) return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil case "list_credentials": filter, _ := call.Arguments["filter"].(string) entries, err := lib.EntryList(h.DB, h.Cfg, nil) if err != nil { return nil, err } var list []map[string]any for _, e := range entries { if filter != "" && e.Type != filter { continue } item := map[string]any{ "entry_id": e.EntryID, "title": e.Title, "type": e.Type, } if e.VaultData != nil && len(e.VaultData.URLs) > 0 { item["urls"] = e.VaultData.URLs } list = append(list, item) } result, _ := json.Marshal(list) return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil case "get_totp": query, _ := call.Arguments["query"].(string) if query == "" { return nil, fmt.Errorf("query is required") } entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query) if err != nil || len(entries) == 0 { return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil } entry := entries[0] if entry.VaultData == nil { return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil } for _, field := range entry.VaultData.Fields { if field.Kind == "totp" { if field.L2 { return map[string]any{"content": []map[string]string{{"type": "text", "text": "TOTP is L2 protected"}}}, nil } seed := strings.ToUpper(strings.ReplaceAll(field.Value, " ", "")) code, err := totp.GenerateCode(seed, time.Now()) if err != nil { return map[string]any{"content": []map[string]string{{"type": "text", "text": "Invalid TOTP seed"}}}, nil } now := time.Now().Unix() expiresIn := 30 - (now % 30) result := fmt.Sprintf(`{"code":"%s","expires_in":%d}`, code, expiresIn) return map[string]any{"content": []map[string]string{{"type": "text", "text": result}}}, nil } } return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil case "check_expiring": daysF, _ := call.Arguments["days"].(float64) days := int(daysF) if days <= 0 { days = 30 } entries, err := lib.EntryList(h.DB, h.Cfg, nil) if err != nil { return nil, err } cutoff := time.Now().AddDate(0, 0, days) var expiring []map[string]any for _, e := range entries { if e.VaultData == nil || e.VaultData.Expires == "" { continue } exp, err := time.Parse("2006-01-02", e.VaultData.Expires) if err != nil { continue } if exp.Before(cutoff) { daysRemaining := int(exp.Sub(time.Now()).Hours() / 24) expiring = append(expiring, map[string]any{ "title": e.Title, "type": e.Type, "expires": e.VaultData.Expires, "days_remaining": daysRemaining, }) } } result, _ := json.Marshal(expiring) return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil default: return nil, fmt.Errorf("unknown tool: %s", call.Name) } } func mcpSuccess(w http.ResponseWriter, id any, result any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": id, "result": result, }) } func mcpError(w http.ResponseWriter, id any, code int, message string) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": id, "error": map[string]any{ "code": code, "message": message, }, }) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func stripL2Fields(vd *lib.VaultData) { for i := range vd.Fields { if vd.Fields[i].L2 { vd.Fields[i].Value = "" } } } func extractDomain(urlStr string) string { // Simple domain extraction urlStr = strings.TrimPrefix(urlStr, "https://") urlStr = strings.TrimPrefix(urlStr, "http://") urlStr = strings.TrimPrefix(urlStr, "www.") if idx := strings.Index(urlStr, "/"); idx > 0 { urlStr = urlStr[:idx] } if idx := strings.Index(urlStr, ":"); idx > 0 { urlStr = urlStr[:idx] } return urlStr } func callLLM(cfg *lib.Config, system, user string) (string, error) { reqBody := map[string]any{ "model": cfg.LLMModel, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "max_tokens": 4096, } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("POST", "https://api.fireworks.ai/inference/v1/chat/completions", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+cfg.FireworksAPIKey) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var result struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` Error struct { Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } if result.Error.Message != "" { return "", fmt.Errorf("LLM error: %s", result.Error.Message) } if len(result.Choices) == 0 { return "", fmt.Errorf("no response from LLM") } return result.Choices[0].Message.Content, nil } // generateTOTPSecret generates a new TOTP secret. func generateTOTPSecret() string { b := make([]byte, 20) rand.Read(b) return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b) }