Skip to content

Commit edefd77

Browse files
ZeyNorFrkAk
andauthored
feat: Add category filter to mymir_query search (#99)
Co-authored-by: Furkan Akbulutlar <f.akbulutlar@gmail.com>
1 parent 8ea692e commit edefd77

5 files changed

Lines changed: 370 additions & 84 deletions

File tree

lib/data/task.ts

Lines changed: 114 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
taskDecisions,
1313
taskLinks,
1414
type NewTask,
15+
type Project,
1516
type TaskLink,
1617
} from "@/lib/db/schema";
1718
import { acquireProjectLock } from "@/lib/db/raw/acquire-project-lock";
@@ -907,23 +908,73 @@ export type SearchResult = {
907908
/** Match a full taskRef like "MYMR-83" (case-insensitive). */
908909
const TASK_REF_PATTERN = /^([A-Z0-9]+)-(\d+)$/i;
909910

911+
/** Filter options for {@link searchTasks} and {@link searchTasksTx}. */
912+
export type SearchTasksOpts = {
913+
/** Optional search string (taskRef, title, or tag substring). */
914+
query?: string;
915+
/** Optional exact tag filter (OR-within). */
916+
tags?: string[];
917+
/** Optional exact project-category filter (AND-narrows). Caller validates. */
918+
category?: string;
919+
};
920+
910921
/**
911-
* Search tasks by taskRef, title, or tags within a project.
922+
* Search tasks by taskRef, title, tags, or category within a project.
923+
* Prefer {@link searchTasksTx} when the caller already owns a
924+
* `withUserContext` frame.
925+
*
912926
* @param ctx - Resolved auth context.
913927
* @param projectId - UUID of the project.
914-
* @param query - Optional search string.
915-
* @param tags - Optional exact tag filter (OR-within).
928+
* @param opts - Filter options.
916929
* @returns Up to 20 matching tasks with derived state.
930+
* @throws ForbiddenError on missing or cross-team project.
917931
*/
918932
export async function searchTasks(
919933
ctx: AuthContext,
920934
projectId: string,
921-
query?: string,
922-
tags?: string[],
935+
opts: SearchTasksOpts = {},
923936
): Promise<SearchResult[]> {
924-
const trimmedQuery = query?.trim() ?? "";
925-
const tagFilter = normalizeTags(tags);
926-
if (trimmedQuery.length === 0 && tagFilter.length === 0) return [];
937+
return withUserContext(ctx.userId, async (tx) => {
938+
const { project } = await assertProjectAccessTx(tx, projectId);
939+
return searchTasksTx(tx, project, opts);
940+
});
941+
}
942+
943+
/** The slice of {@link Project} that {@link searchTasksTx} reads. */
944+
export type SearchTasksProject = Pick<
945+
Project,
946+
"id" | "identifier" | "categories"
947+
>;
948+
949+
/**
950+
* {@link searchTasks} on a caller-supplied tx and pre-resolved project.
951+
*
952+
* The caller MUST have invoked `assertProjectAccessTx` for `project.id` on
953+
* the same `tx` before calling this. RLS still gates every row read, so a
954+
* missing assert never bypasses authorization — it would only mute the
955+
* explicit `Forbidden` error path. The pre-resolved `project` lets the
956+
* search share authorization with surrounding work without a second access
957+
* check.
958+
*
959+
* @param tx - Active RLS transaction handle.
960+
* @param project - Pre-resolved project slice (caller already authorized).
961+
* @param opts - Filter options.
962+
* @returns Up to 20 matching tasks with derived state.
963+
*/
964+
export async function searchTasksTx(
965+
tx: Tx,
966+
project: SearchTasksProject,
967+
opts: SearchTasksOpts = {},
968+
): Promise<SearchResult[]> {
969+
const trimmedQuery = opts.query?.trim() ?? "";
970+
const tagFilter = normalizeTags(opts.tags);
971+
const trimmedCategory = opts.category?.trim() ?? "";
972+
if (
973+
trimmedQuery.length === 0 &&
974+
tagFilter.length === 0 &&
975+
trimmedCategory.length === 0
976+
)
977+
return [];
927978

928979
const lower = trimmedQuery.toLowerCase();
929980
const rankExpr =
@@ -936,73 +987,69 @@ export async function searchTasks(
936987
END`
937988
: null;
938989

939-
const { project, trimmed, stateMap } = await withUserContext(
940-
ctx.userId,
941-
async (tx) => {
942-
const { project } = await assertProjectAccessTx(tx, projectId);
990+
const clauses = [eq(tasks.projectId, project.id)];
943991

944-
const clauses = [eq(tasks.projectId, projectId)];
992+
if (trimmedQuery.length > 0) {
993+
const refMatch = trimmedQuery.match(TASK_REF_PATTERN);
994+
const seqClause =
995+
refMatch && refMatch[1].toUpperCase() === project.identifier
996+
? eq(tasks.sequenceNumber, Number(refMatch[2]))
997+
: null;
945998

946-
if (trimmedQuery.length > 0) {
947-
const refMatch = trimmedQuery.match(TASK_REF_PATTERN);
948-
const seqClause =
949-
refMatch && refMatch[1].toUpperCase() === project.identifier
950-
? eq(tasks.sequenceNumber, Number(refMatch[2]))
951-
: null;
999+
const pattern = `%${trimmedQuery}%`;
1000+
const tagSubstring = sql`EXISTS (SELECT 1 FROM jsonb_array_elements_text(${tasks.tags}) AS t WHERE t ILIKE ${pattern})`;
1001+
const queryClause =
1002+
seqClause ?? or(ilike(tasks.title, pattern), tagSubstring);
1003+
if (queryClause) clauses.push(queryClause);
1004+
}
9521005

953-
const pattern = `%${trimmedQuery}%`;
954-
const tagSubstring = sql`EXISTS (SELECT 1 FROM jsonb_array_elements_text(${tasks.tags}) AS t WHERE t ILIKE ${pattern})`;
955-
const queryClause =
956-
seqClause ?? or(ilike(tasks.title, pattern), tagSubstring);
957-
if (queryClause) clauses.push(queryClause);
958-
}
1006+
if (tagFilter.length > 0) {
1007+
clauses.push(
1008+
sql`EXISTS (SELECT 1 FROM jsonb_array_elements_text(${tasks.tags}) AS t WHERE t IN ${tagFilter})`,
1009+
);
1010+
}
9591011

960-
if (tagFilter.length > 0) {
961-
clauses.push(
962-
sql`EXISTS (SELECT 1 FROM jsonb_array_elements_text(${tasks.tags}) AS t WHERE t IN ${tagFilter})`,
963-
);
964-
}
1012+
if (trimmedCategory.length > 0) {
1013+
clauses.push(eq(tasks.category, trimmedCategory));
1014+
}
9651015

966-
// Inlining a literal `0` in ORDER BY is parsed as a positional column
967-
// reference, not a constant — Postgres rejects it with 42P10.
968-
const orderByCols = rankExpr
969-
? [rankExpr, asc(tasks.order)]
970-
: [asc(tasks.order)];
971-
const trimmedRows = await tx
972-
.select({
973-
id: tasks.id,
974-
title: tasks.title,
975-
status: tasks.status,
976-
tags: tasks.tags,
977-
category: tasks.category,
978-
priority: tasks.priority,
979-
estimate: tasks.estimate,
980-
hasDescription: sql<boolean>`length(btrim(${tasks.description})) > 0`,
981-
hasCriteria: hasCriteriaExpr(),
982-
sequenceNumber: tasks.sequenceNumber,
983-
order: tasks.order,
984-
assigneeCount: assigneeCountExpr(),
985-
})
986-
.from(tasks)
987-
.where(and(...clauses))
988-
.orderBy(...orderByCols)
989-
.limit(20);
990-
const states = await deriveTaskStatesSlim(
991-
projectId,
992-
trimmedRows.map((t) => ({
993-
id: t.id,
994-
status: t.status,
995-
hasDescription: t.hasDescription,
996-
hasCriteria: t.hasCriteria,
997-
})),
998-
tx,
999-
);
1000-
return { project, trimmed: trimmedRows, stateMap: states };
1001-
},
1016+
// Inlining a literal `0` in ORDER BY is parsed as a positional column
1017+
// reference, not a constant — Postgres rejects it with 42P10.
1018+
const orderByCols = rankExpr
1019+
? [rankExpr, asc(tasks.order)]
1020+
: [asc(tasks.order)];
1021+
const trimmedRows = await tx
1022+
.select({
1023+
id: tasks.id,
1024+
title: tasks.title,
1025+
status: tasks.status,
1026+
tags: tasks.tags,
1027+
category: tasks.category,
1028+
priority: tasks.priority,
1029+
estimate: tasks.estimate,
1030+
hasDescription: sql<boolean>`length(btrim(${tasks.description})) > 0`,
1031+
hasCriteria: hasCriteriaExpr(),
1032+
sequenceNumber: tasks.sequenceNumber,
1033+
order: tasks.order,
1034+
assigneeCount: assigneeCountExpr(),
1035+
})
1036+
.from(tasks)
1037+
.where(and(...clauses))
1038+
.orderBy(...orderByCols)
1039+
.limit(20);
1040+
const stateMap = await deriveTaskStatesSlim(
1041+
project.id,
1042+
trimmedRows.map((t) => ({
1043+
id: t.id,
1044+
status: t.status,
1045+
hasDescription: t.hasDescription,
1046+
hasCriteria: t.hasCriteria,
1047+
})),
1048+
tx,
10021049
);
10031050

10041051
const identifier = asIdentifier(project.identifier);
1005-
return enrichWithTaskRef(trimmed, identifier).map((t) => ({
1052+
return enrichWithTaskRef(trimmedRows, identifier).map((t) => ({
10061053
id: t.id,
10071054
taskRef: t.taskRef,
10081055
title: t.title,

lib/graph/tool-handlers.ts

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@ import {
1515
listProjectsForMcp,
1616
listUserTeams,
1717
getProjectTags,
18+
getProjectTagsTx,
1819
getProjectMeta,
1920
} from "@/lib/data/project";
2021
import {
2122
createTask,
2223
updateTask,
2324
deleteTask,
2425
deleteTaskPreview,
25-
searchTasks,
26+
searchTasksTx,
2627
getProjectTasksSlim,
2728
getTaskFull,
2829
fetchAssigneesUnchecked,
@@ -81,6 +82,7 @@ import type { AuthContext } from "@/lib/auth/context";
8182
import {
8283
ForbiddenError,
8384
InsufficientRoleError,
85+
assertProjectAccessTx,
8486
assertTaskAccess,
8587
} from "@/lib/auth/authorization";
8688
import { withUserContext } from "@/lib/db/rls";
@@ -699,7 +701,7 @@ export const DESCRIPTIONS = {
699701
"Server rejects self-edges, duplicates, and cycles. On 'duplicate edge' (concurrent-write race): treat as success and verify with mymir_query type='edges'.",
700702
mymir_query:
701703
"Search and browse project data. Pick the slim tool first; reserve overview for unfamiliar projects. " +
702-
"search=tasks by taskRef, title, or tag substring (case-insensitive, up to 20). Pass tags=[...] for exact tag match (OR-within); combine with `query` to AND-narrow. Single-result responses include a state hint pointing to the right next call. " +
704+
"search=tasks by taskRef, title, or tag substring (case-insensitive, up to 20). Pass tags=[...] for exact tag match (OR-within); combine with `query` to AND-narrow. Pass category='...' for exact project-category match (closed vocabulary; unknown values rejected with the valid list inline); combines with query/tags via AND. Single-result responses include a state hint pointing to the right next call. " +
703705
"list=every task in the project (slim, ordered by position). " +
704706
"edges=relationships on one task (connected title, status, direction, note). " +
705707
"meta=slim project metadata: header, description, status, categories, tag vocabulary (with usage counts), progress + status counts. No task list, no edges. Use this to look up categories before setting one, or the tag vocabulary before coining new tags. " +
@@ -785,6 +787,7 @@ export type QueryParams = {
785787
projectId?: string;
786788
query?: string;
787789
tags?: string[];
790+
category?: string;
788791
taskId?: string;
789792
};
790793

@@ -1359,25 +1362,68 @@ export async function handleQuery(
13591362
);
13601363
const hasQuery = (p.query?.trim() ?? "").length > 0;
13611364
const tagFilter = normalizeTags(p.tags);
1362-
if (!hasQuery && tagFilter.length === 0) {
1365+
const trimmedCategory = p.category?.trim() ?? "";
1366+
if (
1367+
!hasQuery &&
1368+
tagFilter.length === 0 &&
1369+
trimmedCategory.length === 0
1370+
) {
13631371
return fail(
1364-
"query or tags required for search. Pass `query` (taskRef, title or tag substring) or `tags=[...]` (exact tag, OR-within).",
1372+
"query, tags, or category required for search. Pass `query` (taskRef, title or tag substring), `tags=[...]` (exact tag, OR-within), or `category` (exact project category).",
13651373
);
13661374
}
13671375

1368-
const variantHints =
1369-
tagFilter.length > 0
1370-
? tagVariantHints(
1371-
tagFilter,
1372-
(await getProjectTags(ctx, p.projectId)).map((t) => t.tag),
1373-
)
1374-
: [];
1376+
const projectId = p.projectId;
1377+
const outcome = await withUserContext(ctx.userId, async (tx) => {
1378+
const { project } = await assertProjectAccessTx(tx, projectId);
1379+
1380+
if (
1381+
trimmedCategory.length > 0 &&
1382+
!project.categories.includes(trimmedCategory)
1383+
) {
1384+
return {
1385+
kind: "invalid_category" as const,
1386+
categories: project.categories,
1387+
};
1388+
}
1389+
1390+
const projectTagVocab =
1391+
tagFilter.length > 0
1392+
? (await getProjectTagsTx(tx, projectId)).map((t) => t.tag)
1393+
: [];
1394+
1395+
const results = await searchTasksTx(tx, project, {
1396+
query: p.query,
1397+
tags: tagFilter,
1398+
category: trimmedCategory || undefined,
1399+
});
1400+
1401+
return { kind: "ok" as const, projectTagVocab, results };
1402+
});
13751403

1376-
const results = await searchTasks(ctx, p.projectId, p.query, tagFilter);
1377-
const hintParts: string[] = [...variantHints];
1378-
if (results.length === 1) hintParts.push(stateHint(results[0].state));
1379-
const hint = hintParts.length > 0 ? hintParts.join("\n> ") : undefined;
1380-
return ok(formatSearchResults(results, hint));
1404+
switch (outcome.kind) {
1405+
case "invalid_category":
1406+
return fail(
1407+
`Category "${trimmedCategory}" not in this project's categories: [${outcome.categories.join(", ")}]. Run mymir_query type='meta' for the current list.`,
1408+
);
1409+
case "ok": {
1410+
const variantHints =
1411+
tagFilter.length > 0
1412+
? tagVariantHints(tagFilter, outcome.projectTagVocab)
1413+
: [];
1414+
const hintParts: string[] = [...variantHints];
1415+
if (outcome.results.length === 1)
1416+
hintParts.push(stateHint(outcome.results[0].state));
1417+
const hint =
1418+
hintParts.length > 0 ? hintParts.join("\n> ") : undefined;
1419+
return ok(formatSearchResults(outcome.results, hint));
1420+
}
1421+
default:
1422+
outcome satisfies never;
1423+
throw new Error(
1424+
`unreachable: unhandled search outcome ${JSON.stringify(outcome)}`,
1425+
);
1426+
}
13811427
}
13821428
case "list": {
13831429
if (!p.projectId)

lib/mcp/create-server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,12 @@ export function registerAllTools(server: McpServer, ctx: AuthContext): void {
514514
.describe(
515515
"Filter to tasks containing ANY of these exact tags (OR-within). Combine with `query` to narrow further. Pick from the tag vocabulary in `type='meta'`.",
516516
),
517+
category: z
518+
.string()
519+
.optional()
520+
.describe(
521+
"Filter to tasks in exactly this category (AND with `query`/`tags`). Must be one of the project's categories (closed vocabulary); unknown values are rejected. Run mymir_query type='meta' for the current list.",
522+
),
517523
taskId: z.uuid().optional().describe("Task UUID for type='edges'."),
518524
projectId: z
519525
.uuid()

0 commit comments

Comments
 (0)