Skip to content

How to Build Custom MCP Servers for Claude Desktop When Native Connectors Don't Exist

I wanted Claude Desktop to connect to my company’s internal API. The problem? Cowork has about 40 native connectors, and none of them covered my use case. Most software won’t even have an MCP server yet.

I tried searching for community-built servers. Found some, but they were either outdated or didn’t match my API’s structure. So I decided to build my own. Here’s what I learned.

What MCP Actually Is

MCP stands for Model Context Protocol. It’s an open protocol that standardizes how applications provide context to LLMs. Think of it like “USB-C for AI” - a universal connector that works across different systems.

The architecture has three components:

┌─────────────────────────────────────────────────────────────┐
│ MCP Architecture │
├─────────────────────────────────────────────────────────────┤
│ │
│ Claude Desktop (MCP Client) │
│ │ │
│ │ JSON-RPC over stdio/HTTP │
│ ▼ │
│ MCP Server │
│ │ │
│ │ REST/GraphQL/etc │
│ ▼ │
│ External API (Your Service) │
│ │
└─────────────────────────────────────────────────────────────┘

The MCP Server translates between Claude’s tool calls and your API’s endpoints. Claude doesn’t need to know how your API works - it just calls tools like get_user or create_order, and the server handles the translation.

Why Build Custom MCP Servers

I had four reasons to build my own:

1. Extend Claude’s capabilities - Connect to any API, not just the 40 native connectors.

2. Once and done - Build once, use with any MCP-compatible client. Future-proof your integration.

3. Security control - I control exactly what data Claude can access. No third-party servers handling my credentials.

4. Plug-and-play - Like USB devices. Install the server, restart Claude, and it works.

My First Attempt: Manual Build

I started by reading the MCP TypeScript SDK documentation. I created a basic server structure:

My first attempt at server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server({
name: "my-api-server",
version: "1.0.0",
}, {
capabilities: {
tools: {},
},
});
// Tool definitions...
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get_user",
description: "Get user by ID",
inputSchema: {
type: "object",
properties: {
userId: { type: "string" }
},
required: ["userId"]
}
}
]
};
});
// Tool execution...
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "get_user") {
const userId = request.params.arguments.userId;
// Call my API...
const response = await fetch(`https://my-api.com/users/${userId}`);
return { content: [{ type: "text", text: JSON.stringify(await response.json()) }] };
}
});
const transport = new StdioServerTransport();
await server.connect(transport);

This worked, but I ran into problems quickly.

Problem 1: API Client Complexity

My API has 50+ endpoints. Writing handlers for each one manually took forever. And I kept making mistakes in the parameter schemas.

Error from Claude Desktop
Error: Tool 'create_order' input validation failed.
Expected 'items' to be an array, received object.

I’d defined the schema wrong. Fixing each endpoint one by one was tedious.

Problem 2: Error Handling Was Vague

When something went wrong, Claude just showed “API error” with no details. This made debugging impossible.

Bad error handling (my mistake)
// BAD: Generic error
if (!response.ok) {
throw new Error("API error");
}

Claude couldn’t help me debug because it didn’t know what went wrong.

Problem 3: Pagination Missing

My API returns paginated results. My first server didn’t handle this, so Claude only got the first 10 items and thought that was everything.

The Better Approach: mcp-builder Skill

Then I discovered Anthropic’s mcp-builder skill. This changed everything.

From a Reddit discussion:

“With this skill, you give Claude the API documentation of whatever software you want to connect, give it your API keys, and it builds the entire MCP server for you.”

“I built one for X this way. Once I restarted my desktop app, the connection was live.”

The skill follows a structured workflow:

mcp-builder Skill Workflow
Phase 1: Deep Research and Planning
├── Study MCP documentation
├── Understand your API endpoints
└ Plan tool structure
Phase 2: Implementation
├── Create project structure
├── Build API client with auth
├── Add error handling
└── Implement pagination
Phase 3: Review and Test
├── npm run build
├── Test with MCP Inspector
└── Fix issues
Phase 4: Create Evaluations
└── Write test cases for each tool

Step-by-Step: Building My Server

Step 1: Gather Prerequisites

I needed:

  • Node.js 18+ (for TypeScript SDK)
  • My API documentation (Swagger/OpenAPI spec)
  • API key for authentication

Step 2: Initialize Project

Project initialization
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod

Step 3: Invoke the Skill

I provided Claude with:

  1. My API’s Swagger documentation (JSON file)
  2. Sample API responses
  3. My authentication requirements

The skill generated a complete server structure:

Generated project structure
my-mcp-server/
├── src/
│ ├── index.ts # Server entry point
│ ├── tools/
│ │ ├── users.ts # User-related tools
│ │ ├── orders.ts # Order-related tools
│ │ └── products.ts # Product-related tools
│ ├── client/
│ │ └── api-client.ts # API wrapper with auth
│ └── utils/
│ ├── pagination.ts # Pagination helper
│ └── errors.ts # Error formatting
├── package.json
├── tsconfig.json
└── .env # API keys (not hardcoded!)

Step 4: The Generated Server Code

The skill generated proper TypeScript with Zod validation:

