feat: add OpenSEO MCP tools (#161)

This commit is contained in:
Ben Senescu 2026-05-08 00:34:29 -04:00 committed by GitHub
parent 0ff7bc96b2
commit 57790b930f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1122 additions and 49 deletions

View File

@ -1,19 +1,35 @@
import { getMcpAuthContext } from "agents/mcp"; import { getMcpAuthContext } from "agents/mcp";
import { z } from "zod"; import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { buildDashboardUrl } from "@/server/mcp/urls";
type McpAuth = {
userId: string;
userEmail: string;
organizationId: string;
scopes: string[];
clientId: string | null;
audience: string;
subject: string;
};
export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth"; export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth";
const mcpToolAuthContextSchema = z.object({ const mcpToolAuthContextSchema = z.object({
userId: z.string().min(1), userId: z.string().min(1),
userEmail: z.string().min(1),
organizationId: z.string().min(1), organizationId: z.string().min(1),
clientId: z.string().nullable(), clientId: z.string().nullable(),
scopes: z.array(z.string()), scopes: z.array(z.string()),
audience: z.string().min(1), audience: z.string().min(1),
subject: z.string().min(1), subject: z.string().min(1),
baseUrl: z.string().url(),
}); });
type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>; type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>;
export type ToolExtra = unknown;
export function requireMcpToolAuthContext(): McpToolAuthContext { export function requireMcpToolAuthContext(): McpToolAuthContext {
const rawContext = getMcpAuthContext()?.props[MCP_AUTH_CONTEXT_PROP]; const rawContext = getMcpAuthContext()?.props[MCP_AUTH_CONTEXT_PROP];
const result = mcpToolAuthContextSchema.safeParse(rawContext); const result = mcpToolAuthContextSchema.safeParse(rawContext);
@ -24,3 +40,37 @@ export function requireMcpToolAuthContext(): McpToolAuthContext {
return result.data; return result.data;
} }
export function getAuth(_extra?: ToolExtra): McpAuth {
const { baseUrl: _baseUrl, ...auth } = requireMcpToolAuthContext();
return auth;
}
export function getBaseUrl(_extra?: ToolExtra): string {
return requireMcpToolAuthContext().baseUrl;
}
export function buildBillingCustomer(
auth: McpAuth,
projectId: string,
): BillingCustomerContext {
return {
userId: auth.userId,
userEmail: auth.userEmail,
organizationId: auth.organizationId,
projectId,
};
}
export function buildProjectMeta(
context: { auth: Pick<McpAuth, "organizationId">; baseUrl: string },
projectId: string,
path?: string,
params?: Record<string, string | number | undefined>,
) {
return {
organizationId: context.auth.organizationId,
projectId,
url: path ? buildDashboardUrl(context.baseUrl, path, params) : undefined,
};
}

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { mcpResponse } from "./formatters";
describe("mcpResponse", () => {
it("returns content as a text block", () => {
const result = mcpResponse({ text: "hi" });
expect(result.content).toEqual([{ type: "text", text: "hi" }]);
});
it("includes _meta only when meta is provided", () => {
const bare = mcpResponse({ text: "hi" });
expect(bare._meta).toBeUndefined();
const withMeta = mcpResponse({
text: "hi",
meta: { url: "https://app.openseo.so/p/1", projectId: "1" },
});
expect(withMeta._meta).toEqual({
url: "https://app.openseo.so/p/1",
projectId: "1",
});
});
it("drops undefined meta keys", () => {
const result = mcpResponse({
text: "hi",
meta: {
url: "https://app.openseo.so",
organizationId: undefined,
creditsCharged: 0,
},
});
expect(result._meta).toEqual({
url: "https://app.openseo.so",
creditsCharged: 0,
});
expect(result._meta).not.toHaveProperty("organizationId");
});
it("attaches structuredContent when provided", () => {
const result = mcpResponse({
text: "hi",
structuredContent: { foo: "bar" },
});
expect(result.structuredContent).toEqual({ foo: "bar" });
});
});

View File

@ -0,0 +1,34 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
type McpResponseMeta = {
url?: string;
organizationId?: string;
projectId?: string;
runId?: string;
creditsCharged?: number;
creditsRemaining?: number;
};
export function mcpResponse(opts: {
text: string;
meta?: McpResponseMeta;
structuredContent?: Record<string, unknown>;
}): CallToolResult {
const result: CallToolResult = {
content: [{ type: "text", text: opts.text }],
};
if (opts.structuredContent) {
result.structuredContent = opts.structuredContent;
}
if (opts.meta) {
// Drop undefined keys so the wire payload stays clean.
const meta: Record<string, unknown> = {};
for (const [key, value] of Object.entries(opts.meta)) {
if (value !== undefined) meta[key] = value;
}
if (Object.keys(meta).length > 0) {
result._meta = meta;
}
}
return result;
}

View File

@ -8,6 +8,10 @@ const verifyMocks = vi.hoisted(() => ({
verifyJwsAccessToken: vi.fn(), verifyJwsAccessToken: vi.fn(),
})); }));
const userEmailMocks = vi.hoisted(() => ({
getMcpUserEmail: vi.fn(),
}));
const serverMocks = vi.hoisted(() => ({ const serverMocks = vi.hoisted(() => ({
nextServerId: 0, nextServerId: 0,
createdServerIds: [] as number[], createdServerIds: [] as number[],
@ -34,6 +38,10 @@ vi.mock("@/server/mcp/server", () => ({
}, },
})); }));
vi.mock("@/server/mcp/user-email", () => ({
getMcpUserEmail: userEmailMocks.getMcpUserEmail,
}));
vi.mock("agents/mcp", () => ({ vi.mock("agents/mcp", () => ({
createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => { createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => {
return async () => return async () =>
@ -110,6 +118,7 @@ describe("handleMcpRequest", () => {
verifyMocks.verifyJwsAccessToken.mockResolvedValue( verifyMocks.verifyJwsAccessToken.mockResolvedValue(
createAccessTokenPayload(), createAccessTokenPayload(),
); );
userEmailMocks.getMcpUserEmail.mockResolvedValue("alice@example.com");
}); });
it("accepts access tokens verified by Better Auth", async () => { it("accepts access tokens verified by Better Auth", async () => {
@ -129,9 +138,13 @@ describe("handleMcpRequest", () => {
body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP], body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP],
).toMatchObject({ ).toMatchObject({
userId: "user_123", userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123", organizationId: "org_123",
clientId: "client_123", clientId: "client_123",
scopes: ["offline_access", "mcp"], scopes: ["offline_access", "mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
}); });
expect(body.options.route).toBe("/mcp"); expect(body.options.route).toBe("/mcp");
expect(body.options.enableJsonResponse).toBe(true); expect(body.options.enableJsonResponse).toBe(true);
@ -233,6 +246,21 @@ describe("handleMcpRequest", () => {
expect(response.status).toBe(403); expect(response.status).toBe(403);
}); });
it("returns 403 when the verified user is not found", async () => {
const { handleMcpRequest } = await import("@/server/mcp/handler");
userEmailMocks.getMcpUserEmail.mockResolvedValue(null);
const response = await handleMcpRequest(
createMcpRequest(jwtShapedToken),
{
AUTH_MODE: "hosted",
},
ctx,
);
expect(response.status).toBe(403);
});
it("returns 401 when the token is missing the required mcp scope", async () => { it("returns 401 when the token is missing the required mcp scope", async () => {
const { handleMcpRequest } = await import("@/server/mcp/handler"); const { handleMcpRequest } = await import("@/server/mcp/handler");
verifyMocks.verifyJwsAccessToken.mockResolvedValue( verifyMocks.verifyJwsAccessToken.mockResolvedValue(

View File

@ -11,6 +11,7 @@ import {
} from "@/lib/oauth-resource"; } from "@/lib/oauth-resource";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
import { createOpenSeoMcpServer } from "@/server/mcp/server"; import { createOpenSeoMcpServer } from "@/server/mcp/server";
import { getMcpUserEmail } from "@/server/mcp/user-email";
// MCP request flow: // MCP request flow:
// 1. Resource (`resource=<mcp>`) is injected into /oauth2/token requests by // 1. Resource (`resource=<mcp>`) is injected into /oauth2/token requests by
@ -24,7 +25,6 @@ import { createOpenSeoMcpServer } from "@/server/mcp/server";
// 3. We expect `iss = baseURL + basePath` (basePath defaults to `/api/auth`) // 3. We expect `iss = baseURL + basePath` (basePath defaults to `/api/auth`)
// and `aud = mcpResource`, both confirmed against the published // and `aud = mcpResource`, both confirmed against the published
// /.well-known/oauth-authorization-server metadata. // /.well-known/oauth-authorization-server metadata.
export const MCP_ROUTE = "/mcp"; export const MCP_ROUTE = "/mcp";
type McpAccessTokenPayload = JWTPayload & { type McpAccessTokenPayload = JWTPayload & {
@ -131,6 +131,17 @@ export async function handleMcpRequest(
); );
} }
const userEmail = await getMcpUserEmail(userId);
if (!userEmail) {
return new Response("MCP user context required", {
status: 403,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "WWW-Authenticate",
},
});
}
return createMcpHandler(server, { return createMcpHandler(server, {
route: MCP_ROUTE, route: MCP_ROUTE,
enableJsonResponse: true, enableJsonResponse: true,
@ -138,11 +149,13 @@ export async function handleMcpRequest(
props: { props: {
[MCP_AUTH_CONTEXT_PROP]: { [MCP_AUTH_CONTEXT_PROP]: {
userId, userId,
userEmail,
organizationId, organizationId,
clientId, clientId,
scopes, scopes,
audience: mcpResource, audience: mcpResource,
subject: userId, subject: userId,
baseUrl,
}, },
}, },
}, },

View File

@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
const mocks = vi.hoisted(() => ({
getMcpAuthContext: vi.fn(),
getProjectForOrganization: vi.fn(),
}));
vi.mock("agents/mcp", () => ({
getMcpAuthContext: mocks.getMcpAuthContext,
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
const authContext = {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
};
describe("withMcpProjectAuth", () => {
beforeEach(() => {
vi.resetModules();
mocks.getMcpAuthContext.mockReset();
mocks.getProjectForOrganization.mockReset();
mocks.getMcpAuthContext.mockReturnValue({
props: { [MCP_AUTH_CONTEXT_PROP]: authContext },
});
});
it("checks project access for the authenticated organization", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn().mockResolvedValue("ok");
const wrapped = withMcpProjectAuth(handler);
await expect(
wrapped({ projectId: "project_123" }, undefined),
).resolves.toBe("ok");
expect(mocks.getProjectForOrganization).toHaveBeenCalledWith(
"org_123",
"project_123",
);
});
it("passes auth, baseUrl, and billing context to the wrapped handler", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn().mockReturnValue("ok");
const wrapped = withMcpProjectAuth(handler);
await wrapped({ projectId: "project_123" }, undefined);
expect(handler).toHaveBeenCalledWith(
{ projectId: "project_123" },
{
auth: {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
},
baseUrl: "https://open-seo.test",
billing: {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
projectId: "project_123",
},
},
);
});
it("propagates project access failures without calling the wrapped handler", async () => {
const error = new Error("project not found");
mocks.getProjectForOrganization.mockRejectedValue(error);
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn();
const wrapped = withMcpProjectAuth(handler);
await expect(wrapped({ projectId: "project_123" }, undefined)).rejects.toBe(
error,
);
expect(handler).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,40 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import {
buildBillingCustomer,
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
type ProjectScopedArgs = {
projectId: string;
};
async function requireProjectAccess(_extra: ToolExtra, projectId: string) {
const { baseUrl, ...auth } = requireMcpToolAuthContext();
// This lookup enforces that the project belongs to the authenticated org.
await ProjectService.getProjectForOrganization(
auth.organizationId,
projectId,
);
return {
auth,
baseUrl,
billing: buildBillingCustomer(auth, projectId),
};
}
type McpProjectAuthContext = Awaited<ReturnType<typeof requireProjectAccess>>;
export function withMcpProjectAuth<TArgs extends ProjectScopedArgs, TResult>(
handler: (
args: TArgs,
context: McpProjectAuthContext,
) => Promise<TResult> | TResult,
) {
return async (args: TArgs, extra: ToolExtra) => {
const context = await requireProjectAccess(extra, args.projectId);
return handler(args, context);
};
}

24
src/server/mcp/schemas.ts Normal file
View File

@ -0,0 +1,24 @@
import { z } from "zod";
export const DEFAULT_LOCATION_CODE = 2840;
export const DEFAULT_LANGUAGE_CODE = "en";
export const projectIdSchema = z
.string()
.min(1)
.describe(
"Required. The OpenSEO project ID to scope this call to. Get one from list_projects.",
);
export const locationCodeSchema = z
.number()
.int()
.positive()
.describe(
"DataForSEO location code. Defaults to 2840 (United States). See dataforseo.com/help-center/locations.",
);
export const languageCodeSchema = z
.string()
.min(2)
.describe("Language code (e.g. 'en', 'es', 'fr'). Defaults to 'en'.");

View File

@ -1,18 +1,14 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
import { requireMcpToolAuthContext } from "@/server/mcp/context"; import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
function jsonToolResult(data: Record<string, unknown>) { import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
return { import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results";
structuredContent: data, import { listProjectsTool } from "@/server/mcp/tools/list-projects";
content: [ import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
{ import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
type: "text" as const, import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
text: JSON.stringify(data, null, 2), import { whoamiTool } from "@/server/mcp/tools/whoami";
},
],
};
}
export function createOpenSeoMcpServer() { export function createOpenSeoMcpServer() {
const server = new McpServer({ const server = new McpServer({
@ -20,43 +16,51 @@ export function createOpenSeoMcpServer() {
version: "0.0.10", version: "0.0.10",
}); });
server.registerTool(whoamiTool.name, whoamiTool.config, whoamiTool.handler);
server.registerTool( server.registerTool(
"whoami", listProjectsTool.name,
{ listProjectsTool.config,
title: "Who am I", listProjectsTool.handler,
description: "Return the verified OpenSEO user and organization context.",
},
async () => {
const auth = requireMcpToolAuthContext();
return jsonToolResult({
userId: auth.userId,
activeOrganizationId: auth.organizationId,
account: {
clientId: auth.clientId,
scopes: auth.scopes,
audience: auth.audience,
subject: auth.subject,
},
});
},
); );
server.registerTool( server.registerTool(
"list_projects", listSavedKeywordsTool.name,
{ listSavedKeywordsTool.config,
title: "List projects", listSavedKeywordsTool.handler,
description: "List projects in the verified OpenSEO organization.", );
}, server.registerTool(
async () => { researchKeywordsTool.name,
const auth = requireMcpToolAuthContext(); researchKeywordsTool.config,
const projects = await ProjectService.listProjects(auth.organizationId); researchKeywordsTool.handler,
);
return jsonToolResult({ server.registerTool(
activeOrganizationId: auth.organizationId, saveKeywordsTool.name,
projects, saveKeywordsTool.config,
}); saveKeywordsTool.handler,
}, );
server.registerTool(
getDomainOverviewTool.name,
getDomainOverviewTool.config,
getDomainOverviewTool.handler,
);
server.registerTool(
getDomainKeywordSuggestionsTool.name,
getDomainKeywordSuggestionsTool.config,
getDomainKeywordSuggestionsTool.handler,
);
server.registerTool(
getBacklinksOverviewTool.name,
getBacklinksOverviewTool.config,
getBacklinksOverviewTool.handler,
);
server.registerTool(
getSerpResultsTool.name,
getSerpResultsTool.config,
getSerpResultsTool.handler,
);
server.registerTool(
getRankTrackerTool.name,
getRankTrackerTool.config,
getRankTrackerTool.handler,
); );
return server; return server;

View File

@ -0,0 +1,81 @@
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 { 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 (~200-500 typical). Requires that the user's DataForSEO account has Backlinks enabled.",
inputSchema,
},
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, spamOptions),
BacklinksService.profileReferringDomains(
lookup,
context.billing,
spamOptions,
),
]);
const topDomains = refDomains.rows ?? [];
const overviewRecord =
overview && typeof overview === "object"
? (overview as Record<string, unknown>)
: {};
const text = [
`Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`,
`- backlinks: ${formatMetric(overviewRecord.backlinks)}`,
`- referring domains: ${formatMetric(overviewRecord.referring_domains)}`,
`- referring pages: ${formatMetric(overviewRecord.referring_pages)}`,
`- rank: ${formatMetric(overviewRecord.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 },
});
}),
};

View File

@ -0,0 +1,67 @@
import { z } from "zod";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
domain: z
.string()
.min(1)
.describe("Competitor or reference domain to extract keywords from."),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const getDomainKeywordSuggestionsTool = {
name: "get_domain_keyword_suggestions",
config: {
title: "Get domain keyword opportunities",
description:
"Returns the organic keywords a domain ranks for, including position and available metrics. Use after get_domain_overview when you want the detailed keyword opportunity list for a competitor or reference domain. Charges credits (~100-300 typical). Cached for 12 hours.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const keywords = await DomainService.getSuggestedKeywords(
{
domain: args.domain,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
organizationId: context.auth.organizationId,
projectId: args.projectId,
},
context.billing,
);
const text = [
`Top keywords for ${args.domain} (${keywords.length}):`,
...keywords
.slice(0, 25)
.map(
(kw) =>
`- "${kw.keyword}" #${kw.position ?? "?"} vol:${kw.searchVolume ?? "?"} kd:${kw.keywordDifficulty ?? "?"}`,
),
].join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/domain`,
{
domain: args.domain,
},
),
structuredContent: { keywords },
});
}),
};

View File

@ -0,0 +1,63 @@
import { z } from "zod";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
domain: z.string().min(1).describe("Domain to analyze (e.g. 'example.com')."),
includeSubdomains: z.boolean().optional().default(false),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const getDomainOverviewTool = {
name: "get_domain_overview",
config: {
title: "Get domain overview",
description:
"Returns a high-level view of a domain's organic footprint: estimated organic traffic, organic keyword count, backlinks, and referring domains. Use this first for domain research; for the detailed ranked-keyword list, call get_domain_keyword_suggestions next. Charges credits (~100-300 typical). Cached for 12 hours per domain.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const result = await DomainService.getOverview(
{
projectId: args.projectId,
domain: args.domain,
includeSubdomains: args.includeSubdomains,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
},
context.billing,
);
const text = [
`Domain: ${result.domain}`,
`Organic traffic: ${result.organicTraffic ?? "?"}`,
`Organic keywords: ${result.organicKeywords ?? "?"}`,
`Backlinks: ${result.backlinks ?? "?"}`,
`Referring domains: ${result.referringDomains ?? "?"}`,
].join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/domain`,
{
domain: args.domain,
},
),
structuredContent: result,
});
}),
};

View File

@ -0,0 +1,88 @@
import { z } from "zod";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { projectIdSchema } from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
trackerId: z
.string()
.optional()
.describe(
"Rank tracker config ID. If omitted, lists all rank trackers in the project.",
),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const getRankTrackerTool = {
name: "get_rank_tracker",
config: {
title: "Get rank tracker",
description:
"Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Free — reads from OpenSEO state, no DataForSEO call. To trigger a new check, use the dashboard.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
if (!args.trackerId) {
const configs = await RankTrackingRepository.getConfigsForProject(
args.projectId,
);
const text =
configs.length === 0
? "No rank trackers configured for this project."
: `Rank trackers (${configs.length}):\n` +
configs
.map(
(c) =>
`- ${c.id} ${c.domain} loc:${c.locationCode} schedule:${c.scheduleInterval}`,
)
.join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/rank-tracking`,
),
structuredContent: { configs },
});
}
const config = await RankTrackingRepository.getConfigById({
configId: args.trackerId,
projectId: args.projectId,
});
if (!config) {
return mcpResponse({
text: `Rank tracker ${args.trackerId} not found in project ${args.projectId}.`,
meta: buildProjectMeta(context, args.projectId),
});
}
const results = await getLatestResults(args.trackerId, args.projectId);
const text = [
`Tracker ${config.id} (${config.domain}):`,
`Schedule: ${config.scheduleInterval}, devices: ${config.devices}, depth: ${config.serpDepth}`,
`Latest run: ${results.run?.lastCheckedAt ?? "never"}`,
`Keywords (${results.rows.length}):`,
...results.rows
.slice(0, 25)
.map(
(r) =>
`- "${r.keyword}" desktop:#${r.desktop.position ?? "-"} (was ${r.desktop.previousPosition ?? "-"}) mobile:#${r.mobile.position ?? "-"}`,
),
].join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/rank-tracking/${args.trackerId}`,
),
structuredContent: { config, results },
});
}),
};

View File

@ -0,0 +1,100 @@
import { z } from "zod";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const querySchema = z.object({
keyword: z.string().min(1),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
});
const inputSchema = {
projectId: projectIdSchema,
queries: z
.array(querySchema)
.min(1)
.max(10)
.describe(
"1-10 queries. Bulk-friendly — prefer this over multiple single-query calls.",
),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const getSerpResultsTool = {
name: "get_serp_results",
config: {
title: "Get Google SERP results",
description:
"Fetch live Google organic search results for 1-10 keywords. Use this to inspect who ranks for a query, verify competitors, compare SERPs across keywords, or gather source URLs before content planning. Charges credits per keyword (~30-60 each). Does not save results to OpenSEO. Per-keyword errors don't fail the batch.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const client = createDataforseoClient(context.billing);
const results = await Promise.all(
args.queries.map(async (q) => {
try {
const items = await client.serp.live({
keyword: q.keyword,
locationCode: q.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: q.languageCode ?? DEFAULT_LANGUAGE_CODE,
});
// Trim noise — return only essentials per item.
const trimmed = items.slice(0, 20).map((item) => ({
type: item.type,
rank: item.rank_absolute ?? item.rank_group ?? null,
title: item.title ?? null,
url: item.url ?? null,
domain: item.domain ?? null,
description: item.description ?? null,
}));
return { keyword: q.keyword, ok: true as const, items: trimmed };
} catch (error) {
return {
keyword: q.keyword,
ok: false as const,
error: error instanceof Error ? error.message : String(error),
};
}
}),
);
const okCount = results.filter((r) => r.ok).length;
const text =
results
.map((r) => {
if (r.ok) {
const top = r.items.slice(0, 3);
return `"${r.keyword}" (${r.items.length} results):\n${top
.map(
(it) =>
` #${it.rank ?? "?"} ${it.domain ?? "?"}${it.title ?? "?"}`,
)
.join("\n")}`;
}
return `"${r.keyword}": FAILED — ${r.error}`;
})
.join("\n\n") +
`\n\n${okCount} of ${results.length} queries succeeded.`;
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/keywords`,
),
structuredContent: { results },
});
}),
};

View File

@ -0,0 +1,40 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters";
import { getAuth, getBaseUrl, type ToolExtra } from "@/server/mcp/context";
import { buildDashboardUrl } from "@/server/mcp/urls";
export const listProjectsTool = {
name: "list_projects",
config: {
title: "List projects",
description:
"Lists all projects in the user's organization. Free — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.",
inputSchema: {} as Record<string, never>,
},
handler: async (_args: Record<string, never>, extra: ToolExtra) => {
const auth = getAuth(extra);
const baseUrl = getBaseUrl(extra);
const projects = await ProjectService.listProjects(auth.organizationId);
const lines =
projects.length === 0
? ["No projects yet. Create one in the dashboard."]
: projects.map(
(p) => `- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""}`,
);
return mcpResponse({
text: `Projects (${projects.length}):\n${lines.join("\n")}`,
meta: {
organizationId: auth.organizationId,
url: buildDashboardUrl(baseUrl, "/"),
},
structuredContent: {
projects: projects.map((p) => ({
id: p.id,
name: p.name,
domain: p.domain,
url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
})),
},
});
},
};

View File

@ -0,0 +1,46 @@
import type { z } from "zod";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { projectIdSchema } from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
} as const;
export const listSavedKeywordsTool = {
name: "list_saved_keywords",
config: {
title: "List saved keywords",
description:
"Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC if available). Free — reads from OpenSEO's database, no DataForSEO call.",
inputSchema,
},
handler: withMcpProjectAuth(
async (args: z.infer<z.ZodObject<typeof inputSchema>>, context) => {
const { rows } = await KeywordResearchService.getSavedKeywords({
projectId: args.projectId,
});
const text =
rows.length === 0
? "No saved keywords yet."
: `Saved keywords (${rows.length}):\n` +
rows
.map(
(r) =>
`- ${r.keyword} vol:${r.searchVolume ?? "?"} kd:${r.keywordDifficulty ?? "?"} cpc:${r.cpc != null ? `$${r.cpc.toFixed(2)}` : "?"}`,
)
.join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/saved`,
),
structuredContent: { rows },
});
},
),
};

View File

@ -0,0 +1,101 @@
import { z } from "zod";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const seedSchema = z.object({
seed: z.string().min(1).describe("Seed keyword to research."),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
});
const inputSchema = {
projectId: projectIdSchema,
seeds: z
.array(seedSchema)
.min(1)
.max(5)
.describe(
"1-5 seed keywords. Each seed is researched independently and returns related keywords with volume/difficulty/CPC. Bulk-friendly — prefer this over multiple single-seed calls.",
),
resultLimit: z
.union([z.literal(150), z.literal(300), z.literal(500)])
.optional()
.describe("Max keywords returned per seed. Defaults to 150."),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const researchKeywordsTool = {
name: "research_keywords",
config: {
title: "Research keywords (bulk)",
description:
"Research keyword data (search volume, difficulty, CPC, related ideas) for 1-5 seed keywords in one call. Charges credits per seed (~50-200 credits each, varies by source). Returns per-seed results — a single bad seed won't fail the batch.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const results = await Promise.all(
args.seeds.map(async (item) => {
try {
const data = await KeywordResearchService.research(
{
projectId: args.projectId,
keywords: [item.seed],
locationCode: item.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: item.languageCode ?? DEFAULT_LANGUAGE_CODE,
resultLimit: args.resultLimit ?? 150,
mode: "auto",
},
context.billing,
);
return {
seed: item.seed,
ok: true as const,
rowCount: data.rows.length,
source: data.source,
usedFallback: data.usedFallback,
topRows: data.rows.slice(0, 20),
};
} catch (error) {
return {
seed: item.seed,
ok: false as const,
error: error instanceof Error ? error.message : String(error),
};
}
}),
);
const okCount = results.filter((r) => r.ok).length;
const failCount = results.length - okCount;
const text =
results
.map((r) => {
if (r.ok) {
return `- "${r.seed}": ${r.rowCount} keywords (source: ${r.source})`;
}
return `- "${r.seed}": FAILED — ${r.error}`;
})
.join("\n") +
`\n\nResearched ${okCount} of ${results.length} seeds${failCount > 0 ? ` (${failCount} failed)` : ""}.`;
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/keywords`,
),
structuredContent: { results },
});
}),
};

View File

@ -0,0 +1,51 @@
import { z } from "zod";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
keywords: z
.array(z.string().min(1))
.min(1)
.max(100)
.describe("Keywords to save (1-100)."),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const saveKeywordsTool = {
name: "save_keywords",
config: {
title: "Save keywords",
description:
"Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
await KeywordResearchService.saveKeywords({
projectId: args.projectId,
keywords: args.keywords,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
});
return mcpResponse({
text: `Saved ${args.keywords.length} keyword(s) to project ${args.projectId}.`,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/saved`,
),
});
}),
};

View File

@ -0,0 +1,68 @@
import { autumn } from "@/server/billing/autumn";
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
} from "@/shared/billing";
import { mcpResponse } from "@/server/mcp/formatters";
import { getAuth, type ToolExtra } from "@/server/mcp/context";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
async function checkBalance(featureId: string, customerId: string) {
try {
const result = await autumn.check({ customerId, featureId });
return result.balance?.remaining ?? null;
} catch {
return null;
}
}
export const whoamiTool = {
name: "whoami",
config: {
title: "Who am I",
description:
"Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Free — does not call DataForSEO. Use this first to confirm connection context before choosing a project or running paid tools.",
inputSchema: {} as Record<string, never>,
},
handler: async (_args: Record<string, never>, extra: ToolExtra) => {
const auth = getAuth(extra);
const isHosted = await isHostedServerAuthMode();
let creditsRemaining: number | null = null;
if (isHosted) {
const [base, topup] = await Promise.all([
checkBalance(AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, auth.organizationId),
checkBalance(
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
auth.organizationId,
),
]);
creditsRemaining = (base ?? 0) + (topup ?? 0);
}
const lines = [
`User: ${auth.userId} (${auth.userEmail})`,
`Organization: ${auth.organizationId}`,
`Mode: ${isHosted ? "hosted" : "self-hosted"}`,
`Scopes: ${auth.scopes.length > 0 ? auth.scopes.join(", ") : "none"}`,
];
if (isHosted) {
lines.push(
`Credits remaining: ${creditsRemaining != null ? creditsRemaining.toLocaleString() : "unknown"}`,
);
}
return mcpResponse({
text: lines.join("\n"),
meta: {
organizationId: auth.organizationId,
creditsRemaining: creditsRemaining ?? undefined,
},
structuredContent: {
userId: auth.userId,
userEmail: auth.userEmail,
organizationId: auth.organizationId,
scopes: auth.scopes,
mode: isHosted ? "hosted" : "self-hosted",
creditsRemaining,
},
});
},
};

18
src/server/mcp/urls.ts Normal file
View File

@ -0,0 +1,18 @@
// Dashboard URL builder. The base URL is derived per-request from the incoming
// MCP request's origin so it works correctly across hosted, self-hosted, and
// dev environments without needing an env var.
export function buildDashboardUrl(
baseUrl: string,
path: string,
params?: Record<string, string | number | undefined>,
): string {
const url = new URL(path.startsWith("/") ? path : `/${path}`, baseUrl);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
url.searchParams.set(key, String(value));
}
}
return url.toString();
}

View File

@ -0,0 +1,12 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { user } from "@/db/schema";
export async function getMcpUserEmail(userId: string) {
const authUser = await db.query.user.findFirst({
columns: { email: true },
where: eq(user.id, userId),
});
return authUser?.email ?? null;
}