From b733be4a5edc395b0c1129adaea6fe0d672347a2 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:01:44 -0400 Subject: [PATCH] release: v0.0.23 - fix mcp for pi + refresh saved keyword metrics (#56) * fix(mcp): render full row data in tool text output, not just counts MCP clients that surface only the text content block (not structuredContent) saw count/truncated summaries instead of the actual rows. Add a shared table renderer (server/mcp/table.ts) and use it so every row ships in the text block across research_keywords, get_ranked_keywords, get_keyword_metrics, search_local_businesses, get_local_serp_results, get_google_business_questions, find_serp_competitors, get_backlinks_profile, get_backlinks_overview, get_domain_keyword_suggestions, get_rank_tracker, get_serp_results, and get_search_console_performance. * test(mcp): assert tool text output renders full row tables Cover the shared table renderer (table.test.ts) and add per-tool assertions that each tool's text content block contains the actual row data (exact rendered rows, null->em-dash), guarding against a column wired to the wrong field. Adds tool-text-output.test.ts for the service-backed tools and text assertions to the DataForSEO and Search Console tool tests. * release: v0.0.23 * style(mcp): prettier-format tool table columns --- package.json | 2 +- release-notes/v0.0.23.md | 12 + src/server/mcp/table.test.ts | 95 +++++ src/server/mcp/table.ts | 60 ++++ .../tools/dataforseo-research-tools.test.ts | 18 + .../mcp/tools/dataforseo-research-tools.ts | 167 ++++++--- .../mcp/tools/get-backlinks-overview.ts | 22 +- src/server/mcp/tools/get-backlinks-profile.ts | 25 +- .../tools/get-domain-keyword-suggestions.ts | 25 +- src/server/mcp/tools/get-rank-tracker.ts | 28 +- src/server/mcp/tools/get-serp-results.ts | 32 +- src/server/mcp/tools/research-keywords.ts | 40 ++- .../mcp/tools/search-console-tools.test.ts | 6 + src/server/mcp/tools/search-console-tools.ts | 35 +- src/server/mcp/tools/tool-text-output.test.ts | 327 ++++++++++++++++++ 15 files changed, 801 insertions(+), 93 deletions(-) create mode 100644 release-notes/v0.0.23.md create mode 100644 src/server/mcp/table.test.ts create mode 100644 src/server/mcp/table.ts create mode 100644 src/server/mcp/tools/tool-text-output.test.ts diff --git a/package.json b/package.json index 5f76515..36478d2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "open-seo", "private": true, "sideEffects": false, - "version": "0.0.22", + "version": "0.0.23", "type": "module", "packageManager": "pnpm@10.30.1", "scripts": { diff --git a/release-notes/v0.0.23.md b/release-notes/v0.0.23.md new file mode 100644 index 0000000..7eed2ad --- /dev/null +++ b/release-notes/v0.0.23.md @@ -0,0 +1,12 @@ +This release lets you refresh every saved keyword's metrics in one click, and makes the MCP tools return their full results in the text response instead of just a row count. + +## What's new + +- Add an "Update keyword stats" action to the Saved Keywords page that refreshes search volume, CPC, competition, difficulty, and intent for every saved keyword — no need to re-run keyword research. — thanks @0xenzyme + +## Fixed + +- MCP clients that read only the text response now get the full result set — every keyword, volume, difficulty, ranking, competitor, and backlink row — instead of just a count or a truncated list. + - Applies to `research_keywords`, `get_keyword_metrics`, `get_ranked_keywords`, `get_serp_results`, `search_local_businesses`, `get_local_serp_results`, `get_google_business_questions`, `find_serp_competitors`, `get_backlinks_profile`, `get_backlinks_overview`, `get_domain_keyword_suggestions`, `get_rank_tracker`, and `get_search_console_performance`. Structured output is unchanged. + +Full Changelog: https://github.com/every-app/open-seo/compare/v0.0.22...v0.0.23 diff --git a/src/server/mcp/table.test.ts b/src/server/mcp/table.test.ts new file mode 100644 index 0000000..18b1489 --- /dev/null +++ b/src/server/mcp/table.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { formatMcpCell, formatMcpTable, readPath } from "./table"; + +describe("formatMcpCell", () => { + it("renders nullish and empty values as an em dash", () => { + expect(formatMcpCell(null)).toBe("—"); + expect(formatMcpCell(undefined)).toBe("—"); + expect(formatMcpCell("")).toBe("—"); + }); + + it("keeps integers exact and gives other numbers two decimals", () => { + expect(formatMcpCell(0)).toBe("0"); + expect(formatMcpCell(2400)).toBe("2400"); + expect(formatMcpCell(1.5)).toBe("1.50"); + expect(formatMcpCell(0.333333)).toBe("0.33"); + }); + + it("renders non-finite numbers as an em dash", () => { + expect(formatMcpCell(Number.NaN)).toBe("—"); + expect(formatMcpCell(Number.POSITIVE_INFINITY)).toBe("—"); + }); + + it("renders booleans and bigints", () => { + expect(formatMcpCell(true)).toBe("yes"); + expect(formatMcpCell(false)).toBe("no"); + expect(formatMcpCell(10n)).toBe("10"); + }); + + it("collapses whitespace so a stray newline can't break the table", () => { + expect(formatMcpCell("multi\nline value")).toBe("multi line value"); + }); + + it("emits compact JSON for objects instead of [object Object]", () => { + expect(formatMcpCell({ a: 1 })).toBe('{"a":1}'); + expect(formatMcpCell([1, 2])).toBe("[1,2]"); + }); +}); + +describe("formatMcpTable", () => { + type Row = { keyword: string; volume: number | null }; + const columns = [ + { header: "keyword", value: (row: Row) => row.keyword }, + { header: "volume", value: (row: Row) => row.volume }, + ]; + + it("renders a header line plus one line per row", () => { + const table = formatMcpTable( + [ + { keyword: "seo tools", volume: 2400 }, + { keyword: "seo audit", volume: null }, + ], + columns, + ); + expect(table).toBe( + ["keyword | volume", "seo tools | 2400", "seo audit | —"].join("\n"), + ); + }); + + it("renders only the header when there are no rows", () => { + expect(formatMcpTable([], columns)).toBe("keyword | volume"); + }); + + it("uses a per-column format override when provided", () => { + const table = formatMcpTable( + [{ keyword: "x", volume: 0.04 }], + [ + { header: "keyword", value: (row: Row) => row.keyword }, + { + header: "CTR", + value: (row: Row) => row.volume, + format: (value) => + typeof value === "number" ? `${(value * 100).toFixed(1)}%` : "—", + }, + ], + ); + expect(table).toBe(["keyword | CTR", "x | 4.0%"].join("\n")); + }); +}); + +describe("readPath", () => { + it("walks nested records", () => { + expect(readPath({ a: { b: { c: 3 } } }, "a", "b", "c")).toBe(3); + }); + + it("returns undefined when a hop is missing or not an object", () => { + expect(readPath({ a: null }, "a", "b")).toBeUndefined(); + expect(readPath({ a: 1 }, "a", "b")).toBeUndefined(); + expect(readPath(null, "a")).toBeUndefined(); + expect(readPath(undefined, "a")).toBeUndefined(); + }); + + it("reads a top-level key", () => { + expect(readPath({ keyword: "seo" }, "keyword")).toBe("seo"); + }); +}); diff --git a/src/server/mcp/table.ts b/src/server/mcp/table.ts new file mode 100644 index 0000000..b17eeda --- /dev/null +++ b/src/server/mcp/table.ts @@ -0,0 +1,60 @@ +// Shared renderer for MCP tool text output. Tools return row data in +// structuredContent, but MCP clients that surface only the text content block +// would otherwise see just a summary. Rendering every row as a compact +// pipe-delimited table here keeps the text block in parity with the data. + +export type McpTableColumn = { + header: string; + value: (row: T) => unknown; + /** Override the default cell formatting for this column. */ + format?: (value: unknown) => string; +}; + +/** Format a single cell. Nullish/empty -> "—"; integers stay exact; other + * numbers get 2 decimals; strings are collapsed to a single line so a stray + * newline can't break the table layout. */ +export function formatMcpCell(value: unknown): string { + if (value == null || value === "") return "—"; + if (typeof value === "number") { + if (!Number.isFinite(value)) return "—"; + return Number.isInteger(value) ? String(value) : value.toFixed(2); + } + if (typeof value === "boolean") return value ? "yes" : "no"; + if (typeof value === "string") return value.replace(/\s+/g, " ").trim(); + if (typeof value === "bigint") return value.toString(); + // Arrays/objects would stringify to "[object Object]"; emit compact JSON so a + // stray nested value still reads as something in the text table. + try { + return JSON.stringify(value) ?? "—"; + } catch { + return "—"; + } +} + +/** Render rows as a `header | header` table with one line per row. */ +export function formatMcpTable( + rows: readonly T[], + columns: readonly McpTableColumn[], +): string { + const headerLine = columns.map((column) => column.header).join(" | "); + const rowLines = rows.map((row) => + columns + .map((column) => { + const raw = column.value(row); + return column.format ? column.format(raw) : formatMcpCell(raw); + }) + .join(" | "), + ); + return [headerLine, ...rowLines].join("\n"); +} + +/** Walk a chain of keys through nested unknown records (provider rows). + * Returns undefined if any hop isn't an object. */ +export function readPath(source: unknown, ...path: string[]): unknown { + let current: unknown = source; + for (const key of path) { + if (typeof current !== "object" || current === null) return undefined; + current = Reflect.get(current, key); + } + return current; +} diff --git a/src/server/mcp/tools/dataforseo-research-tools.test.ts b/src/server/mcp/tools/dataforseo-research-tools.test.ts index cd72933..a65e877 100644 --- a/src/server/mcp/tools/dataforseo-research-tools.test.ts +++ b/src/server/mcp/tools/dataforseo-research-tools.test.ts @@ -48,6 +48,13 @@ const toolExtra: ToolExtra = { } satisfies AuthInfo, }; +function textOf(result: { + content?: Array<{ type: string; text?: string }>; +}): string { + const first = result.content?.[0]; + return first?.type === "text" ? (first.text ?? "") : ""; +} + describe("DataForSEO research MCP tools", () => { beforeEach(() => { vi.resetModules(); @@ -100,6 +107,8 @@ describe("DataForSEO research MCP tools", () => { .passthrough() .parse(result.structuredContent); expect(content.businesses).toEqual([{ title: "Acme Cafe" }]); + expect(textOf(result)).toContain("title | category"); + expect(textOf(result)).toContain("Acme Cafe"); }); it("fetches one local SERP with search_places disabled", async () => { @@ -152,6 +161,8 @@ describe("DataForSEO research MCP tools", () => { rank_group: 1, rank_absolute: 2, }); + expect(textOf(result)).toContain("rank | title | rating"); + expect(textOf(result)).toContain("Acme Cafe"); }); it("fetches Google Business Q&A as an explicit tool", async () => { @@ -191,6 +202,8 @@ describe("DataForSEO research MCP tools", () => { expect(content.questions).toEqual([ { question_text: "Do you serve breakfast?" }, ]); + expect(textOf(result)).toContain("question | asked by"); + expect(textOf(result)).toContain("Do you serve breakfast?"); }); it("passes only explicit brand exclusions to ranked keyword filters", async () => { @@ -249,6 +262,8 @@ describe("DataForSEO research MCP tools", () => { expect(content.competitors.map((row) => row.domain)).toEqual([ "competitor.example", ]); + expect(textOf(result)).toContain("domain | keywords | avg pos"); + expect(textOf(result)).toContain("competitor.example"); }); it("keeps AI overview result types out of SERP competitors", async () => { @@ -328,6 +343,9 @@ describe("DataForSEO research MCP tools", () => { keyword_difficulty: 18, main_intent: "commercial", }); + const out = textOf(result); + expect(out).toContain("keyword | volume | KD | CPC | competition | intent"); + expect(out).toContain("seo automation"); }); it("sorts keyword metric rows by the requested numeric field", async () => { diff --git a/src/server/mcp/tools/dataforseo-research-tools.ts b/src/server/mcp/tools/dataforseo-research-tools.ts index 3616649..9c5a016 100644 --- a/src/server/mcp/tools/dataforseo-research-tools.ts +++ b/src/server/mcp/tools/dataforseo-research-tools.ts @@ -13,6 +13,11 @@ import { optionalMetaOutputSchema, } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + formatMcpTable, + readPath, + type McpTableColumn, +} from "@/server/mcp/table"; import { DEFAULT_LANGUAGE_CODE, DEFAULT_LOCATION_CODE, @@ -414,35 +419,6 @@ function buildRankedKeywordFilters(args: { return filters.length > 0 ? filters : undefined; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function asRecord(value: unknown): Record | undefined { - return isRecord(value) ? value : undefined; -} - -function displayValue(value: unknown): string { - if (typeof value === "string" || typeof value === "number") { - return String(value); - } - return "?"; -} - -function summarizeRankedKeyword(item: Record): string { - const keywordData = asRecord(item.keyword_data); - const keywordInfo = asRecord(keywordData?.keyword_info); - const serpElement = asRecord(item.ranked_serp_element); - const serpItem = asRecord(serpElement?.serp_item); - const keyword = displayValue(keywordData?.keyword ?? item.keyword); - const rank = displayValue( - serpItem?.rank_absolute ?? serpElement?.rank_absolute ?? item.rank_absolute, - ); - const volume = displayValue(keywordInfo?.search_volume); - const url = displayValue(serpItem?.url ?? serpElement?.url); - return `- "${keyword}" #${rank} vol:${volume} ${url}`.trim(); -} - function sortCompetitors( items: Record[], sortBy: FindSerpCompetitorsArgs["sortBy"], @@ -522,6 +498,95 @@ function hostMatchesDomain(host: string, domain: string): boolean { ); } +// Provider rows ship in full in structuredContent; these tables render every +// row into the text content block so text-only MCP clients see the data, not +// just a count. Loose rows are read positionally via readPath. + +type RankedKeywordRow = { + keyword: unknown; + rank: unknown; + volume: unknown; + cpc: unknown; + url: unknown; +}; + +function toRankedKeywordRow(item: unknown): RankedKeywordRow { + return { + keyword: + readPath(item, "keyword_data", "keyword") ?? readPath(item, "keyword"), + rank: + readPath(item, "ranked_serp_element", "serp_item", "rank_absolute") ?? + readPath(item, "ranked_serp_element", "rank_absolute") ?? + readPath(item, "rank_absolute"), + volume: readPath(item, "keyword_data", "keyword_info", "search_volume"), + cpc: readPath(item, "keyword_data", "keyword_info", "cpc"), + url: + readPath(item, "ranked_serp_element", "serp_item", "url") ?? + readPath(item, "ranked_serp_element", "url"), + }; +} + +const RANKED_KEYWORD_COLUMNS: McpTableColumn[] = [ + { header: "keyword", value: (row) => row.keyword }, + { header: "rank", value: (row) => row.rank }, + { header: "volume", value: (row) => row.volume }, + { header: "CPC", value: (row) => row.cpc }, + { header: "url", value: (row) => row.url }, +]; + +const LOCAL_BUSINESS_COLUMNS: McpTableColumn[] = [ + { header: "title", value: (row) => readPath(row, "title") }, + { header: "category", value: (row) => readPath(row, "category") }, + { header: "rating", value: (row) => readPath(row, "rating", "value") }, + { header: "reviews", value: (row) => readPath(row, "rating", "votes_count") }, + { header: "phone", value: (row) => readPath(row, "phone") }, + { header: "address", value: (row) => readPath(row, "address") }, +]; + +const LOCAL_SERP_COLUMNS: McpTableColumn[] = [ + { + header: "rank", + value: (row) => + readPath(row, "rank_absolute") ?? readPath(row, "rank_group"), + }, + { header: "title", value: (row) => readPath(row, "title") }, + { header: "rating", value: (row) => readPath(row, "rating", "value") }, + { header: "reviews", value: (row) => readPath(row, "rating", "votes_count") }, + { header: "phone", value: (row) => readPath(row, "phone") }, + { header: "address", value: (row) => readPath(row, "address") }, +]; + +const BUSINESS_QUESTION_COLUMNS: McpTableColumn[] = [ + { header: "question", value: (row) => readPath(row, "question_text") }, + { header: "asked by", value: (row) => readPath(row, "profile_name") }, + { header: "when", value: (row) => readPath(row, "time_ago") }, + { + header: "answers", + value: (row) => { + const answers = readPath(row, "items"); + return Array.isArray(answers) ? answers.length : 0; + }, + }, +]; + +const SERP_COMPETITOR_COLUMNS: McpTableColumn[] = [ + { header: "domain", value: (row) => readPath(row, "domain") }, + { header: "keywords", value: (row) => readPath(row, "keywords_count") }, + { header: "avg pos", value: (row) => readPath(row, "avg_position") }, + { header: "median pos", value: (row) => readPath(row, "median_position") }, + { header: "visibility", value: (row) => readPath(row, "visibility") }, + { header: "etv", value: (row) => readPath(row, "etv") }, +]; + +const KEYWORD_METRIC_COLUMNS: McpTableColumn[] = [ + { header: "keyword", value: (row) => readPath(row, "keyword") }, + { header: "volume", value: (row) => readPath(row, "search_volume") }, + { header: "KD", value: (row) => readPath(row, "keyword_difficulty") }, + { header: "CPC", value: (row) => readPath(row, "cpc") }, + { header: "competition", value: (row) => readPath(row, "competition") }, + { header: "intent", value: (row) => readPath(row, "main_intent") }, +]; + export const getRankedKeywordsTool = { name: "get_ranked_keywords", config: { @@ -559,15 +624,13 @@ export const getRankedKeywordsTool = { includeSubdomains: args.includeSubdomains ?? !targetIsPage, }); + const rankedRows = keywords.items.map(toRankedKeywordRow); + const text = + rankedRows.length === 0 + ? `No ranked keyword rows for ${args.target}.` + : `Found ${rankedRows.length} ranked keyword rows for ${args.target}${keywords.totalCount != null ? ` (of ${keywords.totalCount} total)` : ""}:\n${formatMcpTable(rankedRows, RANKED_KEYWORD_COLUMNS)}`; return mcpResponse({ - text: [ - `Found ${keywords.items.length} ranked keyword rows for ${args.target}.`, - ...keywords.items - .slice(0, 10) - .map((item) => - summarizeRankedKeyword(item as Record), - ), - ].join("\n"), + text, meta: buildProjectMeta( context, args.projectId, @@ -608,8 +671,12 @@ export const searchLocalBusinessesTool = { limit: args.limit ?? 20, }); + const header = `Found ${businesses.length} local business rows${args.query ? ` for ${args.query}` : ""}.`; return mcpResponse({ - text: `Found ${businesses.length} local business rows${args.query ? ` for ${args.query}` : ""}.`, + text: + businesses.length === 0 + ? header + : `${header}\n${formatMcpTable(businesses, LOCAL_BUSINESS_COLUMNS)}`, meta: buildProjectMeta(context, args.projectId, `/p/${args.projectId}`), structuredContent: { businesses }, }); @@ -647,8 +714,12 @@ export const getLocalSerpResultsTool = { searchPlaces: false, }); + const header = `Fetched ${results.length} local SERP rows for "${args.keyword}".`; return mcpResponse({ - text: `Fetched ${results.length} local SERP rows for "${args.keyword}".`, + text: + results.length === 0 + ? header + : `${header}\n${formatMcpTable(results, LOCAL_SERP_COLUMNS)}`, meta: buildProjectMeta(context, args.projectId, `/p/${args.projectId}`), structuredContent: { results }, }); @@ -683,8 +754,12 @@ export const getGoogleBusinessQuestionsTool = { depth: args.depth ?? 20, }); + const header = `Fetched ${questions.length} Google Business Q&A rows for ${args.keyword}.`; return mcpResponse({ - text: `Fetched ${questions.length} Google Business Q&A rows for ${args.keyword}.`, + text: + questions.length === 0 + ? header + : `${header}\n${formatMcpTable(questions, BUSINESS_QUESTION_COLUMNS)}`, meta: buildProjectMeta(context, args.projectId, `/p/${args.projectId}`), structuredContent: { questions }, }); @@ -733,8 +808,12 @@ export const findSerpCompetitorsTool = { }); const sorted = sortCompetitors(filtered, args.sortBy ?? "visibility"); + const header = `Found ${sorted.length} SERP competitors across ${args.keywords.length} keywords.`; return mcpResponse({ - text: `Found ${sorted.length} SERP competitors across ${args.keywords.length} keywords.`, + text: + sorted.length === 0 + ? header + : `${header}\n${formatMcpTable(sorted, SERP_COMPETITOR_COLUMNS)}`, meta: buildProjectMeta( context, args.projectId, @@ -797,8 +876,12 @@ export const getKeywordMetricsTool = { : row, ); + const header = `Fetched metrics for ${rows.length} keywords. Columns: volume = monthly searches, KD = keyword difficulty (0-100), CPC in USD, competition = paid competition (0-1); "—" = unavailable.`; return mcpResponse({ - text: `Fetched metrics (volume, difficulty, intent) for ${rows.length} keywords.`, + text: + rows.length === 0 + ? header + : `${header}\n${formatMcpTable(rows, KEYWORD_METRIC_COLUMNS)}`, meta: buildProjectMeta( context, args.projectId, diff --git a/src/server/mcp/tools/get-backlinks-overview.ts b/src/server/mcp/tools/get-backlinks-overview.ts index 535549c..d1d46c0 100644 --- a/src/server/mcp/tools/get-backlinks-overview.ts +++ b/src/server/mcp/tools/get-backlinks-overview.ts @@ -7,8 +7,23 @@ import { optionalMetaOutputSchema, } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + formatMcpTable, + readPath, + type McpTableColumn, +} from "@/server/mcp/table"; import { projectIdSchema } from "@/server/mcp/schemas"; +const REFERRING_DOMAIN_COLUMNS: McpTableColumn[] = [ + { header: "domain", value: (row) => readPath(row, "domain") }, + { header: "backlinks", value: (row) => readPath(row, "backlinks") }, + { + header: "referring pages", + value: (row) => readPath(row, "referringPages"), + }, + { header: "rank", value: (row) => readPath(row, "rank") }, +]; + const inputSchema = { projectId: projectIdSchema, target: z @@ -80,10 +95,9 @@ export const getBacklinksOverviewTool = { `- referring pages: ${formatMetric(summary.referringPages)}`, `- rank: ${formatMetric(summary.rank)}`, "", - `Top referring domains (${Math.min(topDomains.length, 10)} shown):`, - ...topDomains - .slice(0, 10) - .map((d) => `- ${d.domain ?? "?"} backlinks:${d.backlinks ?? "?"}`), + topDomains.length === 0 + ? "No referring domains found." + : `Referring domains (${topDomains.length}):\n${formatMcpTable(topDomains, REFERRING_DOMAIN_COLUMNS)}`, ].join("\n"); return mcpResponse({ text, diff --git a/src/server/mcp/tools/get-backlinks-profile.ts b/src/server/mcp/tools/get-backlinks-profile.ts index c12faa1..0018685 100644 --- a/src/server/mcp/tools/get-backlinks-profile.ts +++ b/src/server/mcp/tools/get-backlinks-profile.ts @@ -7,6 +7,7 @@ import { optionalMetaOutputSchema, } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; import { projectIdSchema } from "@/server/mcp/schemas"; import { BACKLINKS_DEFAULT_SORT, @@ -91,7 +92,7 @@ function formatStatus(row: { return statuses.length > 0 ? statuses.join(", ") : "live"; } -function formatBacklinkRow(row: { +type BacklinkRow = { domainFrom?: string | null; urlFrom?: string | null; urlTo?: string | null; @@ -102,10 +103,18 @@ function formatBacklinkRow(row: { spamScore?: number | null; isLost?: boolean | null; isBroken?: boolean | null; -}) { - const source = row.urlFrom ?? row.domainFrom ?? "?"; - return `- ${source} -> ${row.urlTo ?? "?"} anchor:"${row.anchor ?? ""}" ${formatLinkType(row.isDofollow)} rank:${formatMetric(row.rank)} domainRank:${formatMetric(row.domainFromRank)} spam:${formatMetric(row.spamScore)} status:${formatStatus(row)}`; -} +}; + +const BACKLINK_COLUMNS: McpTableColumn[] = [ + { header: "source", value: (row) => row.urlFrom ?? row.domainFrom }, + { header: "target", value: (row) => row.urlTo }, + { header: "anchor", value: (row) => row.anchor }, + { header: "type", value: (row) => formatLinkType(row.isDofollow) }, + { header: "rank", value: (row) => row.rank }, + { header: "domainRank", value: (row) => row.domainFromRank }, + { header: "spam", value: (row) => row.spamScore }, + { header: "status", value: (row) => formatStatus(row) }, +]; export const getBacklinksProfileTool = { name: "get_backlinks_profile", @@ -143,7 +152,6 @@ export const getBacklinksProfileTool = { context.billing, { hideSpam: args.hideSpam ?? true }, ); - const shownRows = backlinks.rows.slice(0, 10); const text = [ `Backlinks profile for ${request.target} (${request.scope ?? "domain"}):`, `- page: ${backlinks.page}`, @@ -152,8 +160,9 @@ export const getBacklinksProfileTool = { `- total backlinks: ${formatMetric(backlinks.totalCount)}`, `- has more: ${backlinks.hasMore ? "yes" : "no"}`, "", - `Backlink rows (${shownRows.length} shown in text, ${backlinks.rows.length} in structured content):`, - ...shownRows.map(formatBacklinkRow), + backlinks.rows.length === 0 + ? "No backlink rows for this page." + : formatMcpTable(backlinks.rows, BACKLINK_COLUMNS), ].join("\n"); return mcpResponse({ diff --git a/src/server/mcp/tools/get-domain-keyword-suggestions.ts b/src/server/mcp/tools/get-domain-keyword-suggestions.ts index cc3b722..45c04bc 100644 --- a/src/server/mcp/tools/get-domain-keyword-suggestions.ts +++ b/src/server/mcp/tools/get-domain-keyword-suggestions.ts @@ -7,6 +7,11 @@ import { optionalMetaOutputSchema, } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + formatMcpTable, + readPath, + type McpTableColumn, +} from "@/server/mcp/table"; import { DEFAULT_LANGUAGE_CODE, DEFAULT_LOCATION_CODE, @@ -16,6 +21,13 @@ import { projectIdSchema, } from "@/server/mcp/schemas"; +const SUGGESTION_COLUMNS: McpTableColumn[] = [ + { header: "keyword", value: (row) => readPath(row, "keyword") }, + { header: "position", value: (row) => readPath(row, "position") }, + { header: "volume", value: (row) => readPath(row, "searchVolume") }, + { header: "KD", value: (row) => readPath(row, "keywordDifficulty") }, +]; + const inputSchema = { projectId: projectIdSchema, domain: z @@ -57,15 +69,10 @@ export const getDomainKeywordSuggestionsTool = { }, context.billing, ); - const text = [ - `Top keywords for ${args.domain} (${keywords.length}):`, - ...keywords - .slice(0, 25) - .map( - (kw) => - `- "${kw.keyword}" #${kw.position ?? "?"} vol:${kw.searchVolume ?? "?"} kd:${kw.keywordDifficulty ?? "?"}`, - ), - ].join("\n"); + const text = + keywords.length === 0 + ? `No ranked keywords found for ${args.domain}.` + : `Keywords for ${args.domain} (${keywords.length}):\n${formatMcpTable(keywords, SUGGESTION_COLUMNS)}`; return mcpResponse({ text, meta: buildProjectMeta( diff --git a/src/server/mcp/tools/get-rank-tracker.ts b/src/server/mcp/tools/get-rank-tracker.ts index f2952eb..044a2c0 100644 --- a/src/server/mcp/tools/get-rank-tracker.ts +++ b/src/server/mcp/tools/get-rank-tracker.ts @@ -8,8 +8,27 @@ import { optionalMetaOutputSchema, } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + formatMcpTable, + readPath, + type McpTableColumn, +} from "@/server/mcp/table"; import { projectIdSchema } from "@/server/mcp/schemas"; +const RANK_RESULT_COLUMNS: McpTableColumn[] = [ + { header: "keyword", value: (row) => readPath(row, "keyword") }, + { header: "desktop", value: (row) => readPath(row, "desktop", "position") }, + { + header: "prev (desktop)", + value: (row) => readPath(row, "desktop", "previousPosition"), + }, + { header: "mobile", value: (row) => readPath(row, "mobile", "position") }, + { + header: "prev (mobile)", + value: (row) => readPath(row, "mobile", "previousPosition"), + }, +]; + const inputSchema = { projectId: projectIdSchema, trackerId: z @@ -85,12 +104,9 @@ export const getRankTrackerTool = { `Schedule: ${config.scheduleInterval}, devices: ${config.devices}, depth: ${config.serpDepth}`, `Latest run: ${results.run?.lastCheckedAt ?? "never"}`, `Keywords (${results.rows.length}):`, - ...results.rows - .slice(0, 25) - .map( - (r) => - `- "${r.keyword}" desktop:#${r.desktop.position ?? "-"} (was ${r.desktop.previousPosition ?? "-"}) mobile:#${r.mobile.position ?? "-"}`, - ), + results.rows.length === 0 + ? "No keywords tracked yet." + : formatMcpTable(results.rows, RANK_RESULT_COLUMNS), ].join("\n"); return mcpResponse({ text, diff --git a/src/server/mcp/tools/get-serp-results.ts b/src/server/mcp/tools/get-serp-results.ts index 5661d68..93e3a92 100644 --- a/src/server/mcp/tools/get-serp-results.ts +++ b/src/server/mcp/tools/get-serp-results.ts @@ -4,6 +4,7 @@ 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 { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; import { DEFAULT_LANGUAGE_CODE, DEFAULT_LOCATION_CODE, @@ -12,6 +13,22 @@ import { projectIdSchema, } from "@/server/mcp/schemas"; +type SerpItem = { + type?: string | null; + rank: number | null; + title: string | null; + url: string | null; + domain: string | null; + description: string | null; +}; + +const SERP_ITEM_COLUMNS: McpTableColumn[] = [ + { header: "rank", value: (item) => item.rank }, + { header: "domain", value: (item) => item.domain }, + { header: "title", value: (item) => item.title }, + { header: "url", value: (item) => item.url }, +]; + const querySchema = z.object({ keyword: z.string().min(1).describe("Search query to fetch the SERP for."), locationCode: locationCodeSchema.optional(), @@ -111,16 +128,13 @@ export const getSerpResultsTool = { const text = results .map((r) => { - if (r.ok) { - const top = r.items.slice(0, 3); - return `"${r.keyword}" (${r.items.length} results):\n${top - .map( - (it) => - ` #${it.rank ?? "?"} ${it.domain ?? "?"} — ${it.title ?? "?"}`, - ) - .join("\n")}`; + if (!r.ok) { + return `"${r.keyword}": FAILED — ${r.error}`; } - return `"${r.keyword}": FAILED — ${r.error}`; + if (r.items.length === 0) { + return `"${r.keyword}" (0 results)`; + } + return `"${r.keyword}" (${r.items.length} results):\n${formatMcpTable(r.items, SERP_ITEM_COLUMNS)}`; }) .join("\n\n") + `\n\n${okCount} of ${results.length} queries succeeded.`; diff --git a/src/server/mcp/tools/research-keywords.ts b/src/server/mcp/tools/research-keywords.ts index 268e0a1..ba963d3 100644 --- a/src/server/mcp/tools/research-keywords.ts +++ b/src/server/mcp/tools/research-keywords.ts @@ -7,6 +7,7 @@ import { optionalMetaOutputSchema, } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; import { DEFAULT_LANGUAGE_CODE, DEFAULT_LOCATION_CODE, @@ -44,6 +45,27 @@ const inputSchema = { type Args = z.infer>; +type ResearchRow = { + keyword: string; + searchVolume: number | null; + keywordDifficulty: number | null; + cpc: number | null; + competition: number | null; + intent: string; +}; + +// The full rows (including trend data) still ship in structuredContent; this +// table exists so MCP clients that surface only text content see every keyword +// and its metrics, not just the count summary. +const RESEARCH_COLUMNS: McpTableColumn[] = [ + { header: "keyword", value: (row) => row.keyword }, + { header: "volume", value: (row) => row.searchVolume }, + { header: "KD", value: (row) => row.keywordDifficulty }, + { header: "CPC", value: (row) => row.cpc }, + { header: "competition", value: (row) => row.competition }, + { header: "intent", value: (row) => row.intent }, +]; + export const researchKeywordsTool = { name: "research_keywords", config: { @@ -61,7 +83,7 @@ export const researchKeywordsTool = { rowCount: z.number(), source: z.string(), usedFallback: z.boolean(), - topRows: z.array(looseObjectOutputSchema), + rows: z.array(looseObjectOutputSchema), }) .passthrough(), z @@ -103,7 +125,7 @@ export const researchKeywordsTool = { rowCount: data.rows.length, source: data.source, usedFallback: data.usedFallback, - topRows: data.rows.slice(0, 20), + rows: data.rows, }; } catch (error) { return { @@ -120,13 +142,17 @@ export const researchKeywordsTool = { const text = results .map((r) => { - if (r.ok) { - return `- "${r.seed}": ${r.rowCount} keywords (source: ${r.source})`; + if (!r.ok) { + return `## "${r.seed}" — FAILED\n${r.error}`; } - return `- "${r.seed}": FAILED — ${r.error}`; + const header = `## "${r.seed}" — ${r.rowCount} keywords (source: ${r.source}${r.usedFallback ? ", fallback" : ""})`; + if (r.rowCount === 0) { + return `${header}\n(no keywords returned)`; + } + return `${header}\n${formatMcpTable(r.rows, RESEARCH_COLUMNS)}`; }) - .join("\n") + - `\n\nResearched ${okCount} of ${results.length} seeds${failCount > 0 ? ` (${failCount} failed)` : ""}.`; + .join("\n\n") + + `\n\nResearched ${okCount} of ${results.length} seeds${failCount > 0 ? ` (${failCount} failed)` : ""}. Columns: volume = monthly searches, KD = keyword difficulty (0-100), CPC in USD, competition = paid competition (0-1); "—" = unavailable.`; return mcpResponse({ text, diff --git a/src/server/mcp/tools/search-console-tools.test.ts b/src/server/mcp/tools/search-console-tools.test.ts index 4f0da1a..9ff8cd4 100644 --- a/src/server/mcp/tools/search-console-tools.test.ts +++ b/src/server/mcp/tools/search-console-tools.test.ts @@ -140,6 +140,12 @@ describe("search console MCP tools", () => { siteUrl: "https://example.com/", rowCount: 1, }); + const text = result.content?.[0]; + expect(text?.type === "text" && text.text).toContain( + "key | clicks | impressions | CTR | position", + ); + expect(text?.type === "text" && text.text).toContain("seo tools"); + expect(text?.type === "text" && text.text).toContain("4.0%"); }); it("surfaces a not-connected message with a connect URL", async () => { diff --git a/src/server/mcp/tools/search-console-tools.ts b/src/server/mcp/tools/search-console-tools.ts index cee8a65..6e737e9 100644 --- a/src/server/mcp/tools/search-console-tools.ts +++ b/src/server/mcp/tools/search-console-tools.ts @@ -4,6 +4,7 @@ import { buildProjectMeta } from "@/server/mcp/context"; import { mcpResponse } from "@/server/mcp/formatters"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; import { projectIdSchema } from "@/server/mcp/schemas"; import { buildDashboardUrl } from "@/server/mcp/urls"; import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config"; @@ -26,6 +27,31 @@ import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc"; const TEXT_SUMMARY_ROWS = 15; +type GscPerfRow = { + keys?: string[]; + clicks: number; + impressions: number; + ctr: number; + position: number; +}; + +const GSC_PERF_COLUMNS: McpTableColumn[] = [ + { header: "key", value: (row) => row.keys?.join(" / ") ?? "(total)" }, + { header: "clicks", value: (row) => row.clicks }, + { header: "impressions", value: (row) => row.impressions }, + { + header: "CTR", + value: (row) => row.ctr, + format: (value) => + typeof value === "number" ? `${(value * 100).toFixed(1)}%` : "—", + }, + { + header: "position", + value: (row) => row.position, + format: (value) => (typeof value === "number" ? value.toFixed(1) : "—"), + }, +]; + type ProjectAuthContext = { auth: { organizationId: string }; baseUrl: string; @@ -234,17 +260,12 @@ export const getSearchConsolePerformanceTool = { const hasMore = rows.length >= requestedLimit; const nextStartRow = (result.request.startRow ?? 0) + rows.length; - const summaryLines = rows.slice(0, TEXT_SUMMARY_ROWS).map((r) => { - const label = r.keys?.join(" / ") ?? "(total)"; - const ctrPct = (r.ctr * 100).toFixed(1); - return ` ${label} — ${r.clicks} clicks, ${r.impressions} impr, ${ctrPct}% CTR, pos ${r.position.toFixed(1)}`; - }); const header = `${result.siteUrl} · ${dimensions.join("+")} · ${result.request.startDate}→${result.request.endDate} · ` + `${rows.length} row${rows.length === 1 ? "" : "s"}${hasMore ? " (more available — paginate with startRow)" : ""}`; const text = - summaryLines.length > 0 - ? `${header}\n${summaryLines.join("\n")}${rows.length > summaryLines.length ? `\n …and ${rows.length - summaryLines.length} more` : ""}` + rows.length > 0 + ? `${header}\n${formatMcpTable(rows, GSC_PERF_COLUMNS)}` : `${header}\nNo rows for this query/date range.`; return mcpResponse({ diff --git a/src/server/mcp/tools/tool-text-output.test.ts b/src/server/mcp/tools/tool-text-output.test.ts new file mode 100644 index 0000000..6157a1f --- /dev/null +++ b/src/server/mcp/tools/tool-text-output.test.ts @@ -0,0 +1,327 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ToolExtra } from "@/server/mcp/context"; +import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; + +// Verifies that each tool renders its actual row data into the text content +// block (not just a count), across the tools whose data comes from OpenSEO +// services rather than the DataForSEO client. Guards against a column wired to +// the wrong field, which would render a table of only "—". + +const mocks = vi.hoisted(() => ({ + getProjectForOrganization: vi.fn(), + createDataforseoClient: vi.fn(), + research: vi.fn(), + profileOverview: vi.fn(), + profileReferringDomainsPage: vi.fn(), + profileBacklinksPage: vi.fn(), + getSuggestedKeywords: vi.fn(), + getConfigById: vi.fn(), + getConfigsForProject: vi.fn(), + getLatestResults: vi.fn(), +})); + +vi.mock("cloudflare:workers", () => ({ env: {} })); +vi.mock("@/server/lib/dataforseo", () => ({ + createDataforseoClient: mocks.createDataforseoClient, +})); +vi.mock("@/server/features/projects/services/ProjectService", () => ({ + ProjectService: { + getProjectForOrganization: mocks.getProjectForOrganization, + }, +})); +vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({ + KeywordResearchService: { research: mocks.research }, +})); +vi.mock("@/server/features/backlinks/services/BacklinksService", () => ({ + BacklinksService: { + profileOverview: mocks.profileOverview, + profileReferringDomainsPage: mocks.profileReferringDomainsPage, + profileBacklinksPage: mocks.profileBacklinksPage, + }, +})); +vi.mock("@/server/features/domain/services/DomainService", () => ({ + DomainService: { getSuggestedKeywords: mocks.getSuggestedKeywords }, +})); +vi.mock( + "@/server/features/rank-tracking/repositories/RankTrackingRepository", + () => ({ + RankTrackingRepository: { + getConfigById: mocks.getConfigById, + getConfigsForProject: mocks.getConfigsForProject, + }, + }), +); +vi.mock("@/server/features/rank-tracking/services/rankTrackingResults", () => ({ + getLatestResults: mocks.getLatestResults, +})); + +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, +}; + +function text(result: { content?: Array<{ type: string; text?: string }> }) { + const first = result.content?.[0]; + return first?.type === "text" ? (first.text ?? "") : ""; +} + +describe("MCP tool text output (service-backed tools)", () => { + beforeEach(() => { + vi.resetModules(); + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); + }); + + it("research_keywords renders every keyword row in the text table", async () => { + mocks.research.mockResolvedValue({ + rows: [ + { + keyword: "seo tools", + searchVolume: 2400, + keywordDifficulty: 18, + cpc: 3.25, + competition: 0.4, + intent: "commercial", + trend: [], + }, + { + keyword: "free seo tools", + searchVolume: 880, + keywordDifficulty: null, + cpc: null, + competition: null, + intent: "informational", + trend: [], + }, + ], + source: "related", + usedFallback: false, + }); + const { researchKeywordsTool } = await import("./research-keywords"); + + const result = await researchKeywordsTool.handler( + { projectId: "project_1", seeds: [{ seed: "seo tools" }] }, + toolExtra, + ); + + const out = text(result); + expect(out).toContain("keyword | volume | KD | CPC | competition | intent"); + expect(out).toContain("seo tools | 2400 | 18 | 3.25 | 0.40 | commercial"); + // Second row proves it isn't truncated and nulls render as em dashes. + expect(out).toContain("free seo tools | 880 | — | — | — | informational"); + }); + + it("get_domain_keyword_suggestions renders keyword rows", async () => { + mocks.getSuggestedKeywords.mockResolvedValue([ + { + keyword: "seo audit", + position: 4, + searchVolume: 880, + keywordDifficulty: 22, + }, + ]); + const { getDomainKeywordSuggestionsTool } = + await import("./get-domain-keyword-suggestions"); + + const result = await getDomainKeywordSuggestionsTool.handler( + { projectId: "project_1", domain: "example.com" }, + toolExtra, + ); + + const out = text(result); + expect(out).toContain("keyword | position | volume | KD"); + expect(out).toContain("seo audit | 4 | 880 | 22"); + }); + + it("get_backlinks_overview renders all referring-domain rows", async () => { + mocks.profileOverview.mockResolvedValue({ + overview: { + summary: { + backlinks: 1200, + referringDomains: 340, + referringPages: 900, + rank: 55, + }, + }, + }); + mocks.profileReferringDomainsPage.mockResolvedValue({ + rows: [ + { + domain: "linker.example", + backlinks: 42, + referringPages: 5, + rank: 30, + }, + ], + }); + const { getBacklinksOverviewTool } = + await import("./get-backlinks-overview"); + + const result = await getBacklinksOverviewTool.handler( + { projectId: "project_1", target: "example.com" }, + toolExtra, + ); + + const out = text(result); + expect(out).toContain("domain | backlinks | referring pages | rank"); + expect(out).toContain("linker.example | 42 | 5 | 30"); + }); + + it("get_backlinks_profile renders all backlink rows", async () => { + mocks.profileBacklinksPage.mockResolvedValue({ + rows: [ + { + urlFrom: "https://a.example/post", + domainFrom: "a.example", + urlTo: "https://target.example", + anchor: "click here", + isDofollow: true, + rank: 12, + domainFromRank: 40, + spamScore: 3, + isLost: false, + isBroken: false, + }, + ], + page: 1, + pageSize: 100, + totalCount: 1, + hasMore: false, + }); + const { getBacklinksProfileTool } = await import("./get-backlinks-profile"); + + const result = await getBacklinksProfileTool.handler( + { + projectId: "project_1", + target: "example.com", + page: 1, + pageSize: 100, + sortField: "rank", + sortOrder: "desc", + filters: {}, + mode: "one_per_domain", + }, + toolExtra, + ); + + const out = text(result); + expect(out).toContain( + "source | target | anchor | type | rank | domainRank | spam | status", + ); + expect(out).toContain("https://a.example/post"); + expect(out).toContain("click here"); + expect(out).toContain("dofollow"); + }); + + it("get_rank_tracker renders every tracked-keyword row (detail view)", async () => { + mocks.getConfigById.mockResolvedValue({ + id: "tracker_1", + domain: "example.com", + scheduleInterval: "daily", + devices: "desktop", + serpDepth: 20, + }); + mocks.getLatestResults.mockResolvedValue({ + run: { lastCheckedAt: "2026-07-01" }, + rows: [ + { + keyword: "seo tools", + desktop: { position: 3, previousPosition: 5 }, + mobile: { position: 7, previousPosition: null }, + }, + ], + }); + const { getRankTrackerTool } = await import("./get-rank-tracker"); + + const result = await getRankTrackerTool.handler( + { projectId: "project_1", trackerId: "tracker_1" }, + toolExtra, + ); + + const out = text(result); + expect(out).toContain( + "keyword | desktop | prev (desktop) | mobile | prev (mobile)", + ); + expect(out).toContain("seo tools | 3 | 5 | 7 | —"); + }); + + it("get_ranked_keywords renders nested provider rows as a text table", async () => { + const rankedKeywords = vi.fn().mockResolvedValue({ + items: [ + { + keyword_data: { + keyword: "seo tools", + keyword_info: { search_volume: 1000, cpc: 3.2 }, + }, + ranked_serp_element: { + serp_item: { rank_absolute: 4, url: "https://example.com/tools" }, + }, + }, + ], + totalCount: 1, + }); + mocks.createDataforseoClient.mockReturnValue({ + domain: { rankedKeywords }, + }); + const { getRankedKeywordsTool } = + await import("./dataforseo-research-tools"); + + const result = await getRankedKeywordsTool.handler( + { projectId: "project_1", target: "example.com" }, + toolExtra, + ); + + const out = text(result); + expect(out).toContain("keyword | rank | volume | CPC | url"); + expect(out).toContain( + "seo tools | 4 | 1000 | 3.20 | https://example.com/tools", + ); + }); + + it("get_serp_results renders each query's items as a text table", async () => { + const live = vi.fn().mockResolvedValue([ + { + type: "organic", + rank_absolute: 1, + title: "Best SEO Tools", + url: "https://example.com/best", + domain: "example.com", + description: "desc", + }, + ]); + mocks.createDataforseoClient.mockReturnValue({ serp: { live } }); + const { getSerpResultsTool } = await import("./get-serp-results"); + + const result = await getSerpResultsTool.handler( + { projectId: "project_1", queries: [{ keyword: "seo tools" }] }, + toolExtra, + ); + + const out = text(result); + expect(out).toContain("rank | domain | title | url"); + expect(out).toContain( + "1 | example.com | Best SEO Tools | https://example.com/best", + ); + }); +});