What You’ll Learn

  • How I scope MCP server work for freelance clients
  • The discovery questions that save the most time
  • A practical server structure I reuse across projects
  • How I handle auth, error boundaries, and tool naming
  • What a clean client handoff looks like for MCP work

Most MCP server tutorials show you how to register a tool and return a string. That is useful for learning, but it is not what client work looks like.

When a client asks for a custom MCP server, they usually want something specific: connect Claude to their internal API, pull structured data from their CRM, automate a workflow that currently involves copy-pasting between tabs. The model connection is the easy part. The hard part is understanding what the client actually needs and building something they can maintain after I leave.

This is how I approach that.

Start with Discovery, Not Code

Before I write any code, I need answers to a few questions:

  • What system is the AI supposed to talk to?
  • What actions should it be able to take?
  • What actions should it explicitly not be able to take?
  • Who will use this, and what does their current workflow look like?
  • Is there an existing API, or do I need to build one?

The last question matters more than people think. If the client has a clean REST API with docs, the MCP server is mostly a translation layer. If they do not, I am building the API and the MCP server, which is a very different scope.

I always confirm scope in writing before starting. MCP projects have a tendency to expand because once the client sees one tool working, they immediately want five more.

The Server Structure I Reuse

I keep a consistent layout across client MCP servers:

src/
  index.ts
  server.ts
  tools/
    search-contacts.ts
    create-task.ts
    get-report.ts
  lib/
    api-client.ts
    auth.ts
    schemas.ts

Each tool gets its own file. The server file registers tools and handles the MCP lifecycle. Shared concerns like API clients and auth live in lib/.

Here is what the server entry point usually looks like:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { registerSearchContacts } from './tools/search-contacts.js';
import { registerCreateTask } from './tools/create-task.js';
import { registerGetReport } from './tools/get-report.js';

const server = new McpServer({
  name: 'client-crm',
  version: '1.0.0',
});

registerSearchContacts(server);
registerCreateTask(server);
registerGetReport(server);

const transport = new StdioServerTransport();
await server.connect(transport);

Each tool registration function follows the same shape:

import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { apiClient } from '../lib/api-client.js';

export function registerSearchContacts(server: McpServer) {
  server.tool(
    'search_contacts',
    'Search CRM contacts by name or email',
    {
      query: z.string().describe('Name or email to search for'),
      limit: z.number().optional().default(10).describe('Max results'),
    },
    async ({ query, limit }) => {
      const contacts = await apiClient.searchContacts(query, limit);

      return {
        content: [
          {
            type: 'text' as const,
            text: JSON.stringify(contacts, null, 2),
          },
        ],
      };
    }
  );
}

This structure stays manageable even when the server grows to ten or fifteen tools.

Tool Naming Matters More Than You Think

Bad tool names confuse the model. Good tool names make the model reach for the right tool without extra prompting.

My rules:

  • Use verb_noun format: search_contacts, create_task, get_report
  • Keep descriptions short but specific
  • Include what the tool returns, not just what it does
  • Never name a tool do_thing or run_action

The description is especially important. Claude reads it to decide which tool to use. A description like “Searches contacts” is less useful than “Search CRM contacts by name or email. Returns matching contacts with id, name, email, and company.”

Handle Auth Early

Most client MCP servers need to authenticate against an internal API. I handle this through environment variables and a thin auth wrapper:

const API_KEY = process.env.CLIENT_API_KEY;
const API_BASE = process.env.CLIENT_API_BASE;

if (!API_KEY || !API_BASE) {
  throw new Error('Missing CLIENT_API_KEY or CLIENT_API_BASE');
}

export const apiClient = {
  async searchContacts(query: string, limit: number) {
    const res = await fetch(`${API_BASE}/contacts?q=${encodeURIComponent(query)}&limit=${limit}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });

    if (!res.ok) {
      throw new Error(`API error: ${res.status} ${res.statusText}`);
    }

    return res.json();
  },
};

I never hardcode credentials. The client configures their API key in the MCP server config, and the server fails fast if it is missing.

Error Boundaries for Every Tool

If a tool throws an unhandled error, the MCP session can break in ways that are hard to debug. I wrap every tool handler:

async ({ query, limit }) => {
  try {
    const contacts = await apiClient.searchContacts(query, limit);
    return {
      content: [{ type: 'text' as const, text: JSON.stringify(contacts, null, 2) }],
    };
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    return {
      content: [{ type: 'text' as const, text: `Error searching contacts: ${message}` }],
      isError: true,
    };
  }
}

Returning isError: true tells Claude the tool failed without crashing the session. The model can then decide whether to retry, ask for different input, or explain the failure to the user.

What Client Handoff Looks Like

When I deliver an MCP server, the client gets:

  • The source code in a private repo
  • A README with setup instructions and environment variable docs
  • A sample MCP config they can paste into their Claude Code settings
  • A short walkthrough of each tool with example prompts

The README matters. If the client cannot set it up without me, the project is not really done.

Here is what a typical config block looks like:

{
  "mcpServers": {
    "client-crm": {
      "command": "node",
      "args": ["dist/index.js"],
      "env": {
        "CLIENT_API_KEY": "your-api-key",
        "CLIENT_API_BASE": "https://api.client.com/v1"
      }
    }
  }
}

Simple, explicit, and easy to change without touching the code.

Final Thought

Custom MCP servers are one of the highest-value freelance deliverables right now because most teams know they want AI connected to their tools but do not know how to build the bridge.

The work is not complicated. It is mostly scoping, API translation, and clean packaging. But doing it well means the client gets something they can actually use and maintain, not just a demo that worked once.

If you need a custom MCP server built for your team or product, take a look at my portfolio: voidcraft-site.vercel.app.