Files
GearBox/src/server/mcp/tools/threads.ts
Jean-Luc Makiola 68f6647f76
All checks were successful
CI / ci (push) Successful in 28s
CI / ci (pull_request) Successful in 25s
CI / e2e (push) Successful in 1m2s
CI / e2e (pull_request) Successful in 1m3s
fix: convert MCP tool schemas from JSON Schema to Zod for SDK v1.29.0
The MCP SDK v1.29.0 changed server.tool() to require Zod schemas
(raw shapes) instead of plain JSON Schema objects. The old format
triggered "expected a Zod schema or ToolAnnotations" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:54:20 +02:00

222 lines
6.5 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) {
return {
list_threads: async (args: {
includeResolved?: boolean;
}): Promise<ToolResult> => {
try {
const threadList = getAllThreads(db, 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 = getThreadWithCandidates(db, 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 = createThread(db, 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 = resolveThread(db, 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 = createCandidate(db, 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 = updateCandidate(db, 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 = deleteCandidate(db, args.id);
if (!candidate) return errorResult(`Candidate ${args.id} not found`);
return textResult({ deleted: true, candidate });
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}