* 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>
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
import { z } from "zod";
|
|
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
|
import { mcpResponse } from "@/server/mcp/formatters";
|
|
import { buildProjectMeta } from "@/server/mcp/context";
|
|
import {
|
|
looseObjectOutputSchema,
|
|
optionalMetaOutputSchema,
|
|
} from "@/server/mcp/output-schemas";
|
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
|
import { projectIdSchema } from "@/server/mcp/schemas";
|
|
|
|
const inputSchema = {
|
|
projectId: projectIdSchema,
|
|
target: z
|
|
.string()
|
|
.min(1)
|
|
.describe(
|
|
"Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').",
|
|
),
|
|
scope: z
|
|
.enum(["domain", "page"])
|
|
.optional()
|
|
.describe(
|
|
"'domain' analyzes the whole domain; 'page' analyzes a specific URL. Defaults to 'domain'.",
|
|
),
|
|
hideSpam: z
|
|
.boolean()
|
|
.optional()
|
|
.describe("Filter out spammy referring domains. 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 : "?";
|
|
}
|
|
|
|
export const getBacklinksOverviewTool = {
|
|
name: "get_backlinks_overview",
|
|
config: {
|
|
title: "Get backlinks overview",
|
|
description:
|
|
"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,
|
|
referringDomains: looseObjectOutputSchema,
|
|
...optionalMetaOutputSchema,
|
|
},
|
|
annotations: {
|
|
readOnlyHint: false,
|
|
openWorldHint: false,
|
|
destructiveHint: false,
|
|
},
|
|
},
|
|
handler: withMcpProjectAuth(async (args: Args, context) => {
|
|
const lookup = { target: args.target, scope: args.scope };
|
|
const spamOptions = { hideSpam: args.hideSpam ?? true };
|
|
const [overview, refDomains] = await Promise.all([
|
|
BacklinksService.profileOverview(lookup, context.billing),
|
|
BacklinksService.profileReferringDomainsPage(
|
|
{
|
|
...lookup,
|
|
page: 1,
|
|
pageSize: 100,
|
|
sortField: "backlinks",
|
|
sortOrder: "desc",
|
|
filters: {},
|
|
},
|
|
context.billing,
|
|
spamOptions,
|
|
),
|
|
]);
|
|
const topDomains = refDomains.rows ?? [];
|
|
const summary = overview.overview.summary;
|
|
const text = [
|
|
`Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`,
|
|
`- backlinks: ${formatMetric(summary.backlinks)}`,
|
|
`- referring domains: ${formatMetric(summary.referringDomains)}`,
|
|
`- 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 ?? "?"}`),
|
|
].join("\n");
|
|
return mcpResponse({
|
|
text,
|
|
meta: buildProjectMeta(
|
|
context,
|
|
args.projectId,
|
|
`/p/${args.projectId}/backlinks`,
|
|
{ target: args.target },
|
|
),
|
|
structuredContent: { overview, referringDomains: refDomains },
|
|
});
|
|
}),
|
|
};
|