From 86e6e9de674e1168201cb2b2cdb10f6d7cbf9bbb Mon Sep 17 00:00:00 2001 From: Despiram Date: Fri, 7 Aug 2026 11:06:23 +0200 Subject: [PATCH] feat(mcp): add rank-tracking keyword write tools Add add_rank_tracking_keywords and remove_rank_tracking_keywords MCP tools so agents can manage a rank tracker's watchlist, not just read it via get_rank_tracker. Both tools call the existing RankTrackingService (no duplicated business logic), scope access through withMcpProjectAuth, and resolve the tracker automatically when the project has exactly one. Adding reports added/duplicate/limit-skipped keywords and triggers no check (no credits spent); removing matches keywords case-insensitively and reports unmatched ones. Tools are listed on the AI/MCP settings page. Co-Authored-By: Claude Fable 5 --- src/client/features/ai-mcp/AvailableTools.tsx | 10 + src/server/mcp/server.ts | 22 ++ .../tools/rank-tracking-write-tools.test.ts | 265 ++++++++++++++++++ .../mcp/tools/rank-tracking-write-tools.ts | 251 +++++++++++++++++ 4 files changed, 548 insertions(+) create mode 100644 src/server/mcp/tools/rank-tracking-write-tools.test.ts create mode 100644 src/server/mcp/tools/rank-tracking-write-tools.ts diff --git a/src/client/features/ai-mcp/AvailableTools.tsx b/src/client/features/ai-mcp/AvailableTools.tsx index f33e0ee7..a1536c10 100644 --- a/src/client/features/ai-mcp/AvailableTools.tsx +++ b/src/client/features/ai-mcp/AvailableTools.tsx @@ -23,6 +23,16 @@ const toolCategories: ToolCategory[] = [ title: "Get rank tracking positions", description: "Read tracked keyword positions.", }, + { + name: "add_rank_tracking_keywords", + title: "Add rank tracking keywords", + description: "Add keywords to a rank tracker's watchlist.", + }, + { + name: "remove_rank_tracking_keywords", + title: "Remove rank tracking keywords", + description: "Remove keywords from a rank tracker's watchlist.", + }, { name: "get_keyword_metrics", title: "Get keyword metrics", diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts index 9420d6d7..978ce174 100644 --- a/src/server/mcp/server.ts +++ b/src/server/mcp/server.ts @@ -17,6 +17,10 @@ import { getRankedKeywordsTool, searchLocalBusinessesTool, } from "@/server/mcp/tools/dataforseo-research-tools"; +import { + addRankTrackingKeywordsTool, + removeRankTrackingKeywordsTool, +} from "@/server/mcp/tools/rank-tracking-write-tools"; import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords"; import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords"; import { @@ -145,6 +149,24 @@ export function registerOpenSeoMcpTools(server: McpServer) { getRankTrackerTool.handler, ), ); + server.registerTool( + addRankTrackingKeywordsTool.name, + addRankTrackingKeywordsTool.config, + instrumentMcpToolHandler( + addRankTrackingKeywordsTool.name, + addRankTrackingKeywordsTool.config.outputSchema, + addRankTrackingKeywordsTool.handler, + ), + ); + server.registerTool( + removeRankTrackingKeywordsTool.name, + removeRankTrackingKeywordsTool.config, + instrumentMcpToolHandler( + removeRankTrackingKeywordsTool.name, + removeRankTrackingKeywordsTool.config.outputSchema, + removeRankTrackingKeywordsTool.handler, + ), + ); server.registerTool( getRankedKeywordsTool.name, getRankedKeywordsTool.config, diff --git a/src/server/mcp/tools/rank-tracking-write-tools.test.ts b/src/server/mcp/tools/rank-tracking-write-tools.test.ts new file mode 100644 index 00000000..fec23fe4 --- /dev/null +++ b/src/server/mcp/tools/rank-tracking-write-tools.test.ts @@ -0,0 +1,265 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { ToolExtra } from "@/server/mcp/context"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; + +const mocks = vi.hoisted(() => ({ + getProjectForOrganization: vi.fn(), + getConfigById: vi.fn(), + getConfigsForProject: vi.fn(), + getKeywordsForConfig: vi.fn(), + addKeywords: vi.fn(), + removeKeywords: vi.fn(), +})); + +vi.mock("@/server/features/projects/services/ProjectService", () => ({ + ProjectService: { + getProjectForOrganization: mocks.getProjectForOrganization, + }, +})); + +vi.mock( + "@/server/features/rank-tracking/repositories/RankTrackingRepository", + () => ({ + RankTrackingRepository: { + getConfigById: mocks.getConfigById, + getConfigsForProject: mocks.getConfigsForProject, + getKeywordsForConfig: mocks.getKeywordsForConfig, + }, + }), +); + +vi.mock("@/server/features/rank-tracking/services/RankTrackingService", () => ({ + RankTrackingService: { + addKeywords: mocks.addKeywords, + removeKeywords: mocks.removeKeywords, + }, +})); + +const authContext = { + userId: "user_123", + userEmail: "alice@example.com", + organizationId: "org_123", + clientId: "client_123", + scopes: ["mcp"], + audience: "https://open-seo.test/mcp", + subject: "user_123", + baseUrl: "https://open-seo.test", +}; + +const toolExtra: ToolExtra = { + signal: new AbortController().signal, + requestId: 1, + sendNotification: vi.fn(), + sendRequest: vi.fn(), + authInfo: { + token: "token", + clientId: "client_123", + scopes: ["mcp"], + resource: new URL("https://open-seo.test/mcp"), + extra: { [MCP_AUTH_CONTEXT_PROP]: authContext }, + } satisfies AuthInfo, +}; + +const config = { + id: "config_1", + projectId: "project_1", + domain: "example.com", + locationCode: 2840, +}; + +function text(result: { content?: Array<{ type: string; text?: string }> }) { + const first = result.content?.[0]; + return first?.type === "text" ? (first.text ?? "") : ""; +} + +describe("rank tracking write MCP tools", () => { + beforeEach(() => { + vi.resetModules(); + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.getProjectForOrganization.mockResolvedValue({ + id: "project_1", + locationCode: 2840, + languageCode: "en", + }); + }); + + describe("add_rank_tracking_keywords", () => { + it("adds keywords through the service and reports duplicates", async () => { + mocks.getConfigById.mockResolvedValue(config); + mocks.getKeywordsForConfig.mockResolvedValue([ + { id: "kw_1", keyword: "seo tools" }, + ]); + mocks.addKeywords.mockResolvedValue({ added: 1, addedIds: ["kw_2"] }); + const { addRankTrackingKeywordsTool } = + await import("./rank-tracking-write-tools"); + + const result = await addRankTrackingKeywordsTool.handler( + { + projectId: "project_1", + trackerId: "config_1", + keywords: [" Free SEO Tools ", "SEO Tools"], + }, + toolExtra, + ); + + expect(mocks.addKeywords).toHaveBeenCalledWith("config_1", "project_1", [ + " Free SEO Tools ", + "SEO Tools", + ]); + expect(result.structuredContent).toMatchObject({ + projectId: "project_1", + trackerId: "config_1", + added: 1, + addedKeywords: ["free seo tools"], + duplicateKeywords: ["seo tools"], + skippedByLimit: 0, + }); + expect(text(result)).toContain("Added 1 keyword(s)"); + expect(text(result)).toContain("Already tracked (1): seo tools"); + }); + + it("resolves the tracker when the project has exactly one", async () => { + mocks.getConfigsForProject.mockResolvedValue([config]); + mocks.getKeywordsForConfig.mockResolvedValue([]); + mocks.addKeywords.mockResolvedValue({ added: 1, addedIds: ["kw_1"] }); + const { addRankTrackingKeywordsTool } = + await import("./rank-tracking-write-tools"); + + const result = await addRankTrackingKeywordsTool.handler( + { projectId: "project_1", keywords: ["seo tools"] }, + toolExtra, + ); + + expect(mocks.getConfigById).not.toHaveBeenCalled(); + expect(mocks.addKeywords).toHaveBeenCalledWith("config_1", "project_1", [ + "seo tools", + ]); + expect(result.structuredContent).toMatchObject({ + trackerId: "config_1", + added: 1, + }); + }); + + it("requires trackerId when the project has several trackers", async () => { + mocks.getConfigsForProject.mockResolvedValue([ + config, + { ...config, id: "config_2", domain: "example.org" }, + ]); + const { addRankTrackingKeywordsTool } = + await import("./rank-tracking-write-tools"); + + await expect(() => + addRankTrackingKeywordsTool.handler( + { projectId: "project_1", keywords: ["seo tools"] }, + toolExtra, + ), + ).rejects.toThrow("pass trackerId"); + expect(mocks.addKeywords).not.toHaveBeenCalled(); + }); + + it("rejects an unknown trackerId before writing", async () => { + mocks.getConfigById.mockResolvedValue(null); + const { addRankTrackingKeywordsTool } = + await import("./rank-tracking-write-tools"); + + await expect(() => + addRankTrackingKeywordsTool.handler( + { + projectId: "project_1", + trackerId: "missing", + keywords: ["seo tools"], + }, + toolExtra, + ), + ).rejects.toThrow("not found"); + expect(mocks.addKeywords).not.toHaveBeenCalled(); + }); + + it("reports keywords skipped by the per-tracker limit", async () => { + mocks.getConfigById.mockResolvedValue(config); + mocks.getKeywordsForConfig.mockResolvedValue([]); + // Service hit the cap: only one of the two new keywords fit. + mocks.addKeywords.mockResolvedValue({ added: 1, addedIds: ["kw_1"] }); + const { addRankTrackingKeywordsTool } = + await import("./rank-tracking-write-tools"); + + const result = await addRankTrackingKeywordsTool.handler( + { + projectId: "project_1", + trackerId: "config_1", + keywords: ["seo tools", "free seo tools"], + }, + toolExtra, + ); + + expect(result.structuredContent).toMatchObject({ + added: 1, + addedKeywords: ["seo tools"], + skippedByLimit: 1, + }); + expect(text(result)).toContain("Skipped 1 keyword(s)"); + }); + }); + + describe("remove_rank_tracking_keywords", () => { + it("maps keywords to tracking IDs and reports unmatched ones", async () => { + mocks.getConfigById.mockResolvedValue(config); + mocks.getKeywordsForConfig.mockResolvedValue([ + { id: "kw_1", keyword: "seo tools" }, + { id: "kw_2", keyword: "free seo tools" }, + ]); + mocks.removeKeywords.mockResolvedValue(undefined); + const { removeRankTrackingKeywordsTool } = + await import("./rank-tracking-write-tools"); + + const result = await removeRankTrackingKeywordsTool.handler( + { + projectId: "project_1", + trackerId: "config_1", + keywords: [" SEO Tools ", "unknown keyword"], + }, + toolExtra, + ); + + expect(mocks.removeKeywords).toHaveBeenCalledWith( + "config_1", + "project_1", + ["kw_1"], + ); + expect(result.structuredContent).toMatchObject({ + projectId: "project_1", + trackerId: "config_1", + removed: 1, + removedKeywords: ["seo tools"], + notFoundKeywords: ["unknown keyword"], + }); + expect(text(result)).toContain("Removed 1 keyword(s)"); + expect(text(result)).toContain("Not tracked (1): unknown keyword"); + }); + + it("skips the service call when nothing matches", async () => { + mocks.getConfigById.mockResolvedValue(config); + mocks.getKeywordsForConfig.mockResolvedValue([ + { id: "kw_1", keyword: "seo tools" }, + ]); + const { removeRankTrackingKeywordsTool } = + await import("./rank-tracking-write-tools"); + + const result = await removeRankTrackingKeywordsTool.handler( + { + projectId: "project_1", + trackerId: "config_1", + keywords: ["unknown keyword"], + }, + toolExtra, + ); + + expect(mocks.removeKeywords).not.toHaveBeenCalled(); + expect(result.structuredContent).toMatchObject({ + removed: 0, + notFoundKeywords: ["unknown keyword"], + }); + }); + }); +}); diff --git a/src/server/mcp/tools/rank-tracking-write-tools.ts b/src/server/mcp/tools/rank-tracking-write-tools.ts new file mode 100644 index 00000000..36a44560 --- /dev/null +++ b/src/server/mcp/tools/rank-tracking-write-tools.ts @@ -0,0 +1,251 @@ +import { z } from "zod"; +import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; +import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { projectIdSchema } from "@/server/mcp/schemas"; +import { MAX_KEYWORDS_PER_CONFIG } from "@/shared/rank-tracking"; + +const trackerIdSchema = z + .string() + .optional() + .describe( + "Rank tracker config ID (see get_rank_tracker). Optional when the project has exactly one tracker.", + ); + +const keywordsSchema = z + .array(z.string().min(1)) + .min(1) + .max(100) + .describe("Keywords (1-100). Matched case-insensitively after trimming."); + +// Tracking keywords are stored trimmed and lowercased (see +// RankTrackingService.addKeywords). Mirror that here so duplicate/not-found +// reporting compares like with like. +function normalizeKeywords(keywords: string[]) { + const seen = new Set(); + const normalized: string[] = []; + for (const raw of keywords) { + const keyword = raw.trim().toLowerCase(); + if (keyword && !seen.has(keyword)) { + seen.add(keyword); + normalized.push(keyword); + } + } + return normalized; +} + +// Resolves the target tracker config, falling back to the project's only +// tracker when trackerId is omitted. Throws with an actionable message so the +// calling agent knows to pick a tracker or create one. +async function resolveTrackerConfig(projectId: string, trackerId?: string) { + if (trackerId) { + const config = await RankTrackingRepository.getConfigById({ + configId: trackerId, + projectId, + }); + if (!config) { + throw new Error( + `Rank tracker ${trackerId} not found in project ${projectId}. Use get_rank_tracker to list trackers.`, + ); + } + return config; + } + + const configs = await RankTrackingRepository.getConfigsForProject(projectId); + if (configs.length === 0) { + throw new Error( + `No rank trackers configured for project ${projectId}. Create one from the dashboard first.`, + ); + } + if (configs.length > 1) { + const trackers = configs + .map((c) => `- ${c.id} ${c.domain} loc:${c.locationCode}`) + .join("\n"); + throw new Error( + `Project ${projectId} has ${configs.length} rank trackers; pass trackerId to pick one:\n${trackers}`, + ); + } + return configs[0]; +} + +const addInputSchema = { + projectId: projectIdSchema, + trackerId: trackerIdSchema, + keywords: keywordsSchema, +} as const; + +type AddArgs = z.infer>; + +export const addRankTrackingKeywordsTool = { + name: "add_rank_tracking_keywords", + config: { + title: "Add rank tracking keywords", + description: + "Add keywords to a project's rank tracker watchlist. Uses no credits and triggers no check — new keywords get positions on the next scheduled check, or trigger one from the dashboard. Idempotent: already-tracked keywords are reported as duplicates and skipped. Keywords are stored trimmed and lowercased. If trackerId is omitted and the project has exactly one tracker, that tracker is used.", + inputSchema: addInputSchema, + outputSchema: { + projectId: z.string(), + trackerId: z.string(), + added: z.number(), + addedKeywords: z.array(z.string()), + duplicateKeywords: z.array(z.string()), + skippedByLimit: z.number(), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: false, + }, + }, + handler: withMcpProjectAuth(async (args: AddArgs, context) => { + const config = await resolveTrackerConfig(args.projectId, args.trackerId); + + const existing = await RankTrackingRepository.getKeywordsForConfig( + config.id, + ); + const existingKeywords = new Set(existing.map((kw) => kw.keyword)); + + const requested = normalizeKeywords(args.keywords); + const duplicateKeywords = requested.filter((kw) => + existingKeywords.has(kw), + ); + const newKeywords = requested.filter((kw) => !existingKeywords.has(kw)); + + const result = await RankTrackingService.addKeywords( + config.id, + args.projectId, + args.keywords, + ); + + // The service inserts new keywords in request order and stops at the + // per-tracker cap, so the first `added` new keywords are the ones stored. + const addedKeywords = newKeywords.slice(0, result.added); + const skippedByLimit = newKeywords.length - result.added; + + const lines = [ + `Added ${result.added} keyword(s) to rank tracker ${config.id} (${config.domain}).`, + ]; + if (duplicateKeywords.length > 0) { + lines.push( + `Already tracked (${duplicateKeywords.length}): ${duplicateKeywords.join(", ")}`, + ); + } + if (skippedByLimit > 0) { + lines.push( + `Skipped ${skippedByLimit} keyword(s): tracker is at the ${MAX_KEYWORDS_PER_CONFIG}-keyword limit.`, + ); + } + if (result.added > 0) { + lines.push( + "Positions appear after the next check (scheduled, or triggered from the dashboard).", + ); + } + + return mcpResponse({ + text: lines.join("\n"), + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/rank-tracking/${config.id}`, + ), + structuredContent: { + projectId: args.projectId, + trackerId: config.id, + added: result.added, + addedKeywords, + duplicateKeywords, + skippedByLimit, + }, + }); + }), +}; + +const removeInputSchema = { + projectId: projectIdSchema, + trackerId: trackerIdSchema, + keywords: keywordsSchema, +} as const; + +type RemoveArgs = z.infer>; + +export const removeRankTrackingKeywordsTool = { + name: "remove_rank_tracking_keywords", + config: { + title: "Remove rank tracking keywords", + description: + "Remove keywords from a project's rank tracker watchlist so future checks no longer include them. Keywords are matched case-insensitively after trimming; unmatched keywords are reported, not an error. Uses no credits. If trackerId is omitted and the project has exactly one tracker, that tracker is used. Ask the user for confirmation before removing many keywords.", + inputSchema: removeInputSchema, + outputSchema: { + projectId: z.string(), + trackerId: z.string(), + removed: z.number(), + removedKeywords: z.array(z.string()), + notFoundKeywords: z.array(z.string()), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: true, + }, + }, + handler: withMcpProjectAuth(async (args: RemoveArgs, context) => { + const config = await resolveTrackerConfig(args.projectId, args.trackerId); + + const existing = await RankTrackingRepository.getKeywordsForConfig( + config.id, + ); + const idByKeyword = new Map(existing.map((kw) => [kw.keyword, kw.id])); + + const requested = normalizeKeywords(args.keywords); + const removedKeywords: string[] = []; + const removedIds: string[] = []; + const notFoundKeywords: string[] = []; + for (const keyword of requested) { + const id = idByKeyword.get(keyword); + if (id) { + removedKeywords.push(keyword); + removedIds.push(id); + } else { + notFoundKeywords.push(keyword); + } + } + + if (removedIds.length > 0) { + await RankTrackingService.removeKeywords( + config.id, + args.projectId, + removedIds, + ); + } + + const lines = [ + `Removed ${removedKeywords.length} keyword(s) from rank tracker ${config.id} (${config.domain}).`, + ]; + if (notFoundKeywords.length > 0) { + lines.push( + `Not tracked (${notFoundKeywords.length}): ${notFoundKeywords.join(", ")}`, + ); + } + + return mcpResponse({ + text: lines.join("\n"), + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/rank-tracking/${config.id}`, + ), + structuredContent: { + projectId: args.projectId, + trackerId: config.id, + removed: removedKeywords.length, + removedKeywords, + notFoundKeywords, + }, + }); + }), +};