metatron-open-seo/src/server/mcp/tools/get-backlinks-overview.ts
Matt Van Horn 1dabbc88a6
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>
2026-06-28 18:47:24 -04:00

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