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
This commit is contained in:
Ben Senescu 2026-07-02 13:01:44 -04:00 committed by GitHub
parent 752561b0ac
commit b733be4a5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 801 additions and 93 deletions

View File

@ -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": {

12
release-notes/v0.0.23.md Normal file
View File

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

View File

@ -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");
});
});

60
src/server/mcp/table.ts Normal file
View File

@ -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<T> = {
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<T>(
rows: readonly T[],
columns: readonly McpTableColumn<T>[],
): 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;
}

View File

@ -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 () => {

View File

@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asRecord(value: unknown): Record<string, unknown> | 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, unknown>): 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<string, unknown>[],
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<RankedKeywordRow>[] = [
{ 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<unknown>[] = [
{ 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<unknown>[] = [
{
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<unknown>[] = [
{ 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<unknown>[] = [
{ 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<unknown>[] = [
{ 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<string, unknown>),
),
].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,

View File

@ -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<unknown>[] = [
{ 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,

View File

@ -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<BacklinkRow>[] = [
{ 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({

View File

@ -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<unknown>[] = [
{ 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(

View File

@ -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<unknown>[] = [
{ 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,

View File

@ -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<SerpItem>[] = [
{ 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.`;

View File

@ -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<z.ZodObject<typeof inputSchema>>;
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<ResearchRow>[] = [
{ 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,

View File

@ -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 () => {

View File

@ -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<GscPerfRow>[] = [
{ 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({

View File

@ -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",
);
});
});