diff --git a/src/server/mcp/context.ts b/src/server/mcp/context.ts index 1a1f669..39ba0f4 100644 --- a/src/server/mcp/context.ts +++ b/src/server/mcp/context.ts @@ -1,19 +1,35 @@ import { getMcpAuthContext } from "agents/mcp"; 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"; const mcpToolAuthContextSchema = z.object({ userId: z.string().min(1), + userEmail: z.string().min(1), organizationId: z.string().min(1), clientId: z.string().nullable(), scopes: z.array(z.string()), audience: z.string().min(1), subject: z.string().min(1), + baseUrl: z.string().url(), }); type McpToolAuthContext = z.infer; +export type ToolExtra = unknown; + export function requireMcpToolAuthContext(): McpToolAuthContext { const rawContext = getMcpAuthContext()?.props[MCP_AUTH_CONTEXT_PROP]; const result = mcpToolAuthContextSchema.safeParse(rawContext); @@ -24,3 +40,37 @@ export function requireMcpToolAuthContext(): McpToolAuthContext { 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; baseUrl: string }, + projectId: string, + path?: string, + params?: Record, +) { + return { + organizationId: context.auth.organizationId, + projectId, + url: path ? buildDashboardUrl(context.baseUrl, path, params) : undefined, + }; +} diff --git a/src/server/mcp/formatters.test.ts b/src/server/mcp/formatters.test.ts new file mode 100644 index 0000000..2c20680 --- /dev/null +++ b/src/server/mcp/formatters.test.ts @@ -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" }); + }); +}); diff --git a/src/server/mcp/formatters.ts b/src/server/mcp/formatters.ts new file mode 100644 index 0000000..4039228 --- /dev/null +++ b/src/server/mcp/formatters.ts @@ -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; +}): 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 = {}; + 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; +} diff --git a/src/server/mcp/handler.test.ts b/src/server/mcp/handler.test.ts index d20e2ea..03b49f9 100644 --- a/src/server/mcp/handler.test.ts +++ b/src/server/mcp/handler.test.ts @@ -8,6 +8,10 @@ const verifyMocks = vi.hoisted(() => ({ verifyJwsAccessToken: vi.fn(), })); +const userEmailMocks = vi.hoisted(() => ({ + getMcpUserEmail: vi.fn(), +})); + const serverMocks = vi.hoisted(() => ({ nextServerId: 0, 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", () => ({ createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => { return async () => @@ -110,6 +118,7 @@ describe("handleMcpRequest", () => { verifyMocks.verifyJwsAccessToken.mockResolvedValue( createAccessTokenPayload(), ); + userEmailMocks.getMcpUserEmail.mockResolvedValue("alice@example.com"); }); it("accepts access tokens verified by Better Auth", async () => { @@ -129,9 +138,13 @@ describe("handleMcpRequest", () => { body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP], ).toMatchObject({ userId: "user_123", + userEmail: "alice@example.com", organizationId: "org_123", clientId: "client_123", 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.enableJsonResponse).toBe(true); @@ -233,6 +246,21 @@ describe("handleMcpRequest", () => { 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 () => { const { handleMcpRequest } = await import("@/server/mcp/handler"); verifyMocks.verifyJwsAccessToken.mockResolvedValue( diff --git a/src/server/mcp/handler.ts b/src/server/mcp/handler.ts index 638d837..4a6940b 100644 --- a/src/server/mcp/handler.ts +++ b/src/server/mcp/handler.ts @@ -11,6 +11,7 @@ import { } from "@/lib/oauth-resource"; import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; import { createOpenSeoMcpServer } from "@/server/mcp/server"; +import { getMcpUserEmail } from "@/server/mcp/user-email"; // MCP request flow: // 1. Resource (`resource=`) 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`) // and `aud = mcpResource`, both confirmed against the published // /.well-known/oauth-authorization-server metadata. - export const MCP_ROUTE = "/mcp"; 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, { route: MCP_ROUTE, enableJsonResponse: true, @@ -138,11 +149,13 @@ export async function handleMcpRequest( props: { [MCP_AUTH_CONTEXT_PROP]: { userId, + userEmail, organizationId, clientId, scopes, audience: mcpResource, subject: userId, + baseUrl, }, }, }, diff --git a/src/server/mcp/project-auth.test.ts b/src/server/mcp/project-auth.test.ts new file mode 100644 index 0000000..d432e9b --- /dev/null +++ b/src/server/mcp/project-auth.test.ts @@ -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(); + }); +}); diff --git a/src/server/mcp/project-auth.ts b/src/server/mcp/project-auth.ts new file mode 100644 index 0000000..a224572 --- /dev/null +++ b/src/server/mcp/project-auth.ts @@ -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>; + +export function withMcpProjectAuth( + handler: ( + args: TArgs, + context: McpProjectAuthContext, + ) => Promise | TResult, +) { + return async (args: TArgs, extra: ToolExtra) => { + const context = await requireProjectAccess(extra, args.projectId); + return handler(args, context); + }; +} diff --git a/src/server/mcp/schemas.ts b/src/server/mcp/schemas.ts new file mode 100644 index 0000000..db22a6e --- /dev/null +++ b/src/server/mcp/schemas.ts @@ -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'."); diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts index dbe5f13..ba24bc0 100644 --- a/src/server/mcp/server.ts +++ b/src/server/mcp/server.ts @@ -1,18 +1,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { ProjectService } from "@/server/features/projects/services/ProjectService"; -import { requireMcpToolAuthContext } from "@/server/mcp/context"; - -function jsonToolResult(data: Record) { - return { - structuredContent: data, - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; -} +import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview"; +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"; +import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results"; +import { listProjectsTool } from "@/server/mcp/tools/list-projects"; +import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords"; +import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords"; +import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords"; +import { whoamiTool } from "@/server/mcp/tools/whoami"; export function createOpenSeoMcpServer() { const server = new McpServer({ @@ -20,43 +16,51 @@ export function createOpenSeoMcpServer() { version: "0.0.10", }); + server.registerTool(whoamiTool.name, whoamiTool.config, whoamiTool.handler); server.registerTool( - "whoami", - { - title: "Who am I", - 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, - }, - }); - }, + listProjectsTool.name, + listProjectsTool.config, + listProjectsTool.handler, ); - server.registerTool( - "list_projects", - { - title: "List projects", - description: "List projects in the verified OpenSEO organization.", - }, - async () => { - const auth = requireMcpToolAuthContext(); - const projects = await ProjectService.listProjects(auth.organizationId); - - return jsonToolResult({ - activeOrganizationId: auth.organizationId, - projects, - }); - }, + listSavedKeywordsTool.name, + listSavedKeywordsTool.config, + listSavedKeywordsTool.handler, + ); + server.registerTool( + researchKeywordsTool.name, + researchKeywordsTool.config, + researchKeywordsTool.handler, + ); + server.registerTool( + saveKeywordsTool.name, + 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; diff --git a/src/server/mcp/tools/get-backlinks-overview.ts b/src/server/mcp/tools/get-backlinks-overview.ts new file mode 100644 index 0000000..44d50e1 --- /dev/null +++ b/src/server/mcp/tools/get-backlinks-overview.ts @@ -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>; + +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) + : {}; + 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 }, + }); + }), +}; diff --git a/src/server/mcp/tools/get-domain-keyword-suggestions.ts b/src/server/mcp/tools/get-domain-keyword-suggestions.ts new file mode 100644 index 0000000..ba73616 --- /dev/null +++ b/src/server/mcp/tools/get-domain-keyword-suggestions.ts @@ -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>; + +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 }, + }); + }), +}; diff --git a/src/server/mcp/tools/get-domain-overview.ts b/src/server/mcp/tools/get-domain-overview.ts new file mode 100644 index 0000000..b5e8d3c --- /dev/null +++ b/src/server/mcp/tools/get-domain-overview.ts @@ -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>; + +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, + }); + }), +}; diff --git a/src/server/mcp/tools/get-rank-tracker.ts b/src/server/mcp/tools/get-rank-tracker.ts new file mode 100644 index 0000000..22b08bb --- /dev/null +++ b/src/server/mcp/tools/get-rank-tracker.ts @@ -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>; + +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 }, + }); + }), +}; diff --git a/src/server/mcp/tools/get-serp-results.ts b/src/server/mcp/tools/get-serp-results.ts new file mode 100644 index 0000000..e74a657 --- /dev/null +++ b/src/server/mcp/tools/get-serp-results.ts @@ -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>; + +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 }, + }); + }), +}; diff --git a/src/server/mcp/tools/list-projects.ts b/src/server/mcp/tools/list-projects.ts new file mode 100644 index 0000000..60949d3 --- /dev/null +++ b/src/server/mcp/tools/list-projects.ts @@ -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, + }, + handler: async (_args: Record, 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}`), + })), + }, + }); + }, +}; diff --git a/src/server/mcp/tools/list-saved-keywords.ts b/src/server/mcp/tools/list-saved-keywords.ts new file mode 100644 index 0000000..3e8fcd3 --- /dev/null +++ b/src/server/mcp/tools/list-saved-keywords.ts @@ -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>, 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 }, + }); + }, + ), +}; diff --git a/src/server/mcp/tools/research-keywords.ts b/src/server/mcp/tools/research-keywords.ts new file mode 100644 index 0000000..45537ae --- /dev/null +++ b/src/server/mcp/tools/research-keywords.ts @@ -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>; + +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 }, + }); + }), +}; diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts new file mode 100644 index 0000000..ff2e2d4 --- /dev/null +++ b/src/server/mcp/tools/save-keywords.ts @@ -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>; + +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`, + ), + }); + }), +}; diff --git a/src/server/mcp/tools/whoami.ts b/src/server/mcp/tools/whoami.ts new file mode 100644 index 0000000..4a07b82 --- /dev/null +++ b/src/server/mcp/tools/whoami.ts @@ -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, + }, + handler: async (_args: Record, 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, + }, + }); + }, +}; diff --git a/src/server/mcp/urls.ts b/src/server/mcp/urls.ts new file mode 100644 index 0000000..8bc99a5 --- /dev/null +++ b/src/server/mcp/urls.ts @@ -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 { + 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(); +} diff --git a/src/server/mcp/user-email.ts b/src/server/mcp/user-email.ts new file mode 100644 index 0000000..bf7e977 --- /dev/null +++ b/src/server/mcp/user-email.ts @@ -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; +}