Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 12: Building MCP Servers for Scientific Workflows

12.3 Testing and Publishing

"My test suite covered 97% of the code. The 3% it missed was the authentication layer, the rate limiter, and the part that actually called the API."

A Coverage Metric With Misplaced Confidence

Prerequisites

You should have working MCP tools from Section 12.2 and be familiar with pytest. This section assumes you have Docker installed for the sandboxing examples. The testing patterns build on the software engineering principles from Chapter 8 and connect to the broader testing strategies covered in Chapter 18: AI Assisted Testing and QA.

The Big Picture

An untested MCP server is a liability. When an AI agent calls your search_pubchem tool mid-experiment and gets back malformed JSON, the entire reasoning chain collapses. Testing MCP servers requires three layers: unit tests that verify individual tool logic with mocked APIs, integration tests that validate real API interactions, and end-to-end tests that exercise the full JSON-RPC (JSON Remote Procedure Call) protocol through the MCP inspector. Beyond testing, production servers need authentication (who can call your tools?), sandboxing (what can your tools access?), and packaging (how do users install your server?). This section covers all three concerns.

1. Unit Testing with Mocked APIs

Your search_pubchem tool passes every test on your laptop, you ship it on Friday afternoon, and by Monday morning an agent pipeline has silently returned empty results for 200 experiments because PubChem renamed a JSON field over the weekend. How do you build a test suite that would have caught that before the damage spread?

A server that ships without layered tests is one upstream API change away from silent data corruption, where an agent pipeline keeps running but every result it returns is wrong. The testing strategy in this section exists precisely to close that gap before it costs you weeks of contaminated experiments.

Mocking substitutes a real dependency (an HTTP client, a database connection, a file system call) with a controlled stand-in that returns predetermined responses. Scientific MCP tools typically depend on external APIs such as PubChem or OpenAlex. Calling those APIs in every test run introduces network latency, rate limit risk, and nondeterministic behavior when upstream data changes. Python's unittest.mock.patch intercepts the dependency's import path at runtime and replaces it with a Mock or AsyncMock object whose return values you define in the test. Use mocking for unit tests where you need speed and reproducibility; switch to live API calls (integration tests) when you need to verify that your code still matches the real service's current contract.

MCP tool testing operates at two levels. First, we test the tool function directly by calling it as a regular Python async function with mocked dependencies. Second, we test the JSON-RPC layer by constructing protocol messages and sending them through the server's message handler. The first level catches logic bugs; the second catches serialization and schema validation bugs. In short: if your tests never speak the same JSON-RPC protocol your agent speaks, you are testing a different system than the one running in production.

Common Misconception

A common misconception is that full unit test coverage with mocked APIs guarantees your MCP server works correctly in production. It does not. Mocks freeze the API contract at the moment you recorded them; if the upstream service renames a JSON field, changes a status code, or adds a required parameter, every mocked test still passes while the live server silently breaks. Mocks verify your logic, not the integration; you always need a separate layer of live API tests (Section 2) to catch contract drift.

"""Unit tests for PubChem MCP tools."""
import json
import pytest
from unittest.mock import AsyncMock, patch

# Import the tool function directly
from science_mcp.tools.pubchem import search_pubchem, compute_descriptors


# --- Fixtures ---

@pytest.fixture
def mock_pubchem_search_response():
    """Pre-recorded PubChem search response for 'aspirin'."""
    return {
        "IdentifierList": {
            "CID": [2244, 71364, 71365]
        }
    }

@pytest.fixture
def mock_pubchem_properties_response():
    """Pre-recorded PubChem properties response for aspirin (CID 2244)."""
    return {
        "PropertyTable": {
            "Properties": [
                {
                    "CID": 2244,
                    "IUPACName": "2-acetyloxybenzoic acid",
                    "MolecularFormula": "C9H8O4",
                    "MolecularWeight": 180.16,
                    "CanonicalSMILES": "CC(=O)OC1=CC=CC=C1C(=O)O",
                }
            ]
        }
    }


# --- Tool logic tests ---

class TestSearchPubChem:
    """Tests for the search_pubchem tool."""

    @pytest.mark.asyncio
    async def test_successful_search(
        self, mock_pubchem_search_response, mock_pubchem_properties_response
    ):
        """A valid search returns structured compound data."""
        mock_responses = [
            AsyncMock(status_code=200, json=lambda: mock_pubchem_search_response),
            AsyncMock(status_code=200, json=lambda: mock_pubchem_properties_response),
        ]

        with patch("science_mcp.tools.pubchem.httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
            mock_client.return_value.__aexit__ = AsyncMock(return_value=False)
            mock_client.return_value.get = AsyncMock(side_effect=mock_responses)

            result = await search_pubchem("aspirin", search_type="name", max_results=1)

        data = json.loads(result)
        assert "compounds" in data
        assert len(data["compounds"]) >= 1
        assert data["compounds"][0]["cid"] == 2244
        assert data["compounds"][0]["formula"] == "C9H8O4"
        assert data["source"] == "PubChem"

    @pytest.mark.asyncio
    async def test_compound_not_found(self):
        """A search for a nonexistent compound returns empty results."""
        mock_resp = AsyncMock(status_code=404)
        mock_resp.raise_for_status = AsyncMock(side_effect=None)

        with patch("science_mcp.tools.pubchem.httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
            mock_client.return_value.__aexit__ = AsyncMock(return_value=False)
            mock_client.return_value.get = AsyncMock(return_value=mock_resp)

            result = await search_pubchem("xyznonexistent12345")

        data = json.loads(result)
        assert data["compounds"] == []
        assert data["total_count"] == 0

    @pytest.mark.asyncio
    async def test_invalid_max_results(self):
        """max_results outside allowed range raises ValueError."""
        with pytest.raises(ValueError, match="max_results must be 1-25"):
            await search_pubchem("aspirin", max_results=100)


class TestComputeDescriptors:
    """Tests for the compute_descriptors tool (no mocking needed)."""

    @pytest.mark.asyncio
    async def test_aspirin_descriptors(self):
        """Aspirin descriptors match known values."""
        result = await compute_descriptors("CC(=O)OC1=CC=CC=C1C(=O)O")
        data = json.loads(result)

        assert "descriptors" in data
        desc = data["descriptors"]
        assert 180 < desc["molecular_weight"] < 181  # ~180.16
        assert desc["hydrogen_bond_donors"] == 1
        assert desc["hydrogen_bond_acceptors"] == 4
        assert data["lipinski_rule_of_five"]["drug_like"] is True

    @pytest.mark.asyncio
    async def test_invalid_smiles(self):
        """Invalid SMILES returns a structured error, not an exception."""
        result = await compute_descriptors("not_a_molecule!!!")
        data = json.loads(result)

        assert "error" in data
        assert "Invalid SMILES" in data["error"]
        assert "suggestion" in data
Unit tests for MCP tools. API-dependent tools are tested with mocked HTTP responses; local computation tools (RDKit, the open-source cheminformatics library for Python,) are tested directly using SMILES (Simplified Molecular Input Line Entry System) strings, a compact text notation that encodes molecular structure. The lipinski_rule_of_five assertion checks Lipinski's Rule of Five, a set of four molecular property thresholds (molecular weight, lipophilicity, hydrogen bond donors, and acceptors) that predict whether a compound is likely to be orally active as a drug. Note that test_invalid_smiles verifies the tool returns a structured error rather than raising an exception.
Key Insight: Test the Contract, Not the Implementation

MCP tools have two consumers: the LLM (which reads the description and calls the tool) and the test suite (which verifies the tool's behavior). Both consumers care about the contract: given this input, the tool returns this output structure. Neither consumer cares about internal implementation details (which HTTP library you use, how you parse the response). Structure your tests around the contract: valid inputs produce expected output shapes, invalid inputs produce structured errors, edge cases produce defined behavior. This mirrors the testing philosophy from Chapter 18, where we treat AI-generated code the same way: test behavior, not mechanics.

2. Integration Testing Against Live APIs

Unit tests with mocks verify logic, but they cannot catch API changes (a renamed field, a new rate limit, a deprecated endpoint). Integration tests call the real API, but they are slower, non-deterministic, and require network access. Run integration tests in CI (Continuous Integration) on a schedule (nightly, not on every push) and mark them so developers can skip them locally.

"""Integration tests against live scientific APIs."""
import json
import pytest

from science_mcp.tools.pubchem import search_pubchem
from science_mcp.tools.literature import search_literature

# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration


class TestPubChemIntegration:
    """Live API tests for PubChem tools."""

    @pytest.mark.asyncio
    async def test_aspirin_search_live(self):
        """Search for aspirin returns the expected CID from live PubChem."""
        result = await search_pubchem("aspirin", search_type="name", max_results=3)
        data = json.loads(result)

        assert data["total_count"] >= 1
        cids = [c["cid"] for c in data["compounds"]]
        assert 2244 in cids, "Aspirin (CID 2244) should appear in results"

    @pytest.mark.asyncio
    async def test_smiles_search_live(self):
        """SMILES search returns matching compounds from live PubChem."""
        # Ethanol SMILES
        result = await search_pubchem("CCO", search_type="smiles", max_results=1)
        data = json.loads(result)

        assert data["total_count"] >= 1
        assert data["compounds"][0]["formula"] == "C2H6O"


class TestOpenAlexIntegration:
    """Live API tests for OpenAlex tools."""

    @pytest.mark.asyncio
    async def test_literature_search_live(self):
        """Search for a well-known topic returns results from live OpenAlex."""
        result = await search_literature(
            "transformer attention mechanism", max_results=5
        )
        data = json.loads(result)

        assert data["total_count"] > 0
        assert len(data["papers"]) <= 5
        # At least one paper should have a title
        assert any(p["title"] for p in data["papers"])

    @pytest.mark.asyncio
    async def test_date_filter_live(self):
        """Date filtering works correctly with live OpenAlex."""
        result = await search_literature(
            "CRISPR", max_results=3, year_from=2023, year_to=2024
        )
        data = json.loads(result)

        for paper in data["papers"]:
            if paper["year"]:
                assert 2023 <= paper["year"] <= 2024
Integration tests against live PubChem and OpenAlex APIs. These tests are marked with @pytest.mark.integration so they can be run selectively: pytest -m integration runs only these; pytest -m "not integration" skips them.

Configure pytest to recognize the custom marker in pyproject.toml:

[tool.pytest.ini_options]
markers = [
    "integration: tests that call live external APIs (deselect with '-m not integration')",
]
asyncio_mode = "auto"
Pytest configuration for integration test markers. The asyncio_mode = "auto" setting eliminates the need for @pytest.mark.asyncio decorators on every async test.

3. End-to-End Testing with the MCP Inspector

The MCP Inspector is a developer tool that connects to your server as a client and lets you interactively browse tools, call them, and inspect responses. It validates the JSON-RPC protocol, checks schema compliance, and displays the raw JSON-RPC message exchange. For automated end-to-end testing, we can script the inspector or use the MCP Python SDK's client to drive the server programmatically.

Mental Model

Think of the three test layers like quality checks at a restaurant. Unit tests are the chef tasting each ingredient before cooking: is the salt pure, is the cream fresh? Integration tests are cooking a dish with the real ingredients and checking that it tastes right: did the supplier change the recipe for the soy sauce? End-to-end protocol tests are a mystery diner ordering from the menu, eating the meal, and paying the bill: does the full experience work, from menu description through kitchen to table? A chef who only tastes ingredients (unit tests with mocks) will miss a broken ordering system, a mislabeled menu, or a waiter who garbles the order. Each layer catches a different class of failure, and skipping any one of them leaves a blind spot.

"""End-to-end tests using the MCP client SDK."""
import json
import pytest
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


@pytest.fixture
async def mcp_session():
    """Start the MCP server and connect a test client."""
    server_params = StdioServerParameters(
        command="python",
        args=["-m", "science_mcp.server"],
    )
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            yield session


class TestMCPProtocol:
    """End-to-end tests exercising the full JSON-RPC protocol."""

    @pytest.mark.asyncio
    async def test_list_tools(self, mcp_session):
        """Server advertises expected tools during capability negotiation."""
        tools = await mcp_session.list_tools()
        tool_names = {t.name for t in tools.tools}

        assert "search_pubchem" in tool_names
        assert "search_literature" in tool_names
        assert "compute_descriptors" in tool_names

    @pytest.mark.asyncio
    async def test_tool_schemas_valid(self, mcp_session):
        """Every advertised tool has a valid JSON Schema for its inputs."""
        tools = await mcp_session.list_tools()

        for tool in tools.tools:
            schema = tool.inputSchema
            assert schema.get("type") == "object", (
                f"Tool '{tool.name}' schema must have type 'object'"
            )
            assert "properties" in schema, (
                f"Tool '{tool.name}' schema must define properties"
            )

    @pytest.mark.asyncio
    async def test_call_compute_descriptors(self, mcp_session):
        """Calling compute_descriptors through the protocol returns valid JSON."""
        result = await mcp_session.call_tool(
            "compute_descriptors",
            arguments={"smiles": "CCO"},  # ethanol
        )

        # MCP returns content as a list of content blocks
        assert len(result.content) >= 1
        text = result.content[0].text
        data = json.loads(text)

        assert "descriptors" in data
        assert data["descriptors"]["molecular_weight"] > 0

    @pytest.mark.asyncio
    async def test_call_with_invalid_input(self, mcp_session):
        """Calling a tool with invalid input returns a structured error."""
        result = await mcp_session.call_tool(
            "compute_descriptors",
            arguments={"smiles": "NOT_VALID!!!"},
        )

        text = result.content[0].text
        data = json.loads(text)
        assert "error" in data
End-to-end tests using the MCP Python SDK's client. The test fixture spawns the server as a subprocess, connects via stdio, and runs the initialization handshake. Tests verify both the protocol layer (tool listing, schema validation) and the application layer (tool execution, error handling).

The three test layers form a pyramid (Figure 12.3): a broad base of fast unit tests, a narrower band of integration tests, and a small cap of end-to-end protocol tests. Each layer trades speed for realism, and skipping any one of them leaves an entire class of failure undetected.

MCP Server Test Pyramid Three-layer test pyramid showing unit tests at the base, integration tests in the middle, and end-to-end protocol tests at the top. Unit Tests (50+) Mocked APIs, every commit, < 1 s Integration (10-15) Live API, nightly CI, 1-3 s E2E (5-10) Full JSON-RPC, releases Slower, fewer tests Faster, more tests Each layer catches a different class of failure
Figure 12.3: The three-layer test pyramid for MCP servers. Unit tests (base) are fast and numerous; integration tests (middle) verify live API contracts nightly; end-to-end protocol tests (top) exercise the full JSON-RPC handshake on releases.
Practical Example: The Three-Layer Test Pyramid

For a scientific MCP server with 10 tools, a healthy test distribution looks like this: 50+ unit tests (fast, mocked, run on every commit), 10-15 integration tests (live API, run nightly), and 5-10 end-to-end tests (full protocol, run on releases). The unit tests catch logic bugs in minutes. The integration tests catch API drift overnight. The end-to-end tests catch protocol-level regressions before users see them. This pyramid mirrors the testing strategies discussed in Chapter 18, and the CI/CD patterns from Chapter 21. Figure 12.3.1 illustrates MCP server three-layer test pyramid and CI pipeline.

MCP server three-layer test pyramid and CI pipeline
Figure 12.3.1: The three-layer test pyramid for MCP servers, mapping unit, integration, and end-to-end protocol tests to their CI triggers, execution frequency, and the dependencies each layer exercises.

Checkpoint

So far: you have three test layers for MCP servers (unit tests with mocks for speed, integration tests against live APIs for contract fidelity, and end-to-end protocol tests for JSON-RPC correctness), each catching a distinct class of failure that the others miss.

4. Authentication and Authorization

Passing all three test layers confirms that your server behaves correctly, but correctness alone is not enough once the server is exposed beyond your local machine.

A scientific MCP server may expose tools that access proprietary databases, consume paid API quotas, or control expensive instruments. Authentication verifies who is connecting; authorization determines what they can do.

For stdio transport, authentication is implicit: the host spawns the server as a child process, and only the host can communicate with it. The server inherits the host's environment variables, which is the standard mechanism for passing API keys:

"""Authentication via environment variables (stdio transport)."""
import os
from mcp.server import Server

server = Server("authenticated-science")

def get_api_key(service: str) -> str:
    """Retrieve an API key from environment variables.

    Raises RuntimeError if the key is not set, with instructions
    for the user to configure it.
    """
    env_var = f"{service.upper()}_API_KEY"
    key = os.environ.get(env_var)
    if not key:
        raise RuntimeError(
            f"Missing {env_var} environment variable. "
            f"Set it in your MCP client configuration: "
            f'"env": {{"{env_var}": "your-key-here"}}'
        )
    return key

@server.tool()
async def search_proprietary_db(query: str) -> str:
    """Search a proprietary compound database (requires API key).

    Args:
        query: Search term for the database.
    """
    api_key = get_api_key("PROPRIETARY_DB")
    # Use the key to authenticate with the database
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "https://api.proprietary-db.example.com/search",
            params={"q": query},
            headers={"Authorization": f"Bearer {api_key}"},
        )
        resp.raise_for_status()
        return resp.text
API key authentication via environment variables. The MCP client configuration passes the key to the server process without exposing it in the tool schema or the JSON-RPC messages.

For HTTP transports (SSE (Server-Sent Events), Streamable HTTP), the server is a network service that anyone might attempt to reach. Here you need explicit authentication. The MCP specification recommends OAuth (Open Authorization) 2.0 for remote servers, with the host handling the OAuth flow and including the access token in HTTP headers.

The standard approach uses a bearer token (a credential string the client presents in an HTTP header to prove it has been authorized) validated by middleware that runs before the request reaches your tool handler.

"""Token-based auth middleware for HTTP-transport MCP servers."""
from functools import wraps
from starlette.requests import Request
from starlette.responses import JSONResponse

def require_auth(allowed_scopes: list[str]):
    """Middleware that validates OAuth bearer tokens and scopes.

    Args:
        allowed_scopes: List of OAuth scopes required to access this endpoint.
    """
    def decorator(func):
        @wraps(func)
        async def wrapper(request: Request, *args, **kwargs):
            auth_header = request.headers.get("Authorization", "")
            if not auth_header.startswith("Bearer "):
                return JSONResponse(
                    {"error": "Missing or invalid Authorization header"},
                    status_code=401,
                )

            token = auth_header[7:]
            # Validate the token (in production, verify the JWT (JSON Web Token) signature and claims)
            # validate_token is an application-specific function you implement
            # to decode the JWT, check its signature, and return the claims dict
            claims = await validate_token(token)
            if claims is None:
                return JSONResponse(
                    {"error": "Invalid or expired token"},
                    status_code=401,
                )

            # Check scopes
            token_scopes = set(claims.get("scope", "").split())
            if not token_scopes.issuperset(allowed_scopes):
                return JSONResponse(
                    {"error": f"Insufficient scope. Required: {allowed_scopes}"},
                    status_code=403,
                )

            return await func(request, *args, **kwargs)
        return wrapper
    return decorator
OAuth bearer token middleware for HTTP-transport MCP servers. The decorator validates the token and checks that the client has the required scopes before allowing access to the endpoint.

5. Sandboxing with Docker

Authentication controls who can reach your server, but it does not limit what a legitimately authenticated request can do to the host system.

MCP servers that execute user-provided input (SMILES strings, search queries, code snippets) should run in a sandbox. Docker enforces process isolation, filesystem and network restrictions, and resource limits, preventing the container from reaching the host filesystem, making arbitrary connections, or consuming unbounded CPU and memory.

Real-World Application: Anthropic's MCP Registry and Smithery
Real-World Application: Anthropic's MCP Registry and Smithery
# Dockerfile for a sandboxed scientific MCP server
FROM python:3.12-slim

# Install system dependencies for RDKit
RUN apt-get update && apt-get install -y --no-install-recommends \
    libxrender1 libxext6 \
    && rm -rf /var/lib/apt/lists/*

# Create non-root user
RUN useradd --create-home --shell /bin/bash mcpuser
WORKDIR /home/mcpuser/app

# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy server code
COPY src/ ./src/

# Switch to non-root user
USER mcpuser

# Run server on stdio (default) or HTTP
ENV MCP_TRANSPORT=stdio
ENTRYPOINT ["python", "-m", "science_mcp.server"]
Dockerfile for a sandboxed MCP server. The server runs as a non-root user with minimal system libraries. The slim base image typically reduces the attack surface by excluding unnecessary packages and utilities that could be exploited if the container were compromised.
# docker-compose.yml for MCP server with resource limits
services:
  science-mcp:
    build: .
    environment:
      - PUBCHEM_API_KEY=${PUBCHEM_API_KEY}
      - OPENALEX_EMAIL=${OPENALEX_EMAIL}
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
        reservations:
          cpus: "0.5"
          memory: 256M
    # Restrict network access to only scientific APIs
    networks:
      - science-apis
    # Read-only filesystem except for tmp
    read_only: true
    tmpfs:
      - /tmp:size=100M

networks:
  science-apis:
    driver: bridge
Docker Compose configuration with resource limits and network restrictions. The server gets at most 2 CPUs and 1 GB RAM, a read-only filesystem, and network access restricted to the science-apis bridge network.
Library Shortcut: Container-Based MCP Hosting

Writing Dockerfiles and compose configurations by hand works but is verbose. The mcp CLI tool (installed with the MCP Python SDK) can generate container configurations automatically: mcp build --docker typically produces a Dockerfile from your pyproject.toml, and mcp run --container launches the server in an isolated Docker container with stdio piped back to the host. Check the SDK documentation for the exact flags available in your version. For Claude Desktop, the configuration is a single entry: {"command": "docker", "args": ["run", "-i", "science-mcp:latest"]}. This reduces the 30-line Dockerfile and 20-line compose file to a one-liner.

6. Publishing and Distribution

A well-tested, sandboxed MCP server is ready for distribution. The MCP ecosystem supports several distribution channels:

PyPI (Python Package Index) is the standard channel for Python packages. Package your server as a regular Python package with pyproject.toml, and users install it with pip install science-mcp. The entry point should be a console script that starts the server on stdio. To publish, build the distribution with python -m build and upload it with twine upload dist/*; both tools are installable via pip install build twine.

# pyproject.toml for an MCP server package
[project]
name = "science-mcp"
version = "1.0.0"
description = "MCP server for scientific discovery workflows"
requires-python = ">=3.11"
dependencies = [
    "mcp>=1.0.0",
    "httpx>=0.27.0",
    "pydantic>=2.0",
    "pubchempy>=1.0.4",
]

[project.optional-dependencies]
chemistry = ["rdkit-pypi>=2024.3.1"]
all = ["science-mcp[chemistry]"]

[project.scripts]
science-mcp = "science_mcp.server:main_sync"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Package configuration for a distributable MCP server. The console script science-mcp lets users start the server directly. Optional dependencies (RDKit) are separated so users who only need literature search skip the chemistry installation.

Claude Desktop configuration is where most users will connect to your server. The configuration file maps server names to launch commands:

{
  "mcpServers": {
    "science-mcp": {
      "command": "science-mcp",
      "env": {
        "OPENALEX_EMAIL": "researcher@university.edu"
      }
    },
    "science-mcp-docker": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "science-mcp:latest"],
      "env": {
        "OPENALEX_EMAIL": "researcher@university.edu"
      }
    }
  }
}
Claude Desktop MCP configuration. Users can choose between running the server directly (faster startup) or in a Docker container (better isolation). Environment variables pass API keys and configuration without embedding them in the server code.
Research Frontier: MCP Server Registries and Automated Verification

As of 2026, MCP server discovery is largely manual: users find servers through GitHub searches, documentation links, and word of mouth. Emerging MCP registries aim to solve this with searchable catalogs of servers, including verified schemas, security audits, and compatibility matrices. A significant step in this direction is the MCP Specification v2025-03-26, which formalized the Streamable HTTP transport, structured tool annotations (including readOnlyHint and destructiveHint metadata), and an OAuth 2.1 authorization framework for remote servers (Anthropic, 2025). These protocol-level additions make automated trust verification possible: a registry can now programmatically check whether a server's tools declare their side effects, whether its transport layer supports the latest authentication standard, and whether its schemas pass validation against the canonical JSON Schema specification. For scientific workflows, domain-specific registries could catalog servers by research area (chemistry, genomics, climate science), enabling agents to dynamically discover and connect to new capabilities while verifying safety annotations before granting access. This connects to the multi-agent discovery vision in Chapter 54, where agents compose tool ecosystems on the fly.

7. Continuous Integration Pipeline

With the server packaged and listed in a registry, the final piece is automating the test and release cycle so that every change is validated before it reaches users.

A complete CI pipeline for an MCP server runs unit tests on every push, integration tests nightly, and end-to-end protocol tests on every release. Here is a GitHub Actions workflow that implements this:

# .github/workflows/test.yml
name: Test MCP Server

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 2 * * *"  # Nightly at 2 AM UTC

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -e ".[all]" && pip install pytest pytest-asyncio
      - run: pytest -m "not integration" --tb=short

  integration-tests:
    if: github.event_name == 'schedule' || github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -e ".[all]" && pip install pytest pytest-asyncio
      - run: pytest -m integration --tb=short
        env:
          OPENALEX_EMAIL: ci@example.com

  protocol-tests:
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -e ".[all]" && pip install pytest pytest-asyncio mcp
      - run: pytest tests/test_protocol.py --tb=short
GitHub Actions CI pipeline with three test tiers. Unit tests run on every push, integration tests run nightly and on pushes to main, and full protocol tests run only on tagged releases.

Try It: Build and Test a Minimal MCP Server

Build a complete test suite for a single-tool MCP server in five steps, using only Python standard libraries plus mcp and pytest:

1. Create a file echo_server.py with a single MCP tool called reverse_text that takes a string and returns it reversed. Use the mcp SDK's @server.tool() decorator and add a main_sync() entry point that runs the server on stdio.

2. Write three unit tests in test_echo_unit.py: one that verifies reverse_text("hello") returns "olleh", one that verifies an empty string returns an empty string, and one that verifies a Unicode string (e.g., with accented characters) round-trips correctly.

3. Write two end-to-end tests in test_echo_protocol.py using the MCP client SDK: one that connects to the server via stdio_client, calls list_tools(), and asserts reverse_text appears; and one that calls reverse_text through the protocol and checks the JSON-RPC response content.

4. Add a pyproject.toml with a [project.scripts] entry pointing to your server's main_sync, then run pip install -e . so the console script is available.

5. Run pytest test_echo_unit.py test_echo_protocol.py -v and confirm all five tests pass. Then intentionally break the tool (e.g., return the string unchanged) and verify that the tests catch the regression.

Exercise 12.3.1

You have an MCP server with a search_literature tool that queries the OpenAlex API. You write a unit test that mocks the HTTP response and verifies the tool returns a list of papers. The test passes. A week later, OpenAlex renames the JSON field results to works in their response payload. Your unit test still passes. Why? Describe the specific change you would add to your CI pipeline to catch this category of failure within 24 hours.

Hint

Consider which test layer (unit, integration, or end-to-end) is sensitive to changes in the upstream API's response format, and how the pytest.mark.integration marker combined with a scheduled CI job (the cron trigger in GitHub Actions) addresses exactly this gap.

Step-Through: MCP End-to-End Test Lifecycle

Trace through the lifecycle of a single end-to-end test calling compute_descriptors("CCO") (ethanol) via the MCP protocol:

Step 1 (Spawn): The mcp_session fixture launches python -m science_mcp.server as a subprocess. The server starts, opens stdin/stdout for JSON-RPC, and waits. Elapsed: ~500 ms.
Step 2 (Initialize): The test client sends {"jsonrpc":"2.0","method":"initialize","id":1,...}. The server responds with its capabilities (tools, resources). The handshake completes. Message count: 2.
Step 3 (Call): The client sends {"jsonrpc":"2.0","method":"tools/call","params":{"name":"compute_descriptors","arguments":{"smiles":"CCO"}},"id":2}. The server deserializes the request, invokes the tool function, computes molecular weight = 46.07, H-bond donors = 1, H-bond acceptors = 1. Elapsed: ~50 ms. (The actual science takes 50 ms; the protocol lifecycle around it takes ten times longer, which is why end-to-end tests expose latency and serialization costs that unit tests never see.)
Step 4 (Response): The server wraps the result as a content block: {"content":[{"type":"text","text":"{\"descriptors\":{\"molecular_weight\":46.07,...}}"}]}. The JSON-RPC response reaches the client. Message count: 4 total.
Step 5 (Assert): The test parses result.content[0].text, deserializes the inner JSON, and asserts data["descriptors"]["molecular_weight"] > 0. Test passes. The fixture tears down the subprocess.

Real-World Application: Anthropic's MCP Registry and Smithery

The testing and publishing patterns in this section mirror the workflow used by Smithery, one of the earliest public MCP server registries. As of early 2026, Smithery typically requires submitted servers to pass automated schema validation (every tool must declare a valid JSON Schema for its inputs), a protocol compliance check (the server must respond correctly to initialize and tools/list), and a sandboxing audit (Docker packaging with non-root execution). Servers that fail any check are rejected before they appear in the catalog, so agents connecting through the registry are more likely to encounter well-tested, safely packaged tools.

The Billion-Dollar Mock

In 2012, a Knight Capital trading algorithm lost \$440 million in 45 minutes because a deployment script reactivated dead code on one of eight servers, and the test suite had verified the new behavior only against mocked market feeds. The mocks faithfully confirmed the code was correct; the live market feed exposed a catastrophic mismatch between the mock contract and the real data format. The incident is now a canonical case study in why integration tests against live services are not optional, no matter how comprehensive the mocked unit tests appear.

Lab: Build a Three-Layer Test Suite for a Weather MCP Server

Goal: Experience the difference between mocked unit tests, live integration tests, and full protocol end-to-end tests by building all three layers for a single MCP tool.
Tools needed: Python 3.11+, mcp SDK, pytest, pytest-asyncio, httpx, and the free Open-Meteo API (no key required).
Setup (5 min): Create a minimal MCP server with one tool, get_temperature(latitude: float, longitude: float), that calls https://api.open-meteo.com/v1/forecast?latitude=...&longitude=...¤t_weather=true and returns the current temperature.
Layer 1, Unit (5 min): Write two unit tests that mock the HTTP response and verify the tool parses the temperature correctly from the JSON payload. Intentionally return a response with a renamed field and confirm the test still passes (demonstrating the mock's blind spot).
Layer 2, Integration (5 min): Write one integration test (marked with @pytest.mark.integration) that calls the live Open-Meteo API for coordinates (48.8566, 2.3522) (Paris) and asserts the temperature is between -40 and 60 degrees Celsius.
Layer 3, Protocol (10 min): Write two end-to-end tests using stdio_client: one that verifies get_temperature appears in list_tools(), and one that calls the tool through the protocol and checks the response content block contains a numeric temperature.
What to vary: Try renaming a field in the Open-Meteo response mock and observe which test layer catches it. Try returning malformed JSON from the tool and observe which layer catches it.
What to observe: Note how fast each layer runs. Unit tests should complete in under 1 second; integration tests take 1 to 3 seconds (network round-trip); protocol tests take 2 to 5 seconds (subprocess startup plus network).

Exercises

  1. Conceptual: Explain why end-to-end MCP protocol tests are necessary in addition to unit tests that call tool functions directly. What class of bugs can protocol tests catch that unit tests cannot? Give two concrete examples.
  2. Coding: Write a pytest fixture that records HTTP responses from PubChem to JSON files on the first run, then replays them on subsequent runs (a "cassette" pattern, named by analogy with recording and replaying audio cassettes). Use the pytest-recording or vcrpy library, or implement it from scratch with httpx event hooks. Verify that your search_pubchem tests produce identical results in both live and replay modes.
  3. Analysis: The Docker configuration in this section limits the server to 1 GB of memory. Estimate the maximum number of concurrent compute_descriptors calls the server can handle before hitting this limit, assuming each RDKit molecule object consumes approximately 5 KB of memory and each call holds the molecule for 200 ms. What happens when the limit is exceeded, and how should the server handle it?

What's Next

With individual MCP tools tested and packaged, Section 12.4: Building a Scientific MCP Server combines PubChem, OpenAlex, and a vector database into a complete server, adds resource subscriptions and prompt templates, and integrates the server into the Discovery Workbench.