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 { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
|
||||||
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
|
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
|
||||||
import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results";
|
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 { listProjectsTool } from "@/server/mcp/tools/list-projects";
|
||||||
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
|
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
|
||||||
import {
|
import {
|
||||||
@ -54,6 +55,15 @@ export function registerOpenSeoMcpTools(server: McpServer) {
|
|||||||
listProjectsTool.handler,
|
listProjectsTool.handler,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
server.registerTool(
|
||||||
|
createProjectTool.name,
|
||||||
|
createProjectTool.config,
|
||||||
|
instrumentMcpToolHandler(
|
||||||
|
createProjectTool.name,
|
||||||
|
createProjectTool.config.outputSchema,
|
||||||
|
createProjectTool.handler,
|
||||||
|
),
|
||||||
|
);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
listSavedKeywordsTool.name,
|
listSavedKeywordsTool.name,
|
||||||
listSavedKeywordsTool.config,
|
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