How to Extend OpenSEO with Custom Modules: A Complete MCP Development Guide

To extend OpenSEO with custom modules, create a TypeScript tool definition with a Zod input schema, an async handler wrapped in withMcpProjectAuth, and register it in src/server/mcp/server.ts using the registerOpenSeoTool function.

OpenSEO is an open-source SEO platform built on the Model Context Protocol (MCP). Its modular architecture lets developers add custom functionality by defining new tools that AI agents can invoke. This guide walks through the exact implementation pattern used in the every-app/open-seo repository, from schema definition to production deployment.

Understanding the OpenSEO Module Architecture

OpenSEO's MCP server exposes a catalog of tools (called "modules" in the UI). Each tool is a plain TypeScript object containing three properties: a name, a configuration object with schemas and metadata, and a handler function.

The registration flow lives in src/server/mcp/server.ts. The createOpenSeoMcpServer function iterates over all built-in tools and calls registerOpenSeoTool for each one. Custom modules plug into this same mechanism.

Key files to reference:

Creating a Custom Module: Step-by-Step

Follow this eight-step process to extend OpenSEO with production-ready functionality.

1. Create the Tool File

Add a new file under src/server/mcp/tools/. Use kebab-case naming that describes your tool's purpose.

touch src/server/mcp/tools/my-custom-tool.ts

2. Define the Zod Input Schema

Export a constant inputSchema describing your tool's arguments. Leverage shared schemas from src/server/mcp/schemas.ts for common fields like projectId and locationCode.

import { z } from "zod";
import { projectIdSchema } from "@/server/mcp/schemas";

const inputSchema = {
  projectId: projectIdSchema,
  targetUrl: z.string().url().describe("URL to analyze"),
  depth: z.number().int().min(1).max(5).default(2).describe("Crawl depth"),
} as const;

type Args = z.infer<z.ZodObject<typeof inputSchema>>;

The as const assertion preserves literal types for downstream inference.

3. Write the Handler

Wrap core logic with withMcpProjectAuth to inherit project-level authorization and billing checks. Return responses via mcpResponse for consistent formatting.

import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { mcpResponse } from "@/server/mcp/formatters";

handler: withMcpProjectAuth(async (args: Args, context) => {
  // Your implementation here
  const result = await analyzeUrl(args.targetUrl, args.depth);
  
  return mcpResponse({
    text: `Analysis complete for ${args.targetUrl}. Found ${result.issues.length} issues.`,
    structuredContent: result,
  });
})

4. Export the Tool Object

Follow the exact shape from lines 32-55 of get-domain-overview.ts:

import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";

export const myCustomTool = {
  name: "my_custom_tool",
  config: {
    title: "Custom URL Analysis",
    description: "Deep-crawls a URL and reports technical SEO issues.",
    inputSchema,
    outputSchema: z
      .object({
        issues: z.array(z.object({
          severity: z.enum(["error", "warning", "info"]),
          message: z.string(),
        })),
        ...optionalMetaOutputSchema,
      })
      .passthrough(),
    annotations: {
      readOnlyHint: false,  // Modifies state?
      openWorldHint: true,  // Calls external services?
      destructiveHint: false, // Deletes data?
    },
  },
  handler, // defined above
};

5. Register in the MCP Server

Import your tool into src/server/mcp/server.ts and call register:

// src/server/mcp/server.ts
import { myCustomTool } from "@/server/mcp/tools/my-custom-tool";

// Inside createOpenSeoMcpServer...
register(myCustomTool);

The register helper (lines 88-91) handles schema validation, context injection, and tool installation on the underlying McpServer instance.

6. Validate with TypeScript

Run the linter to catch schema/type mismatches:

pnpm lint

7. Add Unit Tests

Create a test file under src/server/mcp/tools/__tests__/ following patterns from site-audit-tools.test.ts:

import { myCustomTool } from "../my-custom-tool";

describe("myCustomTool", () => {
  it("validates URL format", async () => {
    const mockContext = { /* ... */ };
    await expect(
      myCustomTool.handler({ projectId: "p_123", targetUrl: "not-a-url" }, mockContext)
    ).rejects.toThrow();
  });
});

