diff --git a/main.go b/main.go index 81d9c34..db31471 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,7 @@ func main() { registerSearchFlashcard(mcpServer, searchEngine) registerUpdateFlashcard(mcpServer, sqliteStore, searchEngine, logger) registerDeleteFlashcard(mcpServer, sqliteStore, searchEngine, logger) + registerVoteFlashcard(mcpServer, sqliteStore, logger) streamableServer := server.NewStreamableHTTPServer(mcpServer) @@ -334,6 +335,92 @@ func handleDeleteFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap } } +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) diff --git a/migrations/000003_add_vote.sql b/migrations/000003_add_vote.sql new file mode 100644 index 0000000..531ff77 --- /dev/null +++ b/migrations/000003_add_vote.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS vote ( + _id INTEGER PRIMARY KEY, + flashcard_id INTEGER NOT NULL REFERENCES flashcard(_id), + value INTEGER NOT NULL CHECK(value >= -3 AND value <= 3), + "user" TEXT NOT NULL, + repo TEXT NOT NULL, + message TEXT NOT NULL, + created TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(flashcard_id, "user", repo) +); + +-- +goose Down +DROP TABLE IF EXISTS vote; diff --git a/sqlite.go b/sqlite.go index 2093965..9f680a9 100644 --- a/sqlite.go +++ b/sqlite.go @@ -43,6 +43,11 @@ func newSqliteStore(path string) (*sqliteStore, error) { return nil, fmt.Errorf("sqlite ping: %w", err) } + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { + db.Close() + return nil, fmt.Errorf("enable foreign keys: %w", err) + } + goose.SetBaseFS(embedMigrations) if err := goose.SetDialect("sqlite3"); err != nil { @@ -233,6 +238,33 @@ func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int6 return &e, nil } +func (s *sqliteStore) saveVote(ctx context.Context, flashcardID int64, value int, user, repo, message string) (*vote, error) { + _, err := s.db.ExecContext(ctx, + `INSERT INTO vote (flashcard_id, value, "user", repo, message) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(flashcard_id, "user", repo) + DO UPDATE SET value = excluded.value, message = excluded.message`, + flashcardID, value, user, repo, message, + ) + if err != nil { + return nil, fmt.Errorf("upsert vote: %w", err) + } + + row := s.db.QueryRowContext(ctx, + `SELECT _id, flashcard_id, value, "user", repo, message, created + FROM vote WHERE flashcard_id = ? AND "user" = ? AND repo = ?`, + flashcardID, user, repo, + ) + + var v vote + err = row.Scan(&v.ID, &v.FlashcardID, &v.Value, &v.User, &v.Repo, &v.Message, &v.Created) + if err != nil { + return nil, fmt.Errorf("read vote: %w", err) + } + + return &v, nil +} + func (s *sqliteStore) tombstone(ctx context.Context, publicID int64, version int) (int64, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO flashcard (public_id, version, note, delete_timestamp) diff --git a/types.go b/types.go index 45d33a3..cb5f4f0 100644 --- a/types.go +++ b/types.go @@ -14,6 +14,16 @@ type entry struct { Created string `json:"created"` } +type vote struct { + ID int64 `json:"_id"` + FlashcardID int64 `json:"flashcard_id"` + Value int `json:"value"` + User string `json:"user"` + Repo string `json:"repo"` + Message string `json:"message"` + Created string `json:"created"` +} + type searchResult struct { Entry entry `json:",inline"` Score float32 `json:"score"`