general mcp-server-design

mcp-server-design

This skill should be used when the user asks to "build an MCP server", "design an MCP server", "create an MCP tool server", "expose tools via MCP", "connect Claude to my data", "build a Model Context Protocol server", "set up MCP for my CRM", "make my API available to Claude", "write an MCP server for GTM tools", or any variation of designing and building MCP servers that give AI agents access to GTM data sources and tools for B2B SaaS.
Download .md

MCP Server Design

MCP (Model Context Protocol) is a standard for connecting AI agents to external data and tools. An MCP server exposes tools, resources, and prompts that any MCP-compatible client (Claude Code, Claude Desktop, custom agent apps) can discover and use. Think of it as an API specifically designed for LLM consumption.

The design principle: an MCP server wraps an existing system (CRM, enrichment provider, sequencing tool, database) and presents it to the model in a way that's easy to understand and safe to use. The server handles authentication, rate limiting, data formatting, and access control. The model handles reasoning.

When to Build an MCP Server vs Use Direct Tool Calls

Scenario MCP server Direct tool in agent code
Tool should be reusable across multiple agents MCP server Overkill
Tool is specific to one agent and won't be reused Overkill Direct tool
Multiple team members need access to the same data source MCP server Creates duplication
Tool requires complex auth (OAuth, API keys, session management) MCP server (handles auth once) Each agent reimplements auth
You want Claude Code or Claude Desktop to use the tool interactively MCP server (required) Not supported
Tool is a simple function with no external dependencies Overkill Direct tool

Rule of thumb: If more than one agent or more than one person needs access to the same external system, build an MCP server. If it's a one-off utility function for a single agent, use a direct tool.


MCP Server Anatomy

An MCP server exposes three types of capabilities:

Capability What it does When to use
Tools Functions the model can call to take actions or retrieve data CRM queries, enrichment lookups, email sending, search
Resources Data the model can read (like files or database records) Account briefs, contact lists, signal inventories, templates
Prompts Pre-built prompt templates the model can use Research prompts, email writing prompts, scoring rubrics

Most GTM MCP servers expose primarily tools. Resources are useful for static reference data. Prompts are useful for standardizing agent behavior across users.


Building a GTM MCP Server

Step 1: Choose what to expose

Map the external system's capabilities and decide which to expose via MCP.

Example: HubSpot CRM MCP server

HubSpot capability Expose via MCP? As what? Why / why not
Search contacts Yes Tool Agents need to look up contacts during research
Get contact details Yes Tool Needed for personalization and committee mapping
Search companies Yes Tool Account research requires company data
Get deal pipeline Yes Tool Pipeline analysis and forecasting agents need this
Create contact Yes, with guardrails Tool Enrichment agents may need to create records
Update contact properties Yes, with guardrails Tool Agents may need to update fields after enrichment
Delete contact No - Too dangerous. No agent should delete CRM records
Bulk import No - Too dangerous and too slow for agent use
Send marketing email No - Customer-facing action requires human approval outside MCP
Workflow triggers No - Side effects are unpredictable. Keep workflows manual

Exposure rules:

  • Expose read operations freely. Agents reading CRM data is low-risk and high-value
  • Expose write operations with guardrails. Create and update should include validation, required fields, and confirmation
  • Never expose delete operations. An agent that can delete CRM records will eventually delete the wrong one
  • Never expose bulk operations. Agents should operate on single records. Bulk operations amplify mistakes
  • Never expose operations with external side effects you can't undo. Sending emails, triggering workflows, charging credit cards. These need human gates outside the MCP server

Step 2: Design tool interfaces

Each MCP tool needs a name, description, parameters, and return format that the model can understand and use correctly.

Tool interface template:

@server.tool()
async def search_contacts(
    query: str,
    properties: list[str] | None = None,
    max_results: int = 10,
) -> str:
    """Search HubSpot contacts by name, email, or company.

    Use this tool to find contacts at a target account during research
    or to look up a specific person before outreach.

    Args:
        query: Search term. Can be a name, email address, or company name.
        properties: Optional list of contact properties to return.
            Defaults to: firstname, lastname, email, jobtitle, company,
            lifecyclestage. Available properties: phone, city, state,
            country, hs_lead_status, notes_last_updated.
        max_results: Maximum contacts to return. Default 10, max 50.

    Returns:
        JSON array of matching contacts with requested properties.
        Returns {"results": [], "total": 0} if no matches found.
    """

Tool design rules:

  • Name the tool for the action, not the system. search_contacts is better than hubspot_api_call. The model decides based on what the tool does, not where it connects
  • Write the description for the model, not for a developer. "Use this tool to find contacts at a target account during research" tells the model when to call it. "Wrapper for /crm/v3/objects/contacts/search endpoint" does not
  • Include usage guidance in the description. When to use, when not to use, what kind of input works best. The description is the model's decision guide
  • Default sensible values. Don't require the model to specify every parameter. Default max_results to 10, default properties to the most useful set
  • Cap result size. Return max 10-50 records per call. Returning 1,000 records floods the context window and degrades reasoning. If the model needs more, it can refine the query
  • Return structured JSON, not raw API responses. Parse, filter, and format before returning. Strip internal IDs, metadata, and fields the model doesn't need
  • Handle errors in the tool. Return {"error": "Rate limited. Try again in 30 seconds"} instead of crashing. The model can reason about error messages. It can't reason about stack traces

