feat: add MCP tool handlers, definitions, and collection resource
Wrap existing service layer with MCP-compatible tool handlers for items, categories, threads/candidates, setups, and image fetching. Add collection summary resource for overview data. All 14 MCP-specific tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
252
src/server/mcp/tools/threads.ts
Normal file
252
src/server/mcp/tools/threads.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
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: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
includeResolved: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Include resolved threads (default: false, only active threads)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_thread",
|
||||
description:
|
||||
"Get a thread with all its candidates for detailed comparison.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "Thread ID" },
|
||||
},
|
||||
required: ["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: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Thread name (e.g. 'Handlebar bag')",
|
||||
},
|
||||
categoryId: { type: "number", description: "Category ID" },
|
||||
},
|
||||
required: ["name", "categoryId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
threadId: { type: "number", description: "Thread ID" },
|
||||
candidateId: {
|
||||
type: "number",
|
||||
description: "ID of the winning candidate",
|
||||
},
|
||||
},
|
||||
required: ["threadId", "candidateId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add_candidate",
|
||||
description: "Add a candidate option to a research thread for comparison.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
threadId: { type: "number", description: "Thread ID" },
|
||||
name: { type: "string", description: "Candidate name" },
|
||||
categoryId: { type: "number", description: "Category ID" },
|
||||
weightGrams: { type: "number", description: "Weight in grams" },
|
||||
priceCents: { type: "number", description: "Price in cents" },
|
||||
notes: { type: "string", description: "Notes" },
|
||||
productUrl: { type: "string", description: "Product URL" },
|
||||
imageFilename: { type: "string", description: "Image filename" },
|
||||
pros: { type: "string", description: "Pros of this candidate" },
|
||||
cons: { type: "string", description: "Cons of this candidate" },
|
||||
},
|
||||
required: ["threadId", "name", "categoryId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_candidate",
|
||||
description:
|
||||
"Update a candidate's details (name, price, pros, cons, etc.).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "Candidate ID" },
|
||||
name: { type: "string", description: "Candidate name" },
|
||||
weightGrams: { type: "number", description: "Weight in grams" },
|
||||
priceCents: { type: "number", description: "Price in cents" },
|
||||
categoryId: { type: "number", description: "Category ID" },
|
||||
notes: { type: "string", description: "Notes" },
|
||||
productUrl: { type: "string", description: "Product URL" },
|
||||
imageFilename: { type: "string", description: "Image filename" },
|
||||
imageSourceUrl: { type: "string", description: "Image source URL" },
|
||||
status: {
|
||||
type: "string",
|
||||
description: "Status: researching, ordered, or arrived",
|
||||
},
|
||||
pros: { type: "string", description: "Pros" },
|
||||
cons: { type: "string", description: "Cons" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_candidate",
|
||||
description: "Remove a candidate from a research thread.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
id: { type: "number", description: "Candidate ID to remove" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user