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