Step 3: Implement the server

Python (using the MCP Python SDK):

from mcp.server import Server
from mcp.types import Tool, TextContent
import httpx

server = Server("hubspot-crm")

HUBSPOT_API_KEY = os.environ["HUBSPOT_API_KEY"]
BASE_URL = "https://api.hubapi.com"

@server.tool()
async def search_contacts(query: str, max_results: int = 10) -> str:
    """Search HubSpot contacts by name, email, or company."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{BASE_URL}/crm/v3/objects/contacts/search",
            headers={"Authorization": f"Bearer {HUBSPOT_API_KEY}"},
            json={
                "query": query,
                "limit": min(max_results, 50),
                "properties": [
                    "firstname", "lastname", "email",
                    "jobtitle", "company", "lifecyclestage"
                ],
            },
        )

        if response.status_code == 429:
            return json.dumps({"error": "Rate limited. Try again in 30 seconds."})

        if response.status_code != 200:
            return json.dumps({"error": f"HubSpot API error: {response.status_code}"})

        data = response.json()
        contacts = [
            {
                "name": f"{r['properties'].get('firstname', '')} {r['properties'].get('lastname', '')}".strip(),
                "email": r["properties"].get("email"),
                "title": r["properties"].get("jobtitle"),
                "company": r["properties"].get("company"),
                "stage": r["properties"].get("lifecyclestage"),
            }
            for r in data.get("results", [])
        ]

        return json.dumps({"results": contacts, "total": data.get("total", 0)})


@server.tool()
async def get_company(company_id: str) -> str:
    """Get detailed company information from HubSpot by company ID."""
    # Implementation...


@server.tool()
async def update_contact_property(
    contact_id: str,
    property_name: str,
    value: str,
) -> str:
    """Update a single property on a HubSpot contact.

    Only use this tool when explicitly instructed to update CRM data.
    Do not update contacts speculatively or without user confirmation.

    Allowed properties: hs_lead_status, lifecyclestage, notes_last_updated,
    custom fields starting with 'abm_'. Other properties require manual update.
    """
    ALLOWED_PROPERTIES = {
        "hs_lead_status", "lifecyclestage", "notes_last_updated",
    }
    if not (property_name in ALLOWED_PROPERTIES
            or property_name.startswith("abm_")):
        return json.dumps({
            "error": f"Property '{property_name}' is not in the allowed list. "
                     f"Allowed: {ALLOWED_PROPERTIES} or custom abm_* fields."
        })
    # Implementation...

Key implementation patterns:

  • Auth via environment variables. Never hardcode API keys. Read from os.environ and fail with a clear error if missing
  • Allowlists for write operations. The update_contact_property example above only allows specific properties. The model can't accidentally overwrite critical fields
  • Rate limit handling. Return a human-readable error message, not a crash. The model (or orchestration layer) can retry
  • Response parsing. Strip raw API responses down to the fields the model actually needs. Internal IDs, pagination tokens, and API metadata are noise

Step 4: Configure for Claude Code

Add the server to Claude Code's MCP configuration:

// .claude/settings.json or ~/.claude/settings.json
{
  "mcpServers": {
    "hubspot": {
      "command": "python",
      "args": ["-m", "hubspot_mcp_server"],
      "env": {
        "HUBSPOT_API_KEY": "your-key-here"
      }
    }
  }
}

For project-level config that all team members share (without secrets):

// .claude/settings.json (project root, committed to git)
{
  "mcpServers": {
    "hubspot": {
      "command": "python",
      "args": ["-m", "hubspot_mcp_server"]
      // API key set in each user's environment
    }
  }
}

GTM MCP Server Patterns

Pattern 1: CRM Server (HubSpot, Salesforce, Attio)

Tools to expose:

Tool Type Guardrails
search_contacts Read Max 50 results per call
get_contact Read None
search_companies Read Max 50 results per call
get_company Read None
get_deals_by_stage Read None
update_contact_property Write Allowlisted properties only
create_note Write Requires contact_id, auto-timestamps
log_activity Write Structured activity types only

Never expose: delete, bulk operations, workflow triggers, email sends.

Pattern 2: Enrichment Server (Apollo, Clearbit, ZoomInfo)

Tools to expose:

Tool Type Guardrails
enrich_company Read Rate limit: 10/minute
enrich_contact Read Rate limit: 10/minute
find_contacts_at_company Read Max 20 results, rate limited
verify_email Read Rate limit: 50/minute

Guardrails specific to enrichment:

  • Rate limit aggressively. Enrichment APIs charge per call. An unthrottled agent can burn through credits in minutes
  • Cache results. The same company enrichment requested 5 times in one session should hit the cache, not the API
  • Return credit cost per call. Include "credits_used": 1 in the response so the agent (or orchestration layer) can track spend

Pattern 3: Sequencing Server (Lemlist, Outreach, Salesloft)

Tools to expose:

Tool Type Guardrails
list_sequences Read None
get_sequence_stats Read None
get_prospect_status Read None
add_prospect_to_sequence Write Requires email verification flag. Human approval gate recommended
pause_prospect Write None (safe to pause)

Never expose: send_email (customer-facing action), delete_sequence, modify_sequence_content (prompt injection risk).

Pattern 4: Research Server (Web search, LinkedIn, news)

Tools to expose:

Tool Type Guardrails
web_search Read Max 10 results per query
search_news Read Date-filtered, max 10 results
get_linkedin_company Read Rate limit: 5/minute
get_linkedin_profile Read Rate limit: 5/minute
get_job_postings Read Max 20 results per company

Research server rules:

  • Deduplicate results across tools. The same news article appearing in web_search and search_news wastes context tokens
  • Include source URL and date in every result. The model needs to assess recency and credibility
  • Filter out irrelevant results before returning. Search for "Acme Corp funding" will return noise. Parse and filter in the tool

Security and Access Control

Authentication

  • Store API keys in environment variables, never in code or config files committed to git
  • Use the most restrictive API permissions available. If the agent only needs read access to contacts, don't use an API key with full admin access
  • Rotate keys on a schedule. MCP servers are long-running processes. Compromised keys have longer exposure windows

Authorization within the server

  • Allowlist operations. Define exactly which actions the server supports. Reject anything else
  • Allowlist fields for writes. Don't let the model update any field. Define which properties are safe to modify
  • Log every action. Every tool call, every parameter, every result. Include the requesting user/agent. This is your audit trail
  • Separate read and write servers. For high-security environments, run read-only and write tools as separate MCP servers with separate permissions. Give agents read access by default, write access only when explicitly needed

Data filtering

  • Never return sensitive fields. Strip SSNs, credit card numbers, salary data, internal notes marked confidential. Filter in the tool before returning
  • Redact PII when possible. If the agent doesn't need the full email address for its task, return a masked version
  • Respect data access controls. If the underlying system has role-based access, the MCP server should enforce the same restrictions. An agent shouldn't see data the user wouldn't see in the UI

Testing MCP Servers

Unit testing tools

Test each tool independently with known inputs.

async def test_search_contacts_found():
    result = await search_contacts("john@acme.com")
    data = json.loads(result)
    assert data["total"] > 0
    assert "email" in data["results"][0]

async def test_search_contacts_not_found():
    result = await search_contacts("nonexistent@fake.com")
    data = json.loads(result)
    assert data["total"] == 0
    assert data["results"] == []

async def test_update_blocked_property():
    result = await update_contact_property("123", "email", "new@test.com")
    data = json.loads(result)
    assert "error" in data  # email is not in allowed list

Integration testing with Claude

Test the server end-to-end with Claude Code.

  1. Start the MCP server
  2. Configure Claude Code to use it
  3. Ask Claude to perform tasks that require the tools
  4. Verify Claude discovers the tools, calls them correctly, and handles responses
  5. Check edge cases: what happens when the API returns errors, empty results, rate limits

Load testing

If the server will handle multiple concurrent agents:

  • Test with 5-10 concurrent tool calls
  • Verify rate limiting works (doesn't let agents exceed API limits)
  • Check that caching reduces redundant API calls
  • Monitor memory usage under load (long-running MCP servers can leak)

Anti-Pattern Check

  • Exposing delete operations. An agent that can delete CRM records will eventually delete the wrong one. Never expose delete via MCP. Deletions are manual operations
  • No rate limiting on enrichment tools. An unthrottled agent calling Apollo 500 times in a session burns through credits and gets your API key suspended. Rate limit every enrichment tool
  • Returning raw API responses. The model receives 50 fields when it needs 5. Context window fills with noise. Parse and filter in the tool, return only what the model needs
  • API keys in code or config committed to git. Use environment variables. Always. No exceptions
  • One giant MCP server with 30 tools. The model struggles to choose between too many tools. Split into domain-specific servers: CRM server, enrichment server, research server. 5-8 tools per server is ideal
  • No logging. When an agent updates the wrong contact or reads the wrong data, you need an audit trail. Log every tool call with parameters and results
  • Write tools without allowlists. update_contact(contact_id, properties) where properties is an open dict means the model can overwrite any field. Allowlist specific properties
  • Exposing customer-facing actions (email send, Slack message) without a human gate. MCP tools execute immediately. There's no "are you sure?" dialog. Customer-facing actions need a human approval step outside the MCP server
Want agents that use skill files like this?
We customize skill files for your brand voice and methodology, then run content agents against them.
Book a call