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:
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: 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: Generic errorif (!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:
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 toolStep-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
mkdir my-mcp-servercd my-mcp-servernpm init -ynpm install @modelcontextprotocol/sdk zodStep 3: Invoke the Skill
I provided Claude with:
- My API’s Swagger documentation (JSON file)
- Sample API responses
- My authentication requirements
The skill generated a complete server 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:
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 Claudeexport 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:
// GOOD: Actionable error messagesexport 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:
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:
npm run buildnpx @modelcontextprotocol/inspector node dist/index.jsThe 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:
{ "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:
List all users from my API who have status 'active'Claude:
- Called the
list_userstool - Got paginated results (all pages)
- Filtered for active users
- Presented the results
The connection was live. It worked.
Common Mistakes I Made (and Fixed)
| Mistake | What I Did | What I Should Do |
|---|---|---|
| Hardcoding API keys | Put key in args array | Use 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 pagination | Only first page | Auto-paginate up to limit |
| Inconsistent naming | get_user vs fetchUser | Stick to snake_case for tools |
Recommended Tech Stack
Based on my experience:
| Component | Recommendation | Why |
|---|---|---|
| Language | TypeScript | Best SDK support, type safety |
| Transport | stdio (local) or HTTP (remote) | stdio simpler for development |
| Schema | Zod v4 | Type-safe validation with descriptions |
| Auth | Environment variables | Secure, 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:
MY_API_KEY=sk-proj-xxxxxAPI_BASE_URL=https://my-api.com/v1.env*.envClaude 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:
- 👨💻 mcp-builder Skill GitHub
- 👨💻 MCP TypeScript SDK
- 👨💻 MCP Python SDK
- 👨💻 MCP Specification
- 👨💻 Example MCP Servers
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments