---
name: mcp-server-design
slug: mcp-server-design
description: 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.
category: general
---

# 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:**

```python
@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):**

```python
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:

```json
// .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):

```json
// .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.

```python
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