feat: add vote_flashcard MCP tool
This commit is contained in:
1 parent
cd8e872491
commit
a40542f7b4
4 files changed
+143
No files matched your search
@@ -54,6 +54,7 @@ func main() {
|
|||||||
registerSearchFlashcard(mcpServer, searchEngine)
|
registerSearchFlashcard(mcpServer, searchEngine)
|
||||||
registerUpdateFlashcard(mcpServer, sqliteStore, searchEngine, logger)
|
registerUpdateFlashcard(mcpServer, sqliteStore, searchEngine, logger)
|
||||||
registerDeleteFlashcard(mcpServer, sqliteStore, searchEngine, logger)
|
registerDeleteFlashcard(mcpServer, sqliteStore, searchEngine, logger)
|
||||||
|
registerVoteFlashcard(mcpServer, sqliteStore, logger)
|
||||||
|
|
||||||
streamableServer := server.NewStreamableHTTPServer(mcpServer)
|
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 {
|
func handleStoreFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap.Logger) server.ToolHandlerFunc {
|
||||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args, ok := request.Params.Arguments.(map[string]any)
|
args, ok := request.Params.Arguments.(map[string]any)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -43,6 +43,11 @@ func newSqliteStore(path string) (*sqliteStore, error) {
|
|||||||
return nil, fmt.Errorf("sqlite ping: %w", err)
|
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)
|
goose.SetBaseFS(embedMigrations)
|
||||||
|
|
||||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||||
@@ -233,6 +238,33 @@ func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int6
|
|||||||
return &e, nil
|
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) {
|
func (s *sqliteStore) tombstone(ctx context.Context, publicID int64, version int) (int64, error) {
|
||||||
result, err := s.db.ExecContext(ctx,
|
result, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO flashcard (public_id, version, note, delete_timestamp)
|
`INSERT INTO flashcard (public_id, version, note, delete_timestamp)
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ type entry struct {
|
|||||||
Created string `json:"created"`
|
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 {
|
type searchResult struct {
|
||||||
Entry entry `json:",inline"`
|
Entry entry `json:",inline"`
|
||||||
Score float32 `json:"score"`
|
Score float32 `json:"score"`
|
||||||
|
|||||||
Reference in new issue
Block a user