feat: add OpenSEO MCP tools (#161)
This commit is contained in:
parent
0ff7bc96b2
commit
57790b930f
@ -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<typeof mcpToolAuthContextSchema>;
|
||||
|
||||
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<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,
|
||||
};
|
||||
}
|
||||
|
||||
47
src/server/mcp/formatters.test.ts
Normal file
47
src/server/mcp/formatters.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
34
src/server/mcp/formatters.ts
Normal file
34
src/server/mcp/formatters.ts
Normal 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;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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=<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`)
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
98
src/server/mcp/project-auth.test.ts
Normal file
98
src/server/mcp/project-auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
40
src/server/mcp/project-auth.ts
Normal file
40
src/server/mcp/project-auth.ts
Normal 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
24
src/server/mcp/schemas.ts
Normal 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'.");
|
||||
@ -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<string, unknown>) {
|
||||
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;
|
||||
|
||||
81
src/server/mcp/tools/get-backlinks-overview.ts
Normal file
81
src/server/mcp/tools/get-backlinks-overview.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
67
src/server/mcp/tools/get-domain-keyword-suggestions.ts
Normal file
67
src/server/mcp/tools/get-domain-keyword-suggestions.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
63
src/server/mcp/tools/get-domain-overview.ts
Normal file
63
src/server/mcp/tools/get-domain-overview.ts
Normal 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,
|
||||
});
|
||||
}),
|
||||
};
|
||||
88
src/server/mcp/tools/get-rank-tracker.ts
Normal file
88
src/server/mcp/tools/get-rank-tracker.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
100
src/server/mcp/tools/get-serp-results.ts
Normal file
100
src/server/mcp/tools/get-serp-results.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
40
src/server/mcp/tools/list-projects.ts
Normal file
40
src/server/mcp/tools/list-projects.ts
Normal 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}`),
|
||||
})),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
46
src/server/mcp/tools/list-saved-keywords.ts
Normal file
46
src/server/mcp/tools/list-saved-keywords.ts
Normal 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 },
|
||||
});
|
||||
},
|
||||
),
|
||||
};
|
||||
101
src/server/mcp/tools/research-keywords.ts
Normal file
101
src/server/mcp/tools/research-keywords.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
51
src/server/mcp/tools/save-keywords.ts
Normal file
51
src/server/mcp/tools/save-keywords.ts
Normal 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`,
|
||||
),
|
||||
});
|
||||
}),
|
||||
};
|
||||
68
src/server/mcp/tools/whoami.ts
Normal file
68
src/server/mcp/tools/whoami.ts
Normal 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
18
src/server/mcp/urls.ts
Normal 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();
|
||||
}
|
||||
12
src/server/mcp/user-email.ts
Normal file
12
src/server/mcp/user-email.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user