8. Build and Deploy

pnpm build

The updated MCP server automatically exposes your tool to connected AI agents without additional configuration.

Complete Working Example

Here's a minimal "hello world" module demonstrating all patterns together:

// src/server/mcp/tools/hello-world.ts
import { z } from "zod";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { mcpResponse } from "@/server/mcp/formatters";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { projectIdSchema } from "@/server/mcp/schemas";

const inputSchema = {
  projectId: projectIdSchema,
  name: z.string().min(1).describe("Name to greet"),
} as const;

type Args = z.infer<z.ZodObject<typeof inputSchema>>;

export const helloWorldTool = {
  name: "hello_world",
  config: {
    title: "Say hello",
    description: "Returns a friendly greeting for the supplied name.",
    inputSchema,
    outputSchema: z
      .object({
        greeting: z.string(),
        ...optionalMetaOutputSchema,
      })
      .passthrough(),
    annotations: {
      readOnlyHint: true,
      openWorldHint: false,
      destructiveHint: false,
    },
  },
  handler: withMcpProjectAuth(async (args: Args, context) => {
    const text = `👋 Hello, ${args.name}!`;
    return mcpResponse({
      text,
      structuredContent: { greeting: text },
    });
  }),
};

Registration in src/server/mcp/server.ts:

import { helloWorldTool } from "@/server/mcp/tools/hello-world";

// Within createOpenSeoMcpServer:
register(helloWorldTool);

After deployment, agents invoke it via:

{
  "name": "hello_world",
  "arguments": { "projectId": "proj_123", "name": "Alice" }
}

How Module Registration Works Under the Hood

Tool registrationregisterOpenSeoTool (lines 88-115 of server.ts) creates a validation wrapper that:

  • Parses arguments against inputSchema
  • Injects a fully-featured ToolContext with project metadata
  • Installs the tool on the McpServer router

Transport handlingtransport.ts exposes handleSelfHostedOpenSeoMcpRequest and handleAuthenticatedOpenSeoMcpRequest. These instantiate a fresh McpServer per request and route HTTP payloads to the registered tool catalog.

AuthorizationwithMcpProjectAuth extracts project scope, validates billing limits, and surfaces curated context to handlers.

Response formattingmcpResponse builds a standardized envelope with text (for human display), structuredContent (for programmatic use), and optional meta fields.

Summary

  • Extend OpenSEO by creating TypeScript tool definitions in src/server/mcp/tools/
  • Define Zod schemas for input validation and output contracts
  • Wrap handlers with project-level authentication using withMcpProjectAuth
  • Export tools as { name, config, handler } objects following the built-in pattern
  • Register in server.ts with the register helper
  • Test thoroughly and run pnpm build before deploying

Frequently Asked Questions

What file naming convention should I use for custom OpenSEO modules?

Use kebab-case filenames that describe your tool's function. The repository convention places files under src/server/mcp/tools/ with names like get-domain-overview.ts, site-audit-tools.ts, or hello-world.ts. This matches the existing codebase structure and ensures consistency with import paths.

Do I need to restart the server after registering a new module?

Yes. The OpenSEO MCP server builds its tool catalog at startup time in createOpenSeoMcpServer. After adding your import and register() call in server.ts, run pnpm build to compile the TypeScript and deploy the updated server. There's no hot-reload mechanism for tool registration.

Can my custom module access external APIs?

Yes. Set annotations.openWorldHint: true in your tool configuration to indicate external service calls. The withMcpProjectAuth wrapper still enforces project authorization and billing limits, but your handler can perform arbitrary async operations including HTTP requests, database queries, or third-party API integrations.

How do I handle authentication for my custom module?

Use the built-in authorization wrappers rather than implementing custom auth. Import withMcpProjectAuth from @/server/mcp/project-auth and wrap your handler. This automatically validates the project ID, checks user permissions, enforces billing limits, and provides a curated ToolContext with project metadata and API credentials.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →