How to Create MCP Servers for LLM Integration Using Claude Skills

Claude Skills utilize the Model Context Protocol (MCP) to expose tool-style APIs that LLMs can invoke during reasoning, requiring a lightweight server process that registers functions with metadata, validates inputs using Pydantic or Zod schemas, and returns results optimized for limited context windows.

To create MCP servers for LLM integration, you implement a standardized protocol that bridges external services with Claude's reasoning capabilities. The ComposioHQ/awesome-claude-skills repository provides a complete framework, documented in mcp-builder/SKILL.md, that guides you through a rigorous four-phase workflow—research, implementation, review, and evaluation—to build production-ready servers.

Understanding the Model Context Protocol

The Model Context Protocol (MCP) defines how LLMs discover and execute external capabilities. An MCP server is a standalone process that exposes a set of tools (functions) along with their input schemas, descriptions, and behavioral hints. When you create MCP servers for LLM integration, you must enforce a CHARACTER_LIMIT = 25_000 to prevent context overflow and implement consistent error handling that returns LLM-friendly messages rather than raw stack traces.

The Four-Phase Development Workflow

The repository mandates a structured approach to ensure reliability and consistency across different language implementations.

Phase 1: Research and Planning

Begin by studying the MCP specification to understand transport mechanisms and message formats. Review the language-specific SDK documentation: Python developers should examine mcp-builder/reference/python_mcp_server.md, while TypeScript developers should consult mcp-builder/reference/node_mcp_server.md.

Identify workflow-oriented tools rather than thin API wrappers. For example, prefer a composite tool like schedule_event_with_attendees over separate create_event and add_attendee calls. This reduces the number of round-trips required during LLM reasoning.

Phase 2: Implementation

Implementation involves four critical components: server initialization, tool definition, shared utilities, and response formatting.

Server Setup – Instantiate your server using FastMCP for Python or MCPServer for TypeScript. Follow the naming convention {service}_mcp (e.g., example_mcp) to ensure clarity.

Tool Definition – Decorate each function with @mcp.tool (Python) or server.registerTool (TypeScript). Supply strict input validation using Pydantic models (Python) or Zod schemas (TypeScript). Add semantic annotations to guide the LLM's decision-making:

  • readOnlyHint: Boolean indicating if the tool modifies state.
  • destructiveHint: Boolean indicating if the tool performs destructive operations.
  • idempotentHint: Boolean indicating if repeated calls with the same inputs produce identical results.
  • openWorldHint: Boolean indicating if the tool interacts with external systems beyond the immediate data store.

Shared Utilities – Factor out API request helpers, pagination logic, and error handlers into separate modules. This ensures DRY compliance and consistent behavior across tools.

Response Formatting – Enforce the 25,000 character limit. When payloads exceed this threshold, truncate the response and append a clear notice: [Note: Results truncated due to length. Use pagination parameters to retrieve more data.].

Phase 3: Review and Refinement

Execute static analysis checks including type validation and Pydantic model verification. Verify that error messages are actionable strings starting with Error: rather than technical exception dumps. Review the codebase for consistent naming conventions and ensure all tools have descriptive docstrings that explain parameters and return formats.

Phase 4: Evaluation

Create a set of 10 realistic, read-only questions that exercise multiple tools and edge cases. Encode these questions in the XML format required by the evaluation harness, as specified in mcp-builder/reference/evaluation.md. Execute the evaluation using mcp-builder/scripts/evaluation.py to simulate realistic LLM interactions against your running server.

Complete MCP Server Implementation Examples

Python Implementation with FastMCP

The following implementation demonstrates a complete searchable-user tool with Pydantic validation, error handling, and dual-format output (Markdown and JSON):

#!/usr/bin/env python3
"""
MCP Server for Example Service.
Provides a searchable‑users tool with Markdown or JSON output.
"""
from enum import Enum
from typing import Optional, List, Dict, Any
import json
import httpx
from pydantic import BaseModel, Field, ConfigDict, field_validator
from mcp.server.fastmcp import FastMCP

# ----------------------------------------------------------------------

# Server initialization

# ----------------------------------------------------------------------

