Files
2026-10-01 13:59:36 -04:00

862 lines
28 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/joho/godotenv"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/qdrant/go-client/qdrant"
"go.uber.org/zap"
)
var version = "0.1.0"
func main() {
godotenv.Load()
logger, err := zap.NewProduction()
if err != nil {
panic(fmt.Sprintf("failed to init logger: %v", err))
}
defer logger.Sync()
openAIKey := os.Getenv("OPENAI_API_KEY")
port := envOrDefault("PORT", "8080")
sqlitePath := envOrDefault("SQLITE_PATH", "flashcard.db")
store, err := newSqliteStore(sqlitePath)
if err != nil {
logger.Fatal("failed to init sqlite", zap.Error(err))
}
defer store.close()
store.bootstrapAdmin(context.Background(), logger)
hmacKeyFile := envOrDefault("HMAC_KEY_FILE", ".secret")
hmacKey, err := loadHMACKey(hmacKeyFile, logger)
if err != nil {
logger.Fatal("failed to load HMAC key", zap.Error(err))
}
searchEngine := createSearchEngine(logger, store, openAIKey)
mcpServer := server.NewMCPServer(
"flashcard-mcp",
version,
server.WithToolCapabilities(true),
server.WithLogging(),
)
registerStoreFlashcard(mcpServer, store, searchEngine, logger)
registerSearchFlashcard(mcpServer, searchEngine)
registerListRecentFlashcard(mcpServer, store, logger)
registerListTags(mcpServer, store, logger)
registerCreateTag(mcpServer, store, logger)
registerUpdateTag(mcpServer, store, logger)
registerMergeTag(mcpServer, store, logger)
registerUpdateFlashcard(mcpServer, store, searchEngine, logger)
registerDeleteFlashcard(mcpServer, store, searchEngine, logger)
registerVoteFlashcard(mcpServer, store, logger)
streamableServer := server.NewStreamableHTTPServer(mcpServer)
withAuth := func(next http.Handler) http.Handler {
return authMiddleware(store, hmacKey, logger, next)
}
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"build_date": os.Getenv("BUILD_DATE"),
"git_commit": os.Getenv("GIT_COMMIT"),
})
})
mux.Handle("/mcp", withAuth(streamableServer))
mux.HandleFunc("/api/v1/auth/login", loginHandler(store, hmacKey, logger))
mux.HandleFunc("/api/v1/auth/logout", logoutHandler())
mux.HandleFunc("/api/v1/auth/me", withAuth(getUserHandler(store, logger)).ServeHTTP)
mux.HandleFunc("/api/v1/auth/password", withAuth(changePasswordHandler(store, logger)).ServeHTTP)
mux.Handle("/api/v1/sessions", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
createSessionHandler(store, logger).ServeHTTP(w, r)
case http.MethodGet:
listSessionsHandler(store, logger).ServeHTTP(w, r)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
})))
mux.Handle("/api/v1/sessions/", withAuth(deleteSessionHandler(store, logger)))
mux.Handle("/api/v1/users", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
createUserHandler(store, logger).ServeHTTP(w, r)
case http.MethodGet:
listUsersHandler(store, logger).ServeHTTP(w, r)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
})))
mux.Handle("/api/v1/flashcards", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
summaries, err := store.listAll(r.Context())
if err != nil {
logger.Error("list flashcards", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, map[string][]flashcardSummary{"flashcards": summaries})
})))
mux.Handle("/api/v1/flashcards/search", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing query parameter 'q'"})
return
}
results, err := searchEngine.Search(r.Context(), query, 50)
if err != nil {
logger.Error("search flashcards", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": results, "query": query})
})))
mux.Handle("/api/v1/flashcards/", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
serveFlashcardDetail(w, r, store)
})))
mux.Handle("/", publicHandler())
httpServer := &http.Server{
Addr: ":" + port,
Handler: corsMiddleware(mux),
}
logger.Info("starting", zap.String("port", port))
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigChan
logger.Info("shutting down", zap.String("signal", sig.String()))
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := streamableServer.Shutdown(shutdownCtx); err != nil {
logger.Error("shutdown error", zap.Error(err))
}
store.close()
os.Exit(0)
}()
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal("server stopped", zap.Error(err))
}
}
func createSearchEngine(logger *zap.Logger, sqliteStore *sqliteStore, openAIKey string) SearchEngine {
qdrantURL := os.Getenv("QDRANT_URL")
searchEngine := os.Getenv("SEARCH_ENGINE")
if searchEngine == "fts5" || qdrantURL == "" {
logger.Info("using SQLite FTS5 for search", zap.String("engine", searchEngine))
return newFTS5Engine(sqliteStore)
}
embeddingModel := os.Getenv("EMBEDDING_MODEL")
embeddingBaseURL := os.Getenv("EMBEDDING_BASE_URL")
qdrantPort := 6334
if p, err := strconv.Atoi(envOrDefault("QDRANT_PORT", "6334")); err == nil {
qdrantPort = p
}
qdrantAPIKey := os.Getenv("QDRANT_API_KEY")
qdrantTLS := os.Getenv("QDRANT_TLS") == "true"
client, err := qdrant.NewClient(&qdrant.Config{
Host: qdrantURL,
Port: qdrantPort,
APIKey: qdrantAPIKey,
UseTLS: qdrantTLS,
})
if err != nil {
logger.Fatal("failed to create qdrant client", zap.Error(err))
}
emb := newEmbedder(openAIKey, embeddingModel, embeddingBaseURL)
store := newQdrantStore(client, emb, sqliteStore, logger)
if err := store.ensureCollection(context.Background()); err != nil {
logger.Fatal("failed to ensure collection", zap.Error(err))
}
return store
}
func registerStoreFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("store_flashcard",
mcp.WithDescription("Store a flashcard for later retrieval. Use this to persist useful information discovered during a session. Use as few tags as possible — ideally 0 to 2 — and reuse existing tags (see list_tags) rather than inventing new ones; create a genuinely new tag with create_tag first."),
mcp.WithString("note",
mcp.Required(),
mcp.Description("The markdown body — keep it concise, one paragraph usually"),
),
mcp.WithNumber("public_id",
mcp.Description("Optional public identifier. Auto-generated if omitted."),
),
mcp.WithArray("repos",
mcp.Description("Optional list of repository names"),
),
mcp.WithString("host",
mcp.Description("Optional hostname or null if universal"),
),
mcp.WithArray("tags",
mcp.Description("Optional list of tags"),
),
mcp.WithString("user",
mcp.Description("Optional user identifier"),
),
mcp.WithString("context",
mcp.Description("Optional context — what triggered this"),
),
),
handleStoreFlashcard(sqlite, engine, logger),
)
}
func registerSearchFlashcard(mcpServer *server.MCPServer, engine SearchEngine) {
mcpServer.AddTool(
mcp.NewTool("search_flashcard",
mcp.WithDescription("Search previously stored flashcards using semantic search. Returns the most relevant entries for the given query."),
mcp.WithString("query",
mcp.Required(),
mcp.Description("The search query"),
),
mcp.WithNumber("limit",
mcp.Description("Maximum number of results to return (default: 5)"),
),
),
handleSearchFlashcard(engine),
)
}
func registerListRecentFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("list_recent_flashcards",
mcp.WithDescription("List recently created flashcards, optionally filtered by tag, repo, or host. Use this at the start of a session to recall what was learned recently."),
mcp.WithNumber("limit",
mcp.Description("Maximum number of results to return (default: 20)"),
),
mcp.WithString("tag",
mcp.Description("Filter by tag (e.g. 'preference', 'convention')"),
),
mcp.WithString("repo",
mcp.Description("Filter by repository name (e.g. 'st-pierre', 'loopagent')"),
),
mcp.WithString("host",
mcp.Description("Filter by hostname (e.g. 'shulgi')"),
),
),
handleListRecentFlashcard(sqlite, logger),
)
}
func registerListTags(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("list_tags",
mcp.WithDescription("List the tag vocabulary: every known tag with its description and its count. count is the number of current flashcards (latest version, not deleted) using the tag. A count of 0 means no current flashcard uses it — the tag still exists and may still be reused, so do not treat it as absent. Check this before storing a flashcard, and reuse an existing tag rather than inventing a near-duplicate."),
),
handleListTags(sqlite, logger),
)
}
func handleListTags(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
tags, err := sqlite.listTags(ctx)
if err != nil {
logger.Error("list tags failed", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("list tags failed: %v", err)), nil
}
output, err := json.MarshalIndent(tags, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal results: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func registerCreateTag(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("create_tag",
mcp.WithDescription("Create a tag in the vocabulary. Tags are shared across flashcards. Check list_tags first and reuse an existing tag when one fits; create a new one only when nothing does. Fails if the tag already exists."),
mcp.WithString("name",
mcp.Required(),
mcp.Description("Tag name — short, lowercase, no spaces (e.g. 'preference', 'workaround')"),
),
mcp.WithString("description",
mcp.Description("What the tag means and when to use it"),
),
),
handleCreateTag(sqlite, logger),
)
}
func handleCreateTag(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
name := strings.TrimSpace(parseOptionalString(args["name"]))
if name == "" {
return mcp.NewToolResultError("name is required and must be a non-empty string"), nil
}
tag, err := sqlite.createTag(ctx, name, parseOptionalString(args["description"]))
if err != nil {
logger.Error("create tag failed", zap.String("name", name), zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("failed to create tag: %v", err)), nil
}
output, err := json.MarshalIndent(tag, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func registerUpdateTag(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("update_tag",
mcp.WithDescription("Update a tag's name and/or description. Renaming keeps the tag linked to its flashcards. Fails if the new name is already used by another tag."),
mcp.WithString("name",
mcp.Required(),
mcp.Description("Current tag name"),
),
mcp.WithString("new_name",
mcp.Description("New name, if renaming"),
),
mcp.WithString("description",
mcp.Description("New description; pass an empty string to clear it"),
),
),
handleUpdateTag(sqlite, logger),
)
}
func handleUpdateTag(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
name := strings.TrimSpace(parseOptionalString(args["name"]))
if name == "" {
return mcp.NewToolResultError("name is required and must be a non-empty string"), nil
}
newName := strings.TrimSpace(parseOptionalString(args["new_name"]))
description, setDescription := "", false
if raw, present := args["description"]; present && raw != nil {
description, setDescription = parseOptionalString(raw), true
}
if newName == "" && !setDescription {
return mcp.NewToolResultError("nothing to update: provide new_name and/or description"), nil
}
tag, err := sqlite.updateTag(ctx, name, newName, description, setDescription)
if err != nil {
logger.Error("update tag failed", zap.String("name", name), zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("failed to update tag: %v", err)), nil
}
output, err := json.MarshalIndent(tag, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func registerMergeTag(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("merge_tag",
mcp.WithDescription("Merge tag 'from' into tag 'into': every flashcard using 'from' is repointed to 'into', then 'from' is deleted. Use this to collapse near-duplicate tags (e.g. merge 'golang' into 'go'). Optionally set the merged tag's description."),
mcp.WithString("from",
mcp.Required(),
mcp.Description("Tag to merge away and delete"),
),
mcp.WithString("into",
mcp.Required(),
mcp.Description("Tag that survives and absorbs the flashcards"),
),
mcp.WithString("description",
mcp.Description("Description to set on the surviving tag; omitted keeps its current description"),
),
),
handleMergeTag(sqlite, logger),
)
}
func handleMergeTag(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
from := strings.TrimSpace(parseOptionalString(args["from"]))
into := strings.TrimSpace(parseOptionalString(args["into"]))
if from == "" || into == "" {
return mcp.NewToolResultError("from and into are required and must be non-empty strings"), nil
}
description, setDescription := "", false
if raw, present := args["description"]; present && raw != nil {
description, setDescription = parseOptionalString(raw), true
}
tag, err := sqlite.mergeTag(ctx, from, into, description, setDescription)
if err != nil {
logger.Error("merge tag failed", zap.String("from", from), zap.String("into", into), zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("failed to merge tag: %v", err)), nil
}
output, err := json.MarshalIndent(tag, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func handleListRecentFlashcard(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
tag := parseOptionalString(args["tag"])
repo := parseOptionalString(args["repo"])
host := parseOptionalString(args["host"])
limit := uint64(20)
if rawLimit := args["limit"]; rawLimit != nil {
switch v := rawLimit.(type) {
case float64:
limit = uint64(v)
case int:
limit = uint64(v)
case int64:
limit = uint64(v)
}
}
entries, err := sqlite.listRecent(ctx, tag, repo, host, limit)
if err != nil {
logger.Error("list recent failed", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("list recent failed: %v", err)), nil
}
output, err := json.MarshalIndent(entries, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal results: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func registerUpdateFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("update_flashcard",
mcp.WithDescription("Update an existing flashcard. Creates a new version while preserving history."),
mcp.WithNumber("public_id",
mcp.Required(),
mcp.Description("Public identifier of the flashcard to update"),
),
mcp.WithString("note",
mcp.Description("The markdown body"),
),
mcp.WithArray("repos",
mcp.Description("Optional list of repository names"),
),
mcp.WithString("host",
mcp.Description("Optional hostname or null if universal"),
),
mcp.WithArray("tags",
mcp.Description("Optional list of tags"),
),
mcp.WithString("user",
mcp.Description("Optional user identifier"),
),
mcp.WithString("context",
mcp.Description("Optional context — what triggered this"),
),
),
handleUpdateFlashcard(sqlite, engine, logger),
)
}
func handleUpdateFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
publicID := parseInt64(args["public_id"])
if publicID == 0 {
return mcp.NewToolResultError("public_id is required and must be a positive integer"), nil
}
latest, err := sqlite.getLatestByPublicID(ctx, publicID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to query: %v", err)), nil
}
if latest == nil {
return mcp.NewToolResultError(fmt.Sprintf("no flashcard found with public_id %d", publicID)), nil
}
note := latest.Note
repos := latest.Repos
host := latest.Host
tags := latest.Tags
user := latest.User
context_ := latest.Context
if v := parseOptionalString(args["note"]); v != "" {
note = v
}
if v := parseStringSlice(args["repos"]); v != nil {
repos = v
}
if v := parseOptionalString(args["host"]); v != "" {
host = v
}
if v := parseStringSlice(args["tags"]); v != nil {
tags = v
}
if v := parseOptionalString(args["user"]); v != "" {
user = v
}
if v := parseOptionalString(args["context"]); v != "" {
context_ = v
}
version := latest.Version + 1
id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID, version)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to persist update: %v", err)), nil
}
if err := engine.Delete(ctx, latest.ID, publicID); err != nil {
logger.Warn("search engine delete failed", zap.Int64("id", latest.ID), zap.Error(err))
}
if err := engine.Index(ctx, id, publicID, version, note); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search engine index failed: %v", err)), nil
}
entry, err := sqlite.getByID(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to retrieve: %v", err)), nil
}
output, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func registerDeleteFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("delete_flashcard",
mcp.WithDescription("Delete a flashcard. Creates a tombstone version and removes it from the search index."),
mcp.WithNumber("public_id",
mcp.Required(),
mcp.Description("Public identifier of the flashcard to delete"),
),
),
handleDeleteFlashcard(sqlite, engine, logger),
)
}
func handleDeleteFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
publicID := parseInt64(args["public_id"])
if publicID == 0 {
return mcp.NewToolResultError("public_id is required and must be a positive integer"), nil
}
latest, err := sqlite.getLatestByPublicID(ctx, publicID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to query: %v", err)), nil
}
if latest == nil {
return mcp.NewToolResultError(fmt.Sprintf("no flashcard found with public_id %d", publicID)), nil
}
if latest.DeleteTimestamp != "" {
return mcp.NewToolResultError(fmt.Sprintf("flashcard %d is already deleted", publicID)), nil
}
if _, err := sqlite.tombstone(ctx, publicID, latest.Version+1); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to tombstone: %v", err)), nil
}
if err := engine.Delete(ctx, latest.ID, publicID); err != nil {
logger.Warn("search engine delete failed", zap.Int64("id", latest.ID), zap.Error(err))
}
return mcp.NewToolResultText(fmt.Sprintf("flashcard %d deleted", publicID)), nil
}
}
func registerVoteFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("vote_flashcard",
mcp.WithDescription("Vote on a flashcard. Rate it from -3 (worst) to 3 (best). Requires detailed context about who is voting and why."),
mcp.WithNumber("public_id",
mcp.Required(),
mcp.Description("Public identifier of the flashcard to vote on"),
),
mcp.WithNumber("value",
mcp.Required(),
mcp.Description("Vote value from -3 to 3"),
),
mcp.WithString("user",
mcp.Required(),
mcp.Description("Who is voting"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository context for the vote"),
),
mcp.WithString("message",
mcp.Required(),
mcp.Description("Justification or context for the vote"),
),
),
handleVoteFlashcard(sqlite, logger),
)
}
func handleVoteFlashcard(sqlite *sqliteStore, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
publicID := parseInt64(args["public_id"])
if publicID == 0 {
return mcp.NewToolResultError("public_id is required and must be a positive integer"), nil
}
value := int(parseInt64(args["value"]))
if value < -3 || value > 3 {
return mcp.NewToolResultError("value must be between -3 and 3"), nil
}
user := parseOptionalString(args["user"])
if user == "" {
return mcp.NewToolResultError("user is required"), nil
}
repo := parseOptionalString(args["repo"])
if repo == "" {
return mcp.NewToolResultError("repo is required"), nil
}
message := parseOptionalString(args["message"])
if message == "" {
return mcp.NewToolResultError("message is required"), nil
}
latest, err := sqlite.getLatestByPublicID(ctx, publicID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to query: %v", err)), nil
}
if latest == nil {
return mcp.NewToolResultError(fmt.Sprintf("no flashcard found with public_id %d", publicID)), nil
}
if latest.DeleteTimestamp != "" {
return mcp.NewToolResultError(fmt.Sprintf("flashcard %d is deleted", publicID)), nil
}
v, err := sqlite.saveVote(ctx, latest.ID, value, user, repo, message)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to save vote: %v", err)), nil
}
output, err := json.MarshalIndent(v, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func handleStoreFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
note, ok := args["note"].(string)
if !ok || note == "" {
return mcp.NewToolResultError("note is required and must be a non-empty string"), nil
}
repos := parseStringSlice(args["repos"])
host := parseOptionalString(args["host"])
tags := parseStringSlice(args["tags"])
user := parseOptionalString(args["user"])
context_ := parseOptionalString(args["context"])
publicID := parseInt64(args["public_id"])
id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID, 1)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to persist: %v", err)), nil
}
if publicID == 0 {
publicID = id
}
if err := engine.Index(ctx, id, publicID, 1, note); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search engine index failed: %v", err)), nil
}
entry, err := sqlite.getByID(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to retrieve: %v", err)), nil
}
output, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func handleSearchFlashcard(engine SearchEngine) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return mcp.NewToolResultError("invalid arguments"), nil
}
query, ok := args["query"].(string)
if !ok || query == "" {
return mcp.NewToolResultError("query is required and must be a non-empty string"), nil
}
limit := uint64(5)
if rawLimit := args["limit"]; rawLimit != nil {
switch v := rawLimit.(type) {
case float64:
limit = uint64(v)
case int:
limit = uint64(v)
case int64:
limit = uint64(v)
}
}
results, err := engine.Search(ctx, query, limit)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search failed: %v", err)), nil
}
output, err := json.MarshalIndent(results, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal results: %v", err)), nil
}
return mcp.NewToolResultText(string(output)), nil
}
}
func corsMiddleware(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
}
}
func requireEnv(logger *zap.Logger, key string) string {
val := os.Getenv(key)
if val == "" {
logger.Fatal("missing required env var", zap.String("key", key))
}
return val
}
func envOrDefault(key, defaultVal string) string {
val := os.Getenv(key)
if val == "" {
return defaultVal
}
return val
}