tools/users.ts (generated)
import { z } from "zod";
export const getUserSchema = z.object({
userId: z.string().describe("The unique user identifier"),
});
export const listUsersSchema = z.object({
page: z.number().optional().default(1).describe("Page number"),
limit: z.number().optional().default(20).describe("Items per page"),
});
// Tool definitions for Claude
export const userTools = [
{
name: "get_user",
description: "Retrieve a single user by their unique ID. Returns user profile including name, email, and status.",
inputSchema: getUserSchema,
},
{
name: "list_users",
description: "List all users with pagination. Use 'page' and 'limit' parameters to navigate large datasets.",
inputSchema: listUsersSchema,
},
];

Notice the .describe() calls. These become Claude’s tool descriptions. Clear descriptions help Claude choose the right tool.

Step 5: Error Handling (Fixed)

The generated code had proper error handling:

utils/errors.ts (generated)
// GOOD: Actionable error messages
export function formatApiError(error: unknown, context: string): string {
if (error instanceof Response) {
const status = error.status;
const message = await error.text();
switch (status) {
case 401:
return `Authentication failed for ${context}. Check your API key in .env file.`;
case 404:
return `${context} not found. Verify the resource ID exists.`;
case 429:
return `Rate limit exceeded for ${context}. Wait 60 seconds before retrying.`;
default:
return `${context} failed: ${status} - ${message}`;
}
}
return `Network error during ${context}: ${error.message}`;
}

Now Claude gets actionable error messages. It can suggest fixes.

Step 6: Pagination Support

The pagination helper was crucial:

utils/pagination.ts (generated)
export async function paginate<T>(
fetchPage: (page: number, limit: number) => Promise<{ data: T[]; total: number }>,
maxItems: number = 100
): Promise<T[]> {
const results: T[] = [];
let page = 1;
const limit = 20;
while (results.length < maxItems) {
const response = await fetchPage(page, limit);
results.push(...response.data);
if (results.length >= response.total) break;
page++;
}
return results;
}

Claude can now get all items, not just the first page.

Step 7: Test Locally

Before connecting to Claude Desktop, I tested with MCP Inspector:

Testing with MCP Inspector
npm run build
npx @modelcontextprotocol/inspector node dist/index.js

The Inspector opens a web UI where I can:

  • See all tool definitions
  • Test each tool manually
  • Check parameter validation
  • View response formats

I found a bug: one tool had required: [] when it should have required parameters. Fixed it before deploying.

Step 8: Configure Claude Desktop

I added my server to Claude Desktop’s configuration:

Claude Desktop config (~/.claude/config.json)
{
"mcpServers": {
"my-api": {
"command": "node",
"args": ["/path/to/my-mcp-server/dist/index.js"],
"env": {
"API_KEY": "${MY_API_KEY}"
}
}
}
}

Note the env section. I reference an environment variable, not a hardcoded key.

Step 9: Verify Connection

After restarting Claude Desktop, I tested:

Test prompt in Claude Desktop
List all users from my API who have status 'active'

Claude:

  1. Called the list_users tool
  2. Got paginated results (all pages)
  3. Filtered for active users
  4. Presented the results

The connection was live. It worked.

Common Mistakes I Made (and Fixed)

MistakeWhat I DidWhat I Should Do
Hardcoding API keysPut key in args arrayUse env object with variable reference
Generic error messages”API error”Actionable: “404: User ID not found”
Missing tool descriptions”Get user""Retrieve user profile with name, email, status”
No paginationOnly first pageAuto-paginate up to limit
Inconsistent namingget_user vs fetchUserStick to snake_case for tools

Based on my experience:

ComponentRecommendationWhy
LanguageTypeScriptBest SDK support, type safety
Transportstdio (local) or HTTP (remote)stdio simpler for development
SchemaZod v4Type-safe validation with descriptions
AuthEnvironment variablesSecure, easy to change

For local development, stdio transport is simplest. For production or remote access, consider Streamable HTTP (stateless, scalable).

Security Considerations

One concern I had: API key security. The solution:

.env file (never commit this!)
MY_API_KEY=sk-proj-xxxxx
API_BASE_URL=https://my-api.com/v1
.gitignore
.env
*.env

Claude Desktop reads environment variables from your shell or the env config section. The key never appears in code.

From Reddit:

“If API key security is a concern you can save them in a .env file and have Claude reference them as variables”

This is exactly what I did.

Key Takeaways

Building MCP servers taught me:

1. Provide complete API documentation - Swagger/OpenAPI spec is ideal. Claude needs to understand every endpoint.

2. Use TypeScript - The SDK support is best. Generated code quality is higher than Python equivalent.

3. Secure credentials with environment variables - Never hardcode keys in code or args.

4. Write actionable error messages - Claude can suggest fixes when errors explain what went wrong.

5. Test with MCP Inspector before deployment - Find bugs locally, not in production.

6. Include pagination from the start - Most APIs paginate. Your server should handle it automatically.

The mcp-builder skill saved me days of manual work. What would have been a week of debugging became a few hours of guided implementation.

Now I have a reusable MCP server. Any MCP-compatible client can use it - Claude Desktop, OpenClaw, or future tools that adopt the standard. The “USB-C for AI” analogy is real. Build once, plug anywhere.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments