feat: add vote_flashcard MCP tool

This commit is contained in:
didier committed 2026-06-05 17:04:05 -04:00
1 parent cd8e872491
commit a40542f7b4
4 files changed
+143

No files matched your search

+87
View File
@@ -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)
+14
View File
@@ -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;
+32
View File
@@ -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)
+10
View File
@@ -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"`