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>
222 lines
6.5 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|
|
}
|