Files
GearBox/src/server/mcp/tools/threads.ts
Jean-Luc Makiola d4bf4f5c16 feat(16-03): wire userId into MCP server and tool registrations
- Update createMcpServer signature to accept (db, userId)
- MCP auth middleware resolves userId from API key and Bearer token
- Store userId alongside transport in session map
- All 4 tool registration functions accept and pass userId
- Collection summary resource passes userId to all service calls
2026-04-05 10:52:43 +02:00

231 lines
6.6 KiB
TypeScript

import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createCandidate,
createThread,
deleteCandidate,
getAllThreads,
getThreadWithCandidates,
resolveThread,
updateCandidate,
} from "../../services/thread.service.ts";
type Db = typeof prodDb;
interface ToolResult {
content: Array<{ type: "text"; text: string }>;
}
function textResult(data: unknown): ToolResult {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
function errorResult(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
};
}
export const threadToolDefinitions = [
{
name: "list_threads",
description:
"List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.",
inputSchema: {
includeResolved: z
.boolean()
.optional()
.describe(
"Include resolved threads (default: false, only active threads)",
),
},
},
{
name: "get_thread",
description:
"Get a thread with all its candidates for detailed comparison.",
inputSchema: {
id: z.number().describe("Thread ID"),
},
},
{
name: "create_thread",
description:
"Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.",
inputSchema: {
name: z.string().describe("Thread name (e.g. 'Handlebar bag')"),
categoryId: z.number().describe("Category ID"),
},
},
{
name: "resolve_thread",
description:
"Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.",
inputSchema: {
threadId: z.number().describe("Thread ID"),
candidateId: z.number().describe("ID of the winning candidate"),
},
},
{
name: "add_candidate",
description: "Add a candidate option to a research thread for comparison.",
inputSchema: {
threadId: z.number().describe("Thread ID"),
name: z.string().describe("Candidate name"),
categoryId: z.number().describe("Category ID"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
notes: z.string().optional().describe("Notes"),
productUrl: z.string().optional().describe("Product URL"),
imageFilename: z.string().optional().describe("Image filename"),
pros: z.string().optional().describe("Pros of this candidate"),
cons: z.string().optional().describe("Cons of this candidate"),
},
},
{
name: "update_candidate",
description:
"Update a candidate's details (name, price, pros, cons, etc.).",
inputSchema: {
id: z.number().describe("Candidate ID"),
name: z.string().optional().describe("Candidate name"),
weightGrams: z.number().optional().describe("Weight in grams"),
priceCents: z.number().optional().describe("Price in cents"),
categoryId: z.number().optional().describe("Category ID"),
notes: z.string().optional().describe("Notes"),
productUrl: z.string().optional().describe("Product URL"),
imageFilename: z.string().optional().describe("Image filename"),
imageSourceUrl: z.string().optional().describe("Image source URL"),
status: z
.string()
.optional()
.describe("Status: researching, ordered, or arrived"),
pros: z.string().optional().describe("Pros"),
cons: z.string().optional().describe("Cons"),
},
},
{
name: "remove_candidate",
description: "Remove a candidate from a research thread.",
inputSchema: {
id: z.number().describe("Candidate ID to remove"),
},
},
];
export function registerThreadTools(db: Db, userId: number) {
return {
list_threads: async (args: {
includeResolved?: boolean;
}): Promise<ToolResult> => {
try {
const threadList = await getAllThreads(
db,
userId,
args.includeResolved ?? false,
);
return textResult(threadList);
} catch (err) {
return errorResult((err as Error).message);
}
},
get_thread: async (args: { id: number }): Promise<ToolResult> => {
try {
const thread = await getThreadWithCandidates(db, userId, args.id);
if (!thread) return errorResult(`Thread ${args.id} not found`);
return textResult(thread);
} catch (err) {
return errorResult((err as Error).message);
}
},
create_thread: async (args: {
name: string;
categoryId: number;
}): Promise<ToolResult> => {
try {
const thread = await createThread(db, userId, args);
return textResult(thread);
} catch (err) {
return errorResult((err as Error).message);
}
},
resolve_thread: async (args: {
threadId: number;
candidateId: number;
}): Promise<ToolResult> => {
try {
const result = await resolveThread(
db,
userId,
args.threadId,
args.candidateId,
);
if (!result.success) {
return errorResult(result.error ?? "Failed to resolve thread");
}
return textResult(result);
} catch (err) {
return errorResult((err as Error).message);
}
},
add_candidate: async (args: {
threadId: number;
name: string;
categoryId: number;
weightGrams?: number;
priceCents?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
pros?: string;
cons?: string;
}): Promise<ToolResult> => {
try {
const { threadId, ...data } = args;
const candidate = await createCandidate(db, userId, threadId, data);
return textResult(candidate);
} catch (err) {
return errorResult((err as Error).message);
}
},
update_candidate: async (args: {
id: number;
name?: string;
weightGrams?: number;
priceCents?: number;
categoryId?: number;
notes?: string;
productUrl?: string;
imageFilename?: string;
imageSourceUrl?: string;
status?: "researching" | "ordered" | "arrived";
pros?: string;
cons?: string;
}): Promise<ToolResult> => {
try {
const { id, ...data } = args;
const candidate = await updateCandidate(db, userId, id, data);
if (!candidate) return errorResult(`Candidate ${id} not found`);
return textResult(candidate);
} catch (err) {
return errorResult((err as Error).message);
}
},
remove_candidate: async (args: { id: number }): Promise<ToolResult> => {
try {
const candidate = await deleteCandidate(db, userId, args.id);
if (!candidate) return errorResult(`Candidate ${args.id} not found`);
return textResult({ deleted: true, candidate });
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}