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:
parent
752561b0ac
commit
b733be4a5e
@ -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
12
release-notes/v0.0.23.md
Normal 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
|
||||
95
src/server/mcp/table.test.ts
Normal file
95
src/server/mcp/table.test.ts
Normal 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
60
src/server/mcp/table.ts
Normal 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;
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.`;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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({
|
||||
|
||||
327
src/server/mcp/tools/tool-text-output.test.ts
Normal file
327
src/server/mcp/tools/tool-text-output.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user