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:
Matt Van Horn 2026-06-28 15:47:24 -07:00 committed by GitHub
parent 200a6e8b61
commit 1dabbc88a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 419 additions and 3 deletions

View File

@ -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.",
},
],
},
{

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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(),