feat(mcp): add create_project tool (#114)
This commit is contained in:
parent
2c153f9a20
commit
b0b5528a3a
@ -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,
|
||||
|
||||
112
src/server/mcp/tools/create-project.test.ts
Normal file
112
src/server/mcp/tools/create-project.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
98
src/server/mcp/tools/create-project.ts
Normal file
98
src/server/mcp/tools/create-project.ts
Normal 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}`),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user