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

> Learn to extend OpenSEO with custom modules by building a TypeScript tool with Zod schema and MCP project auth. Follow our complete guide for seamless integration.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: how-to-guide
- Published: 2026-08-13

---

**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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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:

- [`src/server/mcp/server.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/server.ts) – MCP server initialization and tool registration
- [`src/server/mcp/tools/get-domain-overview.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/tools/get-domain-overview.ts) – example built-in tool structure
- [`src/server/mcp/transport.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/transport.ts) – HTTP transport layer for MCP requests

## 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.

```bash
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`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/schemas.ts) for common fields like `projectId` and `locationCode`.

```typescript
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.

```typescript
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`](https://github.com/every-app/open-seo/blob/main/get-domain-overview.ts):

```typescript
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`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/server.ts) and call `register`:

```typescript
// 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:

```bash
pnpm lint

```

### 7. Add Unit Tests

Create a test file under `src/server/mcp/tools/__tests__/` following patterns from [`site-audit-tools.test.ts`](https://github.com/every-app/open-seo/blob/main/site-audit-tools.test.ts):

```typescript
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

```bash
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:

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/server.ts):

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

// Within createOpenSeoMcpServer:
register(helloWorldTool);

```

After deployment, agents invoke it via:

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

```

## How Module Registration Works Under the Hood

**Tool registration** – `registerOpenSeoTool` (lines 88-115 of [`server.ts`](https://github.com/every-app/open-seo/blob/main/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 handling** – [`transport.ts`](https://github.com/every-app/open-seo/blob/main/transport.ts) exposes `handleSelfHostedOpenSeoMcpRequest` and `handleAuthenticatedOpenSeoMcpRequest`. These instantiate a fresh `McpServer` per request and route HTTP payloads to the registered tool catalog.

**Authorization** – `withMcpProjectAuth` extracts project scope, validates billing limits, and surfaces curated context to handlers.

**Response formatting** – `mcpResponse` 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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/get-domain-overview.ts), [`site-audit-tools.ts`](https://github.com/every-app/open-seo/blob/main/site-audit-tools.ts), or [`hello-world.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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.