mcp = FastMCP("example_mcp")          # ← follows `{service}_mcp` naming

# ----------------------------------------------------------------------

# Constants

# ----------------------------------------------------------------------

API_BASE_URL = "https://api.example.com/v1"
CHARACTER_LIMIT = 25_000

# ----------------------------------------------------------------------

# Enums & Input models

# ----------------------------------------------------------------------

class ResponseFormat(str, Enum):
    """Desired output format."""
    MARKDOWN = "markdown"
    JSON = "json"

class UserSearchInput(BaseModel):
    """Validated parameters for the user‑search tool."""
    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        extra="forbid"
    )
    query: str = Field(..., description="Search string", min_length=2, max_length=200)
    limit: Optional[int] = Field(default=20, ge=1, le=100, description="Max results")
    offset: Optional[int] = Field(default=0, ge=0, description="Pagination offset")
    response_format: ResponseFormat = Field(
        default=ResponseFormat.MARKDOWN,
        description="Return format"
    )

    @field_validator("query")
    @classmethod
    def _no_blank(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("Query cannot be empty")
        return v.strip()

# ----------------------------------------------------------------------

# Shared helpers

# ----------------------------------------------------------------------

async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict:
    """Generic async wrapper for all external calls."""
    async with httpx.AsyncClient() as client:
        resp = await client.request(method, f"{API_BASE_URL}/{endpoint}", timeout=30.0, **kwargs)
        resp.raise_for_status()
        return resp.json()

def _handle_api_error(e: Exception) -> str:
    """Consistent, LLM‑friendly error strings."""
    if isinstance(e, httpx.HTTPStatusError):
        if e.response.status_code == 404:
            return "Error: Resource not found. Verify the ID."
        if e.response.status_code == 403:
            return "Error: Permission denied. Check your credentials."
        if e.response.status_code == 429:
            return "Error: Rate limit exceeded. Please retry later."
        return f"Error: API request failed with status {e.response.status_code}"
    if isinstance(e, httpx.TimeoutException):
        return "Error: Request timed out. Try again."
    return f"Error: Unexpected error ({type(e).__name__})"

# ----------------------------------------------------------------------

# Tool definition

# ----------------------------------------------------------------------

@mcp.tool(
    name="example_search_users",
    annotations={
        "title": "Search Example Users",
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": True,
    },
)
async def example_search_users(params: UserSearchInput) -> str:
    """Search users by name, email, or team.

    Returns either a Markdown summary or a JSON payload
    (controlled by `response_format`).  Errors are returned as
    plain‑text messages prefixed with `Error:`.
    """
    try:
        data = await _make_api_request(
            "users/search",
            params={"q": params.query, "limit": params.limit, "offset": params.offset},
        )
        users = data.get("users", [])
        total = data.get("total", 0)

        if not users:
            return f"No users found matching '{params.query}'"

        if params.response_format == ResponseFormat.MARKDOWN:
            lines = [
                f"# User Search Results for `{params.query}`",

                f"Found {total} users (showing {len(users)})",
                "",
            ]
            for u in users:
                lines.append(f"## {u['name']} ({u['id']})")

                lines.append(f"- **Email**: {u['email']}")
                if u.get("team"):
                    lines.append(f"- **Team**: {u['team']}")
                lines.append("")
            return "\n".join(lines)

        # JSON response

        payload = {
            "total": total,
            "count": len(users),
            "offset": params.offset,
            "users": users,
        }
        return json.dumps(payload, indent=2)

    except Exception as exc:
        return _handle_api_error(exc)

# ----------------------------------------------------------------------

# Server entry‑point

# ----------------------------------------------------------------------

if __name__ == "__main__":
    # Run with stdio transport (default) – suitable for Claude tools

    mcp.run()

TypeScript Implementation with MCP SDK

The TypeScript equivalent uses Zod for schema validation and the MCPServer class from the MCP SDK:

// src/server.ts
import { MCPServer } from "mcp-sdk";
import { z } from "zod";

const server = new MCPServer("example_mcp");

// ----------------------------------------------------------------------
// Input schema
// ----------------------------------------------------------------------
const SearchInput = z.object({
  query: z.string().min(2).max(200).describe("Search string"),
  limit: z.number().int().min(1).max(100).default(20).describe("Max results"),
  offset: z.number().int().min(0).default(0).describe("Pagination offset"),
  responseFormat: z.enum(["markdown", "json"]).default("markdown").describe("Output format"),
});

// ----------------------------------------------------------------------
// Tool registration
// ----------------------------------------------------------------------
server.registerTool({
  name: "example_search_users",
  description: "Search users by name, email, or team.",
  annotations: {
    title: "Search Example Users",
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: true,
  },
  inputSchema: SearchInput,
  async handler(params) {
    try {
      const resp = await fetch(`https://api.example.com/v1/users/search?q=${encodeURIComponent(params.query)}&limit=${params.limit}&offset=${params.offset}`);
      if (!resp.ok) {
        if (resp.status === 404) return "Error: Resource not found. Verify the ID.";
        if (resp.status === 403) return "Error: Permission denied. Check your credentials.";
        if (resp.status === 429) return "Error: Rate limit exceeded. Please retry later.";
        return `Error: API request failed with status ${resp.status}`;
      }
      const data = await resp.json();
      const users = data.users ?? [];
      const total = data.total ?? 0;

      if (users.length === 0) return `No users found matching '${params.query}'`;

      if (params.responseFormat === "markdown") {
        const lines = [
          `# User Search Results for \`${params.query}\``,

          `Found ${total} users (showing ${users.length})`,
          "",
        ];
        for (const u of users) {
          lines.push(`## ${u.name} (${u.id})`);

          lines.push(`- **Email**: ${u.email}`);
          if (u.team) lines.push(`- **Team**: ${u.team}`);
          lines.push("");
        }
        return lines.join("\n");
      }

      // JSON output
      return JSON.stringify(
        {
          total,
          count: users.length,
          offset: params.offset,
          users,
        },
        null,
        2
      );
    } catch (e: any) {
      return `Error: Unexpected error (${e?.name ?? "unknown"})`;
    }
  },
});

export default server;

To execute the TypeScript server locally:

npm install   # installs mcp-sdk and zod

npm run build  # compiles src/*.ts → dist/

node dist/server.js   # starts the server (stdio transport by default)

Key Resources in the Repository

Summary

  • MCP servers act as lightweight bridges that expose external APIs to Claude through standardized tool definitions, requiring strict input validation and output formatting.
  • Follow the four-phase workflow (research, implementation, review, evaluation) documented in mcp-builder/SKILL.md to ensure production quality.
  • Use Pydantic (Python) or Zod (TypeScript) for rigorous input validation, and always enforce the 25,000 character response limit to respect LLM context constraints.
  • Apply semantic annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) to help the LLM understand tool behavior and side effects.
  • Validate your implementation using the XML-based evaluation harness and evaluation.py script before deployment.

