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:
1 parent
726ab62c62
commit
e700a81c25
10 files changed
+352
-72
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user