Docs

Registering Custom MCP Tools

Learn how to register custom MCP tools in your Next.js application

Registering Custom MCP Tools

This guide shows you how to add custom MCP tools to your application, allowing AI assistants to interact with your specific business logic.

Creating an MCP Route

First, create an MCP route handler in your Next.js app:

// src/app/api/mcp/route.ts
import { createMcpHandler } from "mcp-handler";
import { auth } from "@repo/auth/server";
import { db } from "@repo/database";
import { headers } from "next/headers";
import { z } from "zod";

async function requireAuth() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session?.user?.id) {
    throw new Error("Unauthorized: Authentication required");
  }

  return session;
}

const handler = createMcpHandler(
  (server) => {
    // Register your tools here
    server.tool(
      "get_project_status",
      "Get the status of a project by ID",
      {
        projectId: z.string(),
      },
      async ({ projectId }) => {
        try {
          await requireAuth();

          const project = await db()
            .select()
            .from(projects)
            .where(eq(projects.id, projectId))
            .limit(1);

          if (!project[0]) {
            return {
              content: [
                {
                  type: "text",
                  text: `Project ${projectId} not found`,
                },
              ],
            };
          }

          return {
            content: [
              {
                type: "text",
                text: JSON.stringify(project[0], null, 2),
              },
            ],
          };
        } catch (error) {
          return {
            content: [
              {
                type: "text",
                text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
              },
            ],
          };
        }
      },
    );
  },
  {},
  { basePath: "/api" },
);

export { handler as GET, handler as POST };

Tool Registration Pattern

Each tool follows the server.tool() method with four parameters:

server.tool(
  name: string,           // Unique tool identifier
  description: string,    // Description for the AI model
  parameters: ZodSchema,  // Zod schema for validation
  handler: Function       // Async function that executes the tool
);

Parameter Validation with Zod

Use Zod schemas to define and validate tool parameters:

import { z } from "zod";

// Simple parameters
server.tool(
  "get_user",
  "Get user by ID",
  {
    userId: z.string(),
  },
  async ({ userId }) => {
    // userId is guaranteed to be a string
  },
);

// Optional parameters with defaults
server.tool(
  "list_items",
  "List items with pagination",
  {
    limit: z.number().int().min(1).max(100).optional(),
    offset: z.number().int().min(0).optional(),
  },
  async ({ limit = 50, offset = 0 }) => {
    // limit defaults to 50, offset to 0
  },
);

// Complex objects
server.tool(
  "create_task",
  "Create a new task",
  {
    title: z.string().min(1).max(200),
    description: z.string().optional(),
    priority: z.enum(["low", "medium", "high"]),
    dueDate: z.string().datetime().optional(),
    assigneeIds: z.array(z.string()).optional(),
  },
  async ({ title, description, priority, dueDate, assigneeIds }) => {
    // All parameters are validated
  },
);

Return Format

Tools must return content in a specific format:

return {
  content: [
    {
      type: "text",
      text: "Your response text here",
    },
  ],
};

For structured data, use JSON.stringify():

return {
  content: [
    {
      type: "text",
      text: JSON.stringify(
        {
          id: user.id,
          name: user.name,
          email: user.email,
        },
        null,
        2,
      ),
    },
  ],
};

Error Handling

Always wrap tool logic in try-catch blocks:

server.tool(
  "risky_operation",
  "Perform a database operation",
  { id: z.string() },
  async ({ id }) => {
    try {
      await requireAuth();
      const result = await performOperation(id);

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(result, null, 2),
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
          },
        ],
      };
    }
  },
);

Complete Example: Email Tools

Here's a complete example for email-related MCP tools:

// src/app/api/mcp/email/route.ts
import { createMcpHandler } from "mcp-handler";
import { db } from "@repo/database";
import { email_message, email_thread } from "@repo/database/schema";
import { eq, desc } from "@repo/database";
import { z } from "zod";

const handler = createMcpHandler(
  (server) => {
    // Get recent emails for an inbox
    server.tool(
      "get_recent_emails",
      "Get recent emails from an inbox",
      {
        inboxId: z.string(),
        limit: z.number().int().min(1).max(50).optional(),
      },
      async ({ inboxId, limit = 10 }) => {
        try {
          const emails = await db()
            .select()
            .from(email_message)
            .where(eq(email_message.inboxId, inboxId))
            .orderBy(desc(email_message.receivedAt))
            .limit(limit);

          return {
            content: [
              {
                type: "text",
                text: JSON.stringify(emails, null, 2),
              },
            ],
          };
        } catch (error) {
          return {
            content: [
              {
                type: "text",
                text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
              },
            ],
          };
        }
      },
    );

    // Search emails by subject
    server.tool(
      "search_emails_by_subject",
      "Search emails by subject line",
      {
        query: z.string().min(1),
        inboxId: z.string().optional(),
        limit: z.number().int().min(1).max(50).optional(),
      },
      async ({ query, inboxId, limit = 20 }) => {
        try {
          let query_builder = db()
            .select()
            .from(email_message)
            .where(sql`${email_message.subject} ILIKE ${`%${query}%`}`)
            .orderBy(desc(email_message.receivedAt))
            .limit(limit);

          if (inboxId) {
            query_builder = query_builder.where(
              eq(email_message.inboxId, inboxId),
            );
          }

          const emails = await query_builder;

          return {
            content: [
              {
                type: "text",
                text: JSON.stringify(emails, null, 2),
              },
            ],
          };
        } catch (error) {
          return {
            content: [
              {
                type: "text",
                text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
              },
            ],
          };
        }
      },
    );
  },
  {},
  { basePath: "/api" },
);

export { handler as GET, handler as POST };

Testing Your Tools

You can test MCP tools using curl:

# Test the MCP endpoint
curl -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Cookie: your-session-cookie" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "get_project_status",
      "arguments": {
        "projectId": "proj_123"
      }
    },
    "id": 1
  }'

Best Practices

  1. Use descriptive names - Tool names should clearly indicate what they do
  2. Write clear descriptions - The AI uses descriptions to decide which tool to call
  3. Validate inputs - Always use Zod schemas to validate parameters
  4. Handle errors gracefully - Return meaningful error messages
  5. Protect sensitive data - Always authenticate and authorize tool access
  6. Keep tools focused - Each tool should do one thing well
  7. Return structured data - Use JSON for complex responses

Next Steps

On this page