diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts index defcab4..744059e 100644 --- a/src/server/mcp/tools/save-keywords.ts +++ b/src/server/mcp/tools/save-keywords.ts @@ -10,6 +10,7 @@ import { locationCodeSchema, projectIdSchema, } from "@/server/mcp/schemas"; +import { savedKeywordMetricSchema } from "@/types/schemas/keywords"; const inputSchema = { projectId: projectIdSchema, @@ -18,6 +19,13 @@ const inputSchema = { .min(1) .max(100) .describe("Keywords to save (1-100)."), + metrics: z + .array(savedKeywordMetricSchema) + .max(100) + .optional() + .describe( + "Optional metrics for the saved keywords. Copy keyword, searchVolume, keywordDifficulty, cpc, competition, and intent from research_keywords rows; map each row's trend to monthlySearches. Match each metric using its keyword field.", + ), tags: z .array(z.string().min(1).max(64)) .max(20) @@ -70,6 +78,7 @@ export const saveKeywordsTool = { await KeywordResearchService.saveKeywords({ projectId: args.projectId, keywords: args.keywords, + metrics: args.metrics, tags: args.tags, tagMode: args.tagMode ?? "append", locationCode, diff --git a/src/server/mcp/tools/saved-keywords-tools.test.ts b/src/server/mcp/tools/saved-keywords-tools.test.ts index 5b6c5ac..3a34350 100644 --- a/src/server/mcp/tools/saved-keywords-tools.test.ts +++ b/src/server/mcp/tools/saved-keywords-tools.test.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { listSavedKeywordsTool } from "./list-saved-keywords"; import { saveKeywordsTool } from "./save-keywords"; @@ -63,6 +64,43 @@ describe("saved keyword MCP tools", () => { }); }); + it("accepts and passes keyword metrics through save_keywords", async () => { + mocks.saveKeywords.mockResolvedValue({ + success: true, + savedKeywordIds: ["saved_1"], + }); + const metrics = [ + { + keyword: "technical seo", + searchVolume: 120, + keywordDifficulty: 18, + cpc: 2.5, + competition: 0.42, + intent: "commercial" as const, + monthlySearches: [ + { year: 2026, month: 7, searchVolume: 110 }, + { year: 2026, month: 8, searchVolume: 120 }, + ], + }, + ]; + const args = z.object(saveKeywordsTool.config.inputSchema).parse({ + projectId: "project_1", + keywords: ["technical seo"], + metrics, + }); + + await saveKeywordsTool.handler(args, toolContext); + + expect(mocks.saveKeywords).toHaveBeenCalledWith({ + projectId: "project_1", + keywords: ["technical seo"], + metrics, + tagMode: "append", + locationCode: 2840, + languageCode: "en", + }); + }); + it("replaces tags through save_keywords when requested", async () => { mocks.saveKeywords.mockResolvedValue({ success: true, diff --git a/src/types/schemas/keywords.ts b/src/types/schemas/keywords.ts index 2a1af9c..56ec5fa 100644 --- a/src/types/schemas/keywords.ts +++ b/src/types/schemas/keywords.ts @@ -31,6 +31,33 @@ export const researchKeywordsSchema = z.object({ clickstream: z.boolean().optional().default(false), }); +export const savedKeywordMetricSchema = z.object({ + keyword: z.string().min(1), + searchVolume: z.number().int().nonnegative().nullable().optional(), + cpc: z.number().nonnegative().nullable().optional(), + competition: z.number().min(0).max(1).nullable().optional(), + keywordDifficulty: z.number().int().min(0).max(100).nullable().optional(), + intent: z + .enum([ + "informational", + "commercial", + "transactional", + "navigational", + "unknown", + ]) + .nullable() + .optional(), + monthlySearches: z + .array( + z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + searchVolume: z.number().int().nonnegative(), + }), + ) + .optional(), +}); + export const saveKeywordsSchema = z .object({ projectId: z.string().min(1), @@ -39,43 +66,7 @@ export const saveKeywordsSchema = z languageCode: z.string().min(2).max(8).optional(), tags: z.array(savedKeywordTagSchema).max(20).optional(), tagMode: z.enum(["append", "replace"]).optional(), - metrics: z - .array( - z.object({ - keyword: z.string().min(1), - searchVolume: z.number().int().nonnegative().nullable().optional(), - cpc: z.number().nonnegative().nullable().optional(), - competition: z.number().min(0).max(1).nullable().optional(), - keywordDifficulty: z - .number() - .int() - .min(0) - .max(100) - .nullable() - .optional(), - intent: z - .enum([ - "informational", - "commercial", - "transactional", - "navigational", - "unknown", - ]) - .nullable() - .optional(), - monthlySearches: z - .array( - z.object({ - year: z.number().int().positive(), - month: z.number().int().min(1).max(12), - searchVolume: z.number().int().nonnegative(), - }), - ) - .optional(), - }), - ) - .max(500) - .optional(), + metrics: z.array(savedKeywordMetricSchema).max(500).optional(), }) .refine( (value) => value.tagMode !== "replace" || (value.tags?.length ?? 0) > 0,