Skip to content

Commit 0c0e3b9

Browse files
authored
Add task search by title + better MCP tool error messages (#305)
1 parent 4d63eff commit 0c0e3b9

7 files changed

Lines changed: 177 additions & 1 deletion

File tree

apiserver/internal/apis/task.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package apis
33
import (
44
"net/http"
55
"strconv"
6+
"strings"
67
"time"
78

89
authMW "dkhalife.com/tasks/core/internal/middleware/auth"
@@ -73,6 +74,22 @@ func (h *TasksAPIHandler) getTasksByLabel(c *gin.Context) {
7374
c.JSON(status, response)
7475
}
7576

77+
func (h *TasksAPIHandler) searchTasks(c *gin.Context) {
78+
currentIdentity := auth.CurrentIdentity(c)
79+
80+
query := strings.TrimSpace(c.Query("q"))
81+
if query == "" {
82+
telemetry.TrackWarning(c, "task_invalid_param", "task-handler", "Missing 'q' query parameter", nil)
83+
c.JSON(http.StatusBadRequest, gin.H{
84+
"error": "'q' query parameter is required",
85+
})
86+
return
87+
}
88+
89+
status, response := h.tService.SearchTasksByTitle(c, currentIdentity.UserID, query)
90+
c.JSON(status, response)
91+
}
92+
7693
func (h *TasksAPIHandler) getCompletedTasks(c *gin.Context) {
7794
currentIdentity := auth.CurrentIdentity(c)
7895

@@ -275,6 +292,7 @@ func TaskRoutes(router *gin.Engine, h *TasksAPIHandler, auth *authMW.AuthMiddlew
275292
tasksRoutes.GET("/", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getTasks)
276293
tasksRoutes.GET("/due", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getTasksDueBefore)
277294
tasksRoutes.GET("/label/:labelId", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getTasksByLabel)
295+
tasksRoutes.GET("/search", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.searchTasks)
278296
tasksRoutes.GET("/completed", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getCompletedTasks)
279297
tasksRoutes.PUT("/", authMW.ScopeMiddleware(models.ApiTokenScopeTaskWrite), h.editTask)
280298
tasksRoutes.POST("/", authMW.ScopeMiddleware(models.ApiTokenScopeTaskWrite), h.createTask)

apiserver/internal/repos/task/task.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package repos
33
import (
44
"context"
55
"errors"
6+
"strings"
67
"time"
78

89
config "dkhalife.com/tasks/core/config"
@@ -83,6 +84,28 @@ func (r *TaskRepository) GetTasksByLabel(c context.Context, userID int, labelID
8384
return tasks, nil
8485
}
8586

87+
func (r *TaskRepository) SearchTasksByTitle(c context.Context, userID int, query string) ([]*models.Task, error) {
88+
var tasks []*models.Task
89+
90+
// Escape LIKE wildcards so they match literally. Use '!' as the escape
91+
// character because backslash has dialect-specific meaning inside MySQL
92+
// string literals by default and would produce a SQL syntax error.
93+
escaped := strings.ReplaceAll(query, "!", "!!")
94+
escaped = strings.ReplaceAll(escaped, "%", "!%")
95+
escaped = strings.ReplaceAll(escaped, "_", "!_")
96+
pattern := "%" + strings.ToLower(escaped) + "%"
97+
98+
if err := r.db.WithContext(c).
99+
Where("created_by = ? AND is_active = 1 AND LOWER(title) LIKE ? ESCAPE '!'", userID, pattern).
100+
Order("next_due_date ASC").
101+
Preload("Labels").
102+
Find(&tasks).Error; err != nil {
103+
return nil, err
104+
}
105+
106+
return tasks, nil
107+
}
108+
86109
func (r *TaskRepository) GetCompletedTasks(c context.Context, userID int, limit int, offset int) ([]*models.Task, error) {
87110
var tasks []*models.Task
88111

apiserver/internal/repos/task/task_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,3 +801,72 @@ func (s *TaskTestSuite) TestGetTasksByLabel() {
801801
// All labels are preloaded (not just the filtered one)
802802
s.Require().Len(result[1].Labels, 2)
803803
}
804+
805+
func (s *TaskTestSuite) TestSearchTasksByTitle() {
806+
ctx := context.Background()
807+
808+
now := time.Now().UTC()
809+
due1 := now.Add(24 * time.Hour)
810+
due2 := now.Add(48 * time.Hour)
811+
812+
groceries := &models.Task{
813+
Title: "Buy groceries",
814+
CreatedBy: s.testUser.ID,
815+
NextDueDate: &due2,
816+
IsActive: true,
817+
Frequency: models.Frequency{Type: models.RepeatOnce},
818+
}
819+
s.Require().NoError(s.DB.Create(groceries).Error)
820+
821+
grocerySort := &models.Task{
822+
Title: "Sort Groceries in pantry",
823+
CreatedBy: s.testUser.ID,
824+
NextDueDate: &due1,
825+
IsActive: true,
826+
Frequency: models.Frequency{Type: models.RepeatOnce},
827+
}
828+
s.Require().NoError(s.DB.Create(grocerySort).Error)
829+
830+
unrelated := &models.Task{
831+
Title: "Walk the dog",
832+
CreatedBy: s.testUser.ID,
833+
NextDueDate: &due1,
834+
IsActive: true,
835+
Frequency: models.Frequency{Type: models.RepeatOnce},
836+
}
837+
s.Require().NoError(s.DB.Create(unrelated).Error)
838+
839+
inactive := &models.Task{
840+
ID: 60,
841+
Title: "Old groceries list",
842+
CreatedBy: s.testUser.ID,
843+
NextDueDate: &due1,
844+
IsActive: true,
845+
Frequency: models.Frequency{Type: models.RepeatOnce},
846+
}
847+
s.Require().NoError(s.DB.Create(inactive).Error)
848+
s.Require().NoError(s.DB.Model(&models.Task{}).Where("id = ?", 60).Update("is_active", false).Error)
849+
850+
otherUser := &models.User{}
851+
s.Require().NoError(s.DB.Create(otherUser).Error)
852+
otherUserTask := &models.Task{
853+
Title: "Their groceries",
854+
CreatedBy: otherUser.ID,
855+
NextDueDate: &due1,
856+
IsActive: true,
857+
Frequency: models.Frequency{Type: models.RepeatOnce},
858+
}
859+
s.Require().NoError(s.DB.Create(otherUserTask).Error)
860+
861+
// Case-insensitive, matches substring, only active tasks for this user
862+
result, err := s.repo.SearchTasksByTitle(ctx, s.testUser.ID, "grocer")
863+
s.Require().NoError(err)
864+
s.Require().Len(result, 2)
865+
s.Equal("Sort Groceries in pantry", result[0].Title)
866+
s.Equal("Buy groceries", result[1].Title)
867+
868+
// LIKE wildcards in query are treated literally
869+
resultLiteral, err := s.repo.SearchTasksByTitle(ctx, s.testUser.ID, "%")
870+
s.Require().NoError(err)
871+
s.Require().Len(resultLiteral, 0)
872+
}

apiserver/internal/services/tasks/task.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ func (s *TaskService) GetTasksByLabel(ctx context.Context, userID int, labelID i
8686
}
8787
}
8888

89+
func (s *TaskService) SearchTasksByTitle(ctx context.Context, userID int, query string) (int, interface{}) {
90+
log := logging.FromContext(ctx)
91+
tasks, err := s.t.SearchTasksByTitle(ctx, userID, query)
92+
if err != nil {
93+
log.Errorf("error searching tasks by title %q: %s", query, err.Error())
94+
telemetry.TrackError(ctx, "task_search_failed", "task-service", err, nil)
95+
return http.StatusInternalServerError, gin.H{
96+
"error": "Error searching tasks",
97+
}
98+
}
99+
100+
return http.StatusOK, gin.H{
101+
"tasks": tasks,
102+
}
103+
}
104+
89105
func (s *TaskService) GetCompletedTasks(ctx context.Context, userID, limit, page int) (int, interface{}) {
90106
log := logging.FromContext(ctx)
91107
offset := (page - 1) * limit

mcpserver/Program.cs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
using System.Text.Json;
12
using Microsoft.AspNetCore.Authentication.JwtBearer;
23
using Microsoft.IdentityModel.Tokens;
4+
using ModelContextProtocol;
35
using ModelContextProtocol.AspNetCore;
46
using ModelContextProtocol.AspNetCore.Authentication;
7+
using ModelContextProtocol.Protocol;
58
using TaskWizard.McpServer.Services;
69

710
var builder = WebApplication.CreateBuilder(args);
@@ -76,7 +79,46 @@
7679
builder.Services
7780
.AddMcpServer()
7881
.WithHttpTransport()
79-
.WithToolsFromAssembly();
82+
.WithToolsFromAssembly()
83+
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (context, cancellationToken) =>
84+
{
85+
try
86+
{
87+
return await next(context, cancellationToken);
88+
}
89+
catch (OperationCanceledException)
90+
{
91+
throw;
92+
}
93+
catch (McpException)
94+
{
95+
// Already surfaced with a descriptive message by the SDK.
96+
throw;
97+
}
98+
catch (JsonException ex)
99+
{
100+
var toolName = context.Params?.Name ?? "<unknown>";
101+
var message =
102+
$"Invalid arguments for tool '{toolName}': {ex.Message} " +
103+
"Check that each argument matches the declared type in the tool schema " +
104+
"(for example, booleans must be sent as true/false, not as quoted strings).";
105+
return new CallToolResult
106+
{
107+
IsError = true,
108+
Content = [new TextContentBlock { Text = message }],
109+
};
110+
}
111+
catch (Exception ex)
112+
{
113+
var toolName = context.Params?.Name ?? "<unknown>";
114+
var message = $"Tool '{toolName}' failed: {ex.GetType().Name}: {ex.Message}";
115+
return new CallToolResult
116+
{
117+
IsError = true,
118+
Content = [new TextContentBlock { Text = message }],
119+
};
120+
}
121+
}));
80122

81123
var app = builder.Build();
82124

mcpserver/Services/ApiProxyService.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ public Task<string> GetTasksDueBefore(string before) =>
6363
public Task<string> GetTasksByLabel(int labelId) =>
6464
SendAsync(HttpMethod.Get, $"api/v1/tasks/label/{labelId}");
6565

66+
public Task<string> SearchTasksByTitle(string query) =>
67+
SendAsync(HttpMethod.Get, $"api/v1/tasks/search?q={Uri.EscapeDataString(query)}");
68+
6669
public Task<string> GetTask(int id) =>
6770
SendAsync(HttpMethod.Get, $"api/v1/tasks/{id}");
6871

mcpserver/Tools/TaskTools.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,9 @@ public Task<string> ListTasksDueBefore(
109109
public Task<string> ListTasksByLabel(
110110
[Description("Label ID")] int labelId) =>
111111
api.GetTasksByLabel(labelId);
112+
113+
[McpServerTool, Description("Search active tasks by a case-insensitive substring of their title")]
114+
public Task<string> SearchTasksByTitle(
115+
[Description("Substring to match against task titles (case-insensitive)")] string query) =>
116+
api.SearchTasksByTitle(query);
112117
}

0 commit comments

Comments
 (0)