Frequently Asked Questions

What is the Model Context Protocol (MCP) and why is it necessary for LLM integration?

The Model Context Protocol is a standardized communication layer that allows LLMs to discover and invoke external tools through a well-defined schema. When you create MCP servers for LLM integration, you provide Claude with structured metadata about available functions, enabling the model to decide which tools to call during multi-step reasoning without hardcoding API logic into the prompt.

How should I handle large API responses in an MCP server?

Always enforce a CHARACTER_LIMIT of 25,000 characters and truncate any response that exceeds this threshold. Include a clear truncation notice in the response text, and implement pagination parameters (such as limit and offset) in your tool schemas so the LLM can request additional data in subsequent calls if needed.

What are tool annotations and why do they matter?

Tool annotations are metadata hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) that describe a tool's behavior and side effects. These annotations help the LLM make informed decisions about when to call a tool—for example, avoiding destructive operations during read-only research phases or recognizing when retries are safe due to idempotency.

How do I evaluate an MCP server before deploying it to production?

Create an XML file containing 10 realistic, read-only test questions that exercise multiple tools and edge cases, as specified in mcp-builder/reference/evaluation.md. Run the mcp-builder/scripts/evaluation.py harness against your running server to simulate actual LLM interactions, verifying that all tools return correctly formatted responses and handle errors gracefully.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →