diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts index 2cdc9de..9420d6d 100644 --- a/src/server/mcp/server.ts +++ b/src/server/mcp/server.ts @@ -6,6 +6,7 @@ import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-k 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 { createProjectTool } from "@/server/mcp/tools/create-project"; import { listProjectsTool } from "@/server/mcp/tools/list-projects"; import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords"; import { @@ -54,6 +55,15 @@ export function registerOpenSeoMcpTools(server: McpServer) { listProjectsTool.handler, ), ); + server.registerTool( + createProjectTool.name, + createProjectTool.config, + instrumentMcpToolHandler( + createProjectTool.name, + createProjectTool.config.outputSchema, + createProjectTool.handler, + ), + ); server.registerTool( listSavedKeywordsTool.name, listSavedKeywordsTool.config, diff --git a/src/server/mcp/tools/create-project.test.ts b/src/server/mcp/tools/create-project.test.ts new file mode 100644 index 0000000..863f741 --- /dev/null +++ b/src/server/mcp/tools/create-project.test.ts @@ -0,0 +1,112 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { ToolExtra } from "@/server/mcp/context"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; + +const mocks = vi.hoisted(() => ({ + createProject: vi.fn(), +})); + +vi.mock("@/server/features/projects/services/ProjectService", () => ({ + ProjectService: { + createProject: mocks.createProject, + }, +})); + +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", +}; + +const toolExtra: ToolExtra = { + signal: new AbortController().signal, + requestId: 1, + sendNotification: vi.fn(), + sendRequest: vi.fn(), + authInfo: { + token: "token", + clientId: "client_123", + scopes: ["mcp"], + resource: new URL("https://open-seo.test/mcp"), + extra: { [MCP_AUTH_CONTEXT_PROP]: authContext }, + } satisfies AuthInfo, +}; + +describe("create_project MCP tool", () => { + beforeEach(() => { + vi.resetModules(); + mocks.createProject.mockReset(); + }); + + it("creates a project scoped to the caller's organization and returns it", async () => { + mocks.createProject.mockResolvedValue({ + id: "project_new", + name: "Acme", + domain: "acme.com", + locationCode: 2840, + languageCode: "en", + }); + const { createProjectTool } = await import("./create-project"); + + const result = await createProjectTool.handler( + { name: "Acme", domain: "acme.com", locationCode: 2840 }, + toolExtra, + ); + + // The schema does not derive languageCode; the service resolves it from + // the locationCode, so the tool forwards exactly what was validated. + expect(mocks.createProject).toHaveBeenCalledWith("org_123", { + name: "Acme", + domain: "acme.com", + locationCode: 2840, + }); + expect(result.structuredContent?.project).toMatchObject({ + id: "project_new", + name: "Acme", + domain: "acme.com", + locationCode: 2840, + languageCode: "en", + url: "https://open-seo.test/p/project_new", + }); + const first = result.content?.[0]; + expect(first?.type).toBe("text"); + if (first?.type === "text") { + expect(first.text).toContain("project_new"); + } + }); + + it("creates a minimal project with only a name (org default market)", async () => { + mocks.createProject.mockResolvedValue({ + id: "project_min", + name: "Just a name", + domain: null, + locationCode: 2840, + languageCode: "en", + }); + const { createProjectTool } = await import("./create-project"); + + await createProjectTool.handler({ name: "Just a name" }, toolExtra); + + expect(mocks.createProject).toHaveBeenCalledWith("org_123", { + name: "Just a name", + }); + }); + + it("rejects a languageCode without a locationCode (market pair rule)", async () => { + const { createProjectTool } = await import("./create-project"); + + await expect( + createProjectTool.handler( + { name: "Bad market", languageCode: "en" }, + toolExtra, + ), + ).rejects.toThrow(); + expect(mocks.createProject).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/mcp/tools/create-project.ts b/src/server/mcp/tools/create-project.ts new file mode 100644 index 0000000..2ea2ecb --- /dev/null +++ b/src/server/mcp/tools/create-project.ts @@ -0,0 +1,98 @@ +import { ProjectService } from "@/server/features/projects/services/ProjectService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { + requireMcpToolAuthContext, + type ToolExtra, +} from "@/server/mcp/context"; +import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; +import { buildDashboardUrl } from "@/server/mcp/urls"; +import { languageCodeSchema, locationCodeSchema } from "@/server/mcp/schemas"; +import { createProjectSchema } from "@/types/schemas/projects"; +import { z } from "zod"; + +const inputSchema = { + name: z + .string() + .trim() + .min(1) + .max(120) + .describe("Project name (1-120 characters)."), + domain: z + .string() + .trim() + .max(255) + .optional() + .describe( + 'Optional root domain for the project, e.g. "example.com" (host only, no scheme or path). Sets the default target for domain, backlink, and rank tools.', + ), + locationCode: locationCodeSchema + .optional() + .describe( + "Optional DataForSEO location code for the project's default market (e.g. 2840 = United States, 2504 = Morocco). Falls back to the organization default when omitted.", + ), + languageCode: languageCodeSchema + .optional() + .describe( + 'Optional language code (e.g. "en", "fr"). Requires locationCode; derived from the location when omitted.', + ), +} as const; + +type Args = z.infer>; + +export const createProjectTool = { + name: "create_project", + config: { + title: "Create project", + description: + "Create a new project in the user's organization. Uses no credits — does not call DataForSEO. Provide a name, and optionally a domain and default market (locationCode/languageCode; a languageCode requires a locationCode). Returns the created {id, name, domain, locationCode, languageCode, url}; pass the returned `id` as `projectId` to other OpenSEO tools. Call list_projects first to avoid creating a duplicate.", + inputSchema, + outputSchema: { + project: z + .object({ + id: z.string(), + name: z.string(), + domain: z.string().nullable().optional(), + locationCode: z.number(), + languageCode: z.string(), + url: z.string(), + }) + .passthrough(), + ...optionalMetaOutputSchema, + }, + annotations: { + readOnlyHint: false, + openWorldHint: false, + destructiveHint: false, + }, + }, + handler: async (args: Args, extra: ToolExtra) => { + const { baseUrl, ...auth } = requireMcpToolAuthContext(extra); + // Reuse the app's create schema so the market pair rule (a languageCode + // requires a locationCode) is enforced identically to the dashboard, and + // the domain is normalized the same way. + const input = createProjectSchema.parse(args); + const project = await ProjectService.createProject( + auth.organizationId, + input, + ); + return mcpResponse({ + text: `Created project ${project.id} ${project.name}${ + project.domain ? ` (${project.domain})` : "" + } market:${project.locationCode}/${project.languageCode}`, + meta: { + organizationId: auth.organizationId, + url: buildDashboardUrl(baseUrl, `/p/${project.id}`), + }, + structuredContent: { + project: { + id: project.id, + name: project.name, + domain: project.domain, + locationCode: project.locationCode, + languageCode: project.languageCode, + url: buildDashboardUrl(baseUrl, `/p/${project.id}`), + }, + }, + }); + }, +};