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:
src/server/mcp/server.ts– MCP server initialization and tool registrationsrc/server/mcp/tools/get-domain-overview.ts– example built-in tool structuresrc/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.
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 registration – registerOpenSeoTool (lines 88-115 of server.ts) creates a validation wrapper that:
- Parses arguments against
inputSchema - Injects a fully-featured
ToolContextwith project metadata - Installs the tool on the
McpServerrouter
Transport handling – 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.tswith theregisterhelper - Test thoroughly and run
pnpm buildbefore 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →