feat: add paginated get_backlinks_profile MCP tool (#38)
* feat: add paginated get_backlinks_profile MCP tool Exposes detailed per-link backlink rows over MCP, reusing BacklinksService paginated row fetching. Closes #36. * fix(test): use type-narrowing text assertion to satisfy oxlint expect.stringContaining inside toMatchObject tripped typescript-eslint(no-unsafe-assignment), breaking ci:check. Match the .toContain() pattern used elsewhere in the MCP tool tests. * refactor(mcp): simplify get_backlinks_profile handler + fix cost estimate - Drop the redundant backlinksRowsPageRequestSchema.parse re-validation in the handler; the MCP SDK already validates args against inputSchema. Build the service request straight from args, removing the duplicated defaults that could silently diverge. - Preserve the target length cap by adding .max(2048) to the input schema (previously enforced only via the re-parse). - Correct the credit estimate in the tool description: measured cost is ~30 credits/page, not the ~200-500 copied from get_backlinks_overview. - Minor: type the statuses accumulator. * fix(mcp): correct get_backlinks_overview credit estimate Measured real cost: summary (~26 credits) + history for domain scope (~29 credits) = ~50 per domain, ~25 for a single page. The previous ~200-500 figure was a ~5-10x overestimate (same one get_backlinks_profile inherited). --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Ben Senescu <bensenescu@gmail.com>
This commit is contained in:
parent
200a6e8b61
commit
1dabbc88a6
@ -74,6 +74,11 @@ const toolCategories: ToolCategory[] = [
|
||||
title: "Get backlinks overview",
|
||||
description: "Check backlink and referring-domain stats.",
|
||||
},
|
||||
{
|
||||
name: "get_backlinks_profile",
|
||||
title: "Get backlinks profile",
|
||||
description: "Fetch paginated link-level backlink rows.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -19,6 +19,17 @@ const mcpMetaOutputSchema = z
|
||||
// shape, so it validates both plain rows and typed instances.
|
||||
export const looseObjectOutputSchema = z.object({}).passthrough();
|
||||
|
||||
export const backlinksProfileOutputSchema = z
|
||||
.object({
|
||||
rows: z.array(looseObjectOutputSchema),
|
||||
totalCount: z.number().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
fetchedAt: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const optionalMetaOutputSchema = {
|
||||
meta: mcpMetaOutputSchema.optional(),
|
||||
} as const;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
|
||||
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
|
||||
import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile";
|
||||
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
|
||||
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
|
||||
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
|
||||
@ -101,6 +102,15 @@ export function registerOpenSeoMcpTools(server: McpServer) {
|
||||
getBacklinksOverviewTool.handler,
|
||||
),
|
||||
);
|
||||
server.registerTool(
|
||||
getBacklinksProfileTool.name,
|
||||
getBacklinksProfileTool.config,
|
||||
instrumentMcpToolHandler(
|
||||
getBacklinksProfileTool.name,
|
||||
getBacklinksProfileTool.config.outputSchema,
|
||||
getBacklinksProfileTool.handler,
|
||||
),
|
||||
);
|
||||
server.registerTool(
|
||||
getSerpResultsTool.name,
|
||||
getSerpResultsTool.config,
|
||||
|
||||
@ -40,7 +40,7 @@ export const getBacklinksOverviewTool = {
|
||||
config: {
|
||||
title: "Get backlinks overview",
|
||||
description:
|
||||
"Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~200-500 typical). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
|
||||
"Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~50 typical for a domain, ~25 for a single page). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
|
||||
inputSchema,
|
||||
outputSchema: {
|
||||
overview: looseObjectOutputSchema,
|
||||
|
||||
170
src/server/mcp/tools/get-backlinks-profile.ts
Normal file
170
src/server/mcp/tools/get-backlinks-profile.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { z } from "zod";
|
||||
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
||||
import { buildProjectMeta } from "@/server/mcp/context";
|
||||
import { mcpResponse } from "@/server/mcp/formatters";
|
||||
import {
|
||||
backlinksProfileOutputSchema,
|
||||
optionalMetaOutputSchema,
|
||||
} from "@/server/mcp/output-schemas";
|
||||
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||
import { projectIdSchema } from "@/server/mcp/schemas";
|
||||
import {
|
||||
BACKLINKS_DEFAULT_SORT,
|
||||
BACKLINKS_PAGE_SIZES,
|
||||
DEFAULT_BACKLINKS_PAGE_SIZE,
|
||||
backlinksRowsFiltersSchema,
|
||||
backlinksRowsModeSchema,
|
||||
backlinksRowsSortFieldSchema,
|
||||
backlinksSortOrderSchema,
|
||||
backlinksTargetScopeSchema,
|
||||
} from "@/types/schemas/backlinks";
|
||||
|
||||
const inputSchema = {
|
||||
projectId: projectIdSchema,
|
||||
target: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.describe(
|
||||
"Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').",
|
||||
),
|
||||
scope: backlinksTargetScopeSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"'domain' analyzes the whole domain; 'page' analyzes a specific URL. Defaults to 'domain'.",
|
||||
),
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(1)
|
||||
.describe("1-indexed results page. Defaults to 1."),
|
||||
pageSize: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) =>
|
||||
(BACKLINKS_PAGE_SIZES as readonly number[]).includes(value),
|
||||
)
|
||||
.default(DEFAULT_BACKLINKS_PAGE_SIZE)
|
||||
.describe("Rows per page. Allowed values: 50, 100, or 200."),
|
||||
sortField: backlinksRowsSortFieldSchema
|
||||
.default(BACKLINKS_DEFAULT_SORT.backlinks.field)
|
||||
.describe("Backlink row sort field."),
|
||||
sortOrder: backlinksSortOrderSchema
|
||||
.default(BACKLINKS_DEFAULT_SORT.backlinks.order)
|
||||
.describe("Sort direction."),
|
||||
filters: backlinksRowsFiltersSchema
|
||||
.default({})
|
||||
.describe(
|
||||
"Backlink row filters: include/exclude source URL terms, authority/spam ranges, dofollow/nofollow, lost/broken visibility, or exact domainFrom.",
|
||||
),
|
||||
mode: backlinksRowsModeSchema
|
||||
.default("one_per_domain")
|
||||
.describe(
|
||||
"DataForSEO backlink grouping: one_per_domain returns each referring domain's strongest link; as_is returns individual backlink rows.",
|
||||
),
|
||||
hideSpam: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Filter out spammy backlinks. Defaults to true."),
|
||||
} as const;
|
||||
|
||||
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
|
||||
|
||||
function formatMetric(value: unknown) {
|
||||
return typeof value === "number" || typeof value === "string" ? value : "?";
|
||||
}
|
||||
|
||||
function formatLinkType(value: boolean | null | undefined) {
|
||||
if (value === true) return "dofollow";
|
||||
if (value === false) return "nofollow";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatStatus(row: {
|
||||
isLost?: boolean | null;
|
||||
isBroken?: boolean | null;
|
||||
}) {
|
||||
const statuses: string[] = [];
|
||||
if (row.isLost) statuses.push("lost");
|
||||
if (row.isBroken) statuses.push("broken");
|
||||
return statuses.length > 0 ? statuses.join(", ") : "live";
|
||||
}
|
||||
|
||||
function formatBacklinkRow(row: {
|
||||
domainFrom?: string | null;
|
||||
urlFrom?: string | null;
|
||||
urlTo?: string | null;
|
||||
anchor?: string | null;
|
||||
isDofollow?: boolean | null;
|
||||
rank?: number | null;
|
||||
domainFromRank?: number | null;
|
||||
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)}`;
|
||||
}
|
||||
|
||||
export const getBacklinksProfileTool = {
|
||||
name: "get_backlinks_profile",
|
||||
config: {
|
||||
title: "Get backlinks profile",
|
||||
description:
|
||||
"Returns one bounded page of detailed backlink rows for a domain or page: linking URLs, target URLs, anchors, dofollow/nofollow, authority/spam signals, and lost/broken status. Supports filters, sorting, one_per_domain/as_is mode, and pagination. Charges credits (~30 per page typical). Self-hosted deployments need the Backlinks API enabled on their DataForSEO account.",
|
||||
inputSchema,
|
||||
outputSchema: {
|
||||
backlinks: backlinksProfileOutputSchema,
|
||||
...optionalMetaOutputSchema,
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
openWorldHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
},
|
||||
handler: withMcpProjectAuth(async (args: Args, context) => {
|
||||
// The MCP SDK already validated args against inputSchema (which mirrors
|
||||
// backlinksRowsPageRequestSchema), so pass them straight through.
|
||||
const request = {
|
||||
target: args.target,
|
||||
scope: args.scope,
|
||||
page: args.page,
|
||||
pageSize: args.pageSize,
|
||||
sortField: args.sortField,
|
||||
sortOrder: args.sortOrder,
|
||||
filters: args.filters,
|
||||
mode: args.mode,
|
||||
};
|
||||
|
||||
const backlinks = await BacklinksService.profileBacklinksPage(
|
||||
request,
|
||||
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}`,
|
||||
`- page size: ${backlinks.pageSize}`,
|
||||
`- rows returned: ${backlinks.rows.length}`,
|
||||
`- 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),
|
||||
].join("\n");
|
||||
|
||||
return mcpResponse({
|
||||
text,
|
||||
meta: buildProjectMeta(
|
||||
context,
|
||||
args.projectId,
|
||||
`/p/${args.projectId}/backlinks`,
|
||||
{ target: request.target, scope: request.scope },
|
||||
),
|
||||
structuredContent: { backlinks },
|
||||
});
|
||||
}),
|
||||
};
|
||||
@ -2,12 +2,32 @@ import {
|
||||
normalizeObjectSchema,
|
||||
safeParseAsync,
|
||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import type { ToolExtra } from "@/server/mcp/context";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProjectForOrganization: vi.fn(),
|
||||
profileBacklinksPage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({
|
||||
env: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||
ProjectService: {
|
||||
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/server/features/backlinks/services/BacklinksService", () => ({
|
||||
BacklinksService: {
|
||||
profileBacklinksPage: mocks.profileBacklinksPage,
|
||||
},
|
||||
}));
|
||||
|
||||
// A class instance reproduces what the DataForSEO SDK hands the tools: an
|
||||
// object whose prototype is not Object.prototype (e.g.
|
||||
// DataforseoLabsSerpCompetitorsLiveItem). Zod 4's z.record() rejects those
|
||||
@ -21,6 +41,67 @@ class ProviderRow {
|
||||
) {}
|
||||
}
|
||||
|
||||
const authContext = {
|
||||
userId: "user_123",
|
||||
userEmail: "team@example.com",
|
||||
organizationId: "org_123",
|
||||
clientId: "client_123",
|
||||
scopes: ["mcp"],
|
||||
audience: "open-seo",
|
||||
subject: "user_123",
|
||||
baseUrl: "https://app.example.com",
|
||||
};
|
||||
|
||||
const authExtra: 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://app.example.com/mcp"),
|
||||
extra: {
|
||||
[MCP_AUTH_CONTEXT_PROP]: authContext,
|
||||
},
|
||||
} satisfies AuthInfo,
|
||||
};
|
||||
|
||||
const backlinkPage = {
|
||||
rows: [
|
||||
{
|
||||
domainFrom: "source.example",
|
||||
urlFrom: "https://source.example/post",
|
||||
urlTo: "https://example.com/",
|
||||
anchor: "Example",
|
||||
itemType: "content",
|
||||
isDofollow: true,
|
||||
relAttributes: ["noopener"],
|
||||
rank: 77,
|
||||
domainFromRank: 65,
|
||||
pageFromRank: 54,
|
||||
spamScore: 3,
|
||||
firstSeen: "2026-01-01",
|
||||
lastSeen: "2026-03-01",
|
||||
isLost: false,
|
||||
isBroken: false,
|
||||
linksCount: 1,
|
||||
},
|
||||
],
|
||||
totalCount: 450,
|
||||
hasMore: true,
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
fetchedAt: "2026-06-25T00:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.getProjectForOrganization.mockReset();
|
||||
mocks.profileBacklinksPage.mockReset();
|
||||
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_123" });
|
||||
});
|
||||
|
||||
describe("DataForSEO research tool output schemas", () => {
|
||||
// Every tool that streams provider rows straight to structuredContent.
|
||||
it.each([
|
||||
@ -50,4 +131,143 @@ describe("DataForSEO research tool output schemas", () => {
|
||||
expect(result.success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("get_backlinks_profile accepts a paginated backlinks profile payload", async () => {
|
||||
const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
|
||||
const schema = normalizeObjectSchema(
|
||||
getBacklinksProfileTool.config.outputSchema,
|
||||
);
|
||||
if (!schema) throw new Error("output schema did not normalize");
|
||||
|
||||
const result = await safeParseAsync(schema, {
|
||||
backlinks: backlinkPage,
|
||||
meta: {
|
||||
organizationId: "org_123",
|
||||
projectId: "project_123",
|
||||
url: "https://app.example.com/p/project_123/backlinks",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_backlinks_profile MCP tool", () => {
|
||||
it("returns paginated backlink rows and honors filters, sorting, and mode", async () => {
|
||||
mocks.profileBacklinksPage.mockResolvedValue(backlinkPage);
|
||||
const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
|
||||
|
||||
const result = await getBacklinksProfileTool.handler(
|
||||
{
|
||||
projectId: "project_123",
|
||||
target: "example.com",
|
||||
scope: "domain",
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
sortField: "spamScore",
|
||||
sortOrder: "asc",
|
||||
filters: {
|
||||
include: "blog",
|
||||
linkType: "nofollow",
|
||||
hideLost: true,
|
||||
},
|
||||
mode: "as_is",
|
||||
hideSpam: false,
|
||||
},
|
||||
authExtra,
|
||||
);
|
||||
|
||||
expect(mocks.profileBacklinksPage).toHaveBeenCalledWith(
|
||||
{
|
||||
target: "example.com",
|
||||
scope: "domain",
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
sortField: "spamScore",
|
||||
sortOrder: "asc",
|
||||
filters: {
|
||||
include: "blog",
|
||||
linkType: "nofollow",
|
||||
hideLost: true,
|
||||
},
|
||||
mode: "as_is",
|
||||
},
|
||||
{
|
||||
userId: "user_123",
|
||||
userEmail: "team@example.com",
|
||||
organizationId: "org_123",
|
||||
projectId: "project_123",
|
||||
},
|
||||
{ hideSpam: false },
|
||||
);
|
||||
expect(result.structuredContent?.backlinks).toEqual(backlinkPage);
|
||||
const first = result.content[0];
|
||||
expect(first.type === "text" && first.text).toContain("- has more: yes");
|
||||
});
|
||||
|
||||
it("passes through final-page pagination state", async () => {
|
||||
const finalPage = {
|
||||
...backlinkPage,
|
||||
totalCount: 51,
|
||||
hasMore: false,
|
||||
page: 2,
|
||||
};
|
||||
mocks.profileBacklinksPage.mockResolvedValue(finalPage);
|
||||
const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
|
||||
|
||||
const result = await getBacklinksProfileTool.handler(
|
||||
{
|
||||
projectId: "project_123",
|
||||
target: "example.com",
|
||||
scope: "domain",
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
sortField: "rank",
|
||||
sortOrder: "desc",
|
||||
filters: {},
|
||||
mode: "one_per_domain",
|
||||
hideSpam: true,
|
||||
},
|
||||
authExtra,
|
||||
);
|
||||
|
||||
expect(result.structuredContent?.backlinks).toMatchObject({
|
||||
totalCount: 51,
|
||||
hasMore: false,
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Backlinks API access and credit errors", async () => {
|
||||
const { AppError } = await import("@/server/lib/errors");
|
||||
const error = new AppError(
|
||||
"BACKLINKS_BILLING_ISSUE",
|
||||
"The connected DataForSEO account has a billing or balance issue",
|
||||
);
|
||||
mocks.profileBacklinksPage.mockRejectedValue(error);
|
||||
const { getBacklinksProfileTool } = await import("./get-backlinks-profile");
|
||||
|
||||
await expect(
|
||||
getBacklinksProfileTool.handler(
|
||||
{
|
||||
projectId: "project_123",
|
||||
target: "example.com",
|
||||
scope: "domain",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
sortField: "rank",
|
||||
sortOrder: "desc",
|
||||
filters: {},
|
||||
mode: "one_per_domain",
|
||||
hideSpam: true,
|
||||
},
|
||||
authExtra,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "BACKLINKS_BILLING_ISSUE",
|
||||
message:
|
||||
"The connected DataForSEO account has a billing or balance issue",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -88,7 +88,7 @@ export const backlinksRowsFiltersSchema = z.object({
|
||||
* each referring domain's strongest link (the default, denoised view);
|
||||
* `as_is` returns every individual backlink.
|
||||
*/
|
||||
const backlinksRowsModeSchema = z.enum(["one_per_domain", "as_is"]);
|
||||
export const backlinksRowsModeSchema = z.enum(["one_per_domain", "as_is"]);
|
||||
|
||||
export const referringDomainsFiltersSchema = z.object({
|
||||
include: z.string().optional(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user