Fix MCP saved keyword metrics (#483)

This commit is contained in:
Ben Senescu 2026-08-13 20:16:20 -04:00 committed by GitHub
parent 72eaa09c32
commit db45a0dd6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 75 additions and 37 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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,