Claude Academy
expert18 min

Building Custom MCP Servers

Learning Objectives

  • Build an MCP server from scratch in TypeScript
  • Define tools and handle requests
  • Connect internal APIs to Claude Code
  • Test and deploy your custom server

When You Need Custom

The MCP ecosystem has servers for popular services — GitHub, databases, Slack. But your organization has tools that are unique to you:

  • Internal deployment API
  • Custom monitoring dashboard
  • Legacy database with a proprietary query language
  • Internal user management system
  • Custom CI/CD pipeline

A custom MCP server bridges Claude Code to any of these.

Building a Server: Step by Step

Project Setup

mkdir my-mcp-server && cd my-mcp-server

npm init -y

npm install @modelcontextprotocol/sdk zod

npm install -D typescript @types/node

Create tsconfig.json:

{

"compilerOptions": {

"target": "ES2022",

"module": "Node16",

"moduleResolution": "Node16",

"outDir": "./dist",

"strict": true,

"esModuleInterop": true

},

"include": ["src/*/"]

}

Server Implementation

Create src/index.ts:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

import {

CallToolRequestSchema,

ListToolsRequestSchema,

} from "@modelcontextprotocol/sdk/types.js";

import { z } from "zod";

// Create the server

const server = new Server(

{

name: "internal-deploy",

version: "1.0.0",

},

{

capabilities: {

tools: {},

},

}

);

// Define available tools

server.setRequestHandler(ListToolsRequestSchema, async () => {

return {

tools: [

{

name: "get_deploy_status",

description: "Get the deployment status of a service",

inputSchema: {

type: "object" as const,

properties: {

service: {

type: "string",

description: "Service name (e.g., 'api', 'web', 'worker')",

},

environment: {

type: "string",

enum: ["staging", "production"],

description: "Target environment",

},

},

required: ["service", "environment"],

},

},

{

name: "trigger_deploy",

description: "Trigger a deployment for a service",

inputSchema: {

type: "object" as const,

properties: {

service: {

type: "string",

description: "Service name",

},

environment: {

type: "string",

enum: ["staging", "production"],

description: "Target environment",

},

version: {

type: "string",

description: "Version tag to deploy",

},

},

required: ["service", "environment", "version"],

},

},

{

name: "get_service_health",

description: "Check the health of a deployed service",

inputSchema: {

type: "object" as const,

properties: {

service: { type: "string" },

environment: { type: "string", enum: ["staging", "production"] },

},

required: ["service", "environment"],

},

},

],

};

});

// Handle tool calls

server.setRequestHandler(CallToolRequestSchema, async (request) => {

const { name, arguments: args } = request.params;

switch (name) {

case "get_deploy_status": {

// Call your internal API

const status = await fetchDeployStatus(

args.service as string,

args.environment as string

);

return {

content: [

{

type: "text",

text: JSON.stringify(status, null, 2),

},

],

};

}

case "trigger_deploy": {

const result = await triggerDeploy(

args.service as string,

args.environment as string,

args.version as string

);

return {

content: [

{

type: "text",

text: JSON.stringify(result, null, 2),

},

],

};

}

case "get_service_health": {

const health = await checkHealth(

args.service as string,

args.environment as string

);

return {

content: [

{

type: "text",

text: JSON.stringify(health, null, 2),

},

],

};

}

default:

throw new Error(Unknown tool: ${name});

}

});

// Your internal API functions

async function fetchDeployStatus(service: string, env: string) {

const response = await fetch(

https://deploy.internal.company.com/api/status/${env}/${service},

{ headers: { Authorization: Bearer ${process.env.DEPLOY_TOKEN} } }

);

return response.json();

}

async function triggerDeploy(service: string, env: string, version: string) {

const response = await fetch(

https://deploy.internal.company.com/api/deploy,

{

method: "POST",

headers: {

Authorization: Bearer ${process.env.DEPLOY_TOKEN},

"Content-Type": "application/json",

},

body: JSON.stringify({ service, environment: env, version }),

}

);

return response.json();

}

async function checkHealth(service: string, env: string) {

const response = await fetch(

https://${service}.${env}.company.com/health

);

return {

status: response.ok ? "healthy" : "unhealthy",

statusCode: response.status,

responseTime: ${Date.now()}ms,

};

}

// Start the server

async function main() {

const transport = new StdioServerTransport();

await server.connect(transport);

console.error("Internal Deploy MCP server running");

}

main().catch(console.error);

Build and Connect

# Build

npx tsc

# Connect to Claude Code

claude mcp add internal-deploy \

-e DEPLOY_TOKEN=$DEPLOY_TOKEN \

--transport stdio \

node dist/index.js

Use It

claude

"What's the deployment status of the API service in production?"

# Claude calls mcp__internal-deploy__get_deploy_status

"Deploy version v2.3.0 of the API to staging"

# Claude calls mcp__internal-deploy__trigger_deploy

"Check if staging is healthy after the deploy"

# Claude calls mcp__internal-deploy__get_service_health

Tool Design Best Practices

Clear Descriptions

Claude uses tool descriptions to decide when and how to use them. Be specific:

// Bad

{ name: "query", description: "Run a query" }

// Good

{ name: "search_users", description: "Search internal user database by email, name, or team. Returns user profile, role, and team membership." }

Structured Input Schemas

Use Zod-compatible JSON Schema for input validation:

{

inputSchema: {

type: "object",

properties: {

query: {

type: "string",

description: "Search query (email, name, or team name)"

},

limit: {

type: "number",

description: "Maximum results (default 10, max 50)"

}

},

required: ["query"]

}

}

Structured Return Values

Return JSON that Claude can easily parse:

return {

content: [{

type: "text",

text: JSON.stringify({

results: users,

total: count,

query: args.query,

timestamp: new Date().toISOString()

}, null, 2)

}]

};

Testing Your Server

Manual Testing

# Start the server directly

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js

In Claude Code

# Connect and test

claude mcp add test-server --transport stdio node dist/index.js

# In session

/mcp

# Should show your server and tools

"List the available tools from the internal deploy server"

# Claude should list your tools

Key Takeaway

Custom MCP servers connect any internal tool or API to Claude Code. Use the @modelcontextprotocol/sdk to build a server that defines tools with names, descriptions, input schemas, and handlers. Connect with claude mcp add. The investment in building a custom server pays off every time you or your team uses Claude with your internal infrastructure — eliminating manual data copying and giving Claude direct access to your tools.