feat: added first class citizen tags

This commit is contained in:
didier committed 2026-09-16 07:14:31 -04:00
1 parent 57bcd549d1
commit 4dc42d91c2
4 files changed
+469 -38

No files matched your search

+186 -3
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
@@ -59,6 +60,10 @@ func main() {
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)
@@ -204,7 +209,7 @@ func createSearchEngine(logger *zap.Logger, sqliteStore *sqliteStore, openAIKey
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."),
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"),
@@ -269,6 +274,184 @@ func registerListRecentFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStor
)
}
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 how many flashcards use it. Use this before storing a flashcard to reuse existing tags instead of inventing new ones."),
),
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)
@@ -385,7 +568,7 @@ func handleUpdateFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap
}
version := latest.Version + 1
id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID)
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
}
@@ -564,7 +747,7 @@ func handleStoreFlashcard(sqlite *sqliteStore, engine SearchEngine, logger *zap.
context_ := parseOptionalString(args["context"])
publicID := parseInt64(args["public_id"])
id, err := sqlite.save(ctx, note, host, user, context_, repos, tags, publicID)
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
}