diff --git a/src/client/features/ai-mcp/AvailableTools.tsx b/src/client/features/ai-mcp/AvailableTools.tsx index df9447a..f33e0ee 100644 --- a/src/client/features/ai-mcp/AvailableTools.tsx +++ b/src/client/features/ai-mcp/AvailableTools.tsx @@ -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.", + }, ], }, { diff --git a/src/server/mcp/output-schemas.ts b/src/server/mcp/output-schemas.ts index 572ad79..acfa165 100644 --- a/src/server/mcp/output-schemas.ts +++ b/src/server/mcp/output-schemas.ts @@ -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; diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts index eb4911d..22d224c 100644 --- a/src/server/mcp/server.ts +++ b/src/server/mcp/server.ts @@ -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, diff --git a/src/server/mcp/tools/get-backlinks-overview.ts b/src/server/mcp/tools/get-backlinks-overview.ts index e50cf5c..535549c 100644 --- a/src/server/mcp/tools/get-backlinks-overview.ts +++ b/src/server/mcp/tools/get-backlinks-overview.ts @@ -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, diff --git a/src/server/mcp/tools/get-backlinks-profile.ts b/src/server/mcp/tools/get-backlinks-profile.ts new file mode 100644 index 0000000..c12faa1 --- /dev/null +++ b/src/server/mcp/tools/get-backlinks-profile.ts @@ -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>; + +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 }, + }); + }), +}; diff --git a/src/server/mcp/tools/output-schema-validation.test.ts b/src/server/mcp/tools/output-schema-validation.test.ts index e1f6700..3a185cc 100644 --- a/src/server/mcp/tools/output-schema-validation.test.ts +++ b/src/server/mcp/tools/output-schema-validation.test.ts @@ -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", + }); + }); }); diff --git a/src/types/schemas/backlinks.ts b/src/types/schemas/backlinks.ts index 08be167..477cc6e 100644 --- a/src/types/schemas/backlinks.ts +++ b/src/types/schemas/backlinks.ts @@ -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(),