Add versioning, update tool, and soft-delete

Schema: public_id + version columns with UNIQUE constraint,
delete_timestamp for soft-delete.

Tools: store_flashcard accepts optional public_id, update_flashcard
creates a new version preserving history, delete_flashcard inserts
a tombstone row and removes the Qdrant point.

Qdrant: point ID is public_id (only latest version indexed).
This commit is contained in:
didier committed 2026-06-03 20:15:18 -04:00
1 parent 726ab62c62
commit e700a81c25
10 files changed
+352 -72

No files matched your search

+184 -26
View File
@@ -55,7 +55,7 @@ func main() {
emb := newEmbedder(openAIKey, embeddingModel, embeddingBaseURL)
qdrantStore := newQdrantStore(qdrantClient, emb)
sqlitePath := envOrDefault("SQLITE_PATH", "knowledge.db")
sqlitePath := envOrDefault("SQLITE_PATH", "flashcard.db")
sqliteStore, err := newSqliteStore(sqlitePath)
if err != nil {
logger.Fatal("failed to init sqlite", zap.Error(err))
@@ -67,14 +67,16 @@ func main() {
}
mcpServer := server.NewMCPServer(
"knowledge-mcp",
"flashcard-mcp",
version,
server.WithToolCapabilities(true),
server.WithLogging(),
)
registerStoreKnowledge(mcpServer, sqliteStore, qdrantStore, logger)
registerSearchKnowledge(mcpServer, sqliteStore, qdrantStore, logger)
registerStoreFlashcard(mcpServer, sqliteStore, qdrantStore, logger)
registerSearchFlashcard(mcpServer, sqliteStore, qdrantStore, logger)
registerUpdateFlashcard(mcpServer, sqliteStore, qdrantStore, logger)
registerDeleteFlashcard(mcpServer, sqliteStore, qdrantStore, logger)
streamableServer := server.NewStreamableHTTPServer(mcpServer)
@@ -119,14 +121,17 @@ func main() {
}
}
func registerStoreKnowledge(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) {
func registerStoreFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("store_knowledge",
mcp.WithDescription("Store a piece of knowledge for later retrieval. Use this to persist useful information discovered during a session."),
mcp.NewTool("store_flashcard",
mcp.WithDescription("Store a flashcard for later retrieval. Use this to persist useful information discovered during a session."),
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"),
),
@@ -143,14 +148,14 @@ func registerStoreKnowledge(mcpServer *server.MCPServer, sqlite *sqliteStore, qd
mcp.Description("Optional context — what triggered this"),
),
),
handleStoreKnowledge(sqlite, qdrant, logger),
handleStoreFlashcard(sqlite, qdrant, logger),
)
}
func registerSearchKnowledge(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) {
func registerSearchFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("search_knowledge",
mcp.WithDescription("Search previously stored knowledge using semantic search. Returns the most relevant knowledge entries for the given query."),
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"),
@@ -159,34 +164,94 @@ func registerSearchKnowledge(mcpServer *server.MCPServer, sqlite *sqliteStore, q
mcp.Description("Maximum number of results to return (default: 5)"),
),
),
handleSearchKnowledge(sqlite, qdrant),
handleSearchFlashcard(sqlite, qdrant),
)
}
func handleStoreKnowledge(sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) server.ToolHandlerFunc {
func registerUpdateFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, 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, qdrant, logger),
)
}
func handleUpdateFlashcard(sqlite *sqliteStore, qdrant *qdrantStore, 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
publicID := parseInt64(args["public_id"])
if publicID == 0 {
return mcp.NewToolResultError("public_id is required and must be a positive integer"), nil
}
repos := parseStringSlice(args["repos"])
host := parseOptionalString(args["host"])
tags := parseStringSlice(args["tags"])
user := parseOptionalString(args["user"])
context_ := parseOptionalString(args["context"])
id, err := sqlite.save(ctx, note, host, user, context_, repos, tags)
latest, err := sqlite.getLatestByPublicID(ctx, publicID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to persist: %v", 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 err := qdrant.index(ctx, id, note); err != 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)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to persist update: %v", err)), nil
}
if err := qdrant.index(ctx, id, publicID, version, note); err != nil {
logger.Warn("qdrant index failed (entry in sqlite)", zap.Int64("id", id), zap.Error(err))
}
@@ -204,7 +269,100 @@ func handleStoreKnowledge(sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.
}
}
func handleSearchKnowledge(sqlite *sqliteStore, qdrant *qdrantStore) server.ToolHandlerFunc {
func registerDeleteFlashcard(mcpServer *server.MCPServer, sqlite *sqliteStore, qdrant *qdrantStore, logger *zap.Logger) {
mcpServer.AddTool(
mcp.NewTool("delete_flashcard",
mcp.WithDescription("Delete a flashcard. Creates a tombstone version and removes it from the vector index."),
mcp.WithNumber("public_id",
mcp.Required(),
mcp.Description("Public identifier of the flashcard to delete"),
),
),
handleDeleteFlashcard(sqlite, qdrant, logger),
)
}
func handleDeleteFlashcard(sqlite *sqliteStore, qdrant *qdrantStore, 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 := qdrant.delete(ctx, publicID); err != nil {
logger.Warn("qdrant delete failed (tombstone in sqlite)", zap.Int64("public_id", publicID), zap.Error(err))
}
return mcp.NewToolResultText(fmt.Sprintf("flashcard %d deleted", publicID)), nil
}
}
func handleStoreFlashcard(sqlite *sqliteStore, qdrant *qdrantStore, 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)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to persist: %v", err)), nil
}
if publicID == 0 {
publicID = id
}
if err := qdrant.index(ctx, id, publicID, 1, note); err != nil {
logger.Warn("qdrant index failed (entry in sqlite)", zap.Int64("id", id), zap.Error(err))
}
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(sqlite *sqliteStore, qdrant *qdrantStore) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args, ok := request.Params.Arguments.(map[string]any)
if !ok {