feat(mcp): add create_project tool (#114)

This commit is contained in:
Amine Benboubker 2026-07-23 15:07:02 +01:00 committed by GitHub
parent 2c153f9a20
commit b0b5528a3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 220 additions and 0 deletions

View File

@ -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,

View File

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

View File

@ -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<z.ZodObject<typeof inputSchema>>;
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}`),
},
},
});
},
};