Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
BUNDLE_NAME = mcp-example
VERSION ?= 0.1.0

.PHONY: help install dev-install format format-check lint lint-fix typecheck test test-cov clean run run-http check all bump bundle
.PHONY: help install dev-install format format-check lint lint-fix typecheck test test-integration test-llm test-cov clean run run-http check all bump bundle

help: ## Show this help message
@echo 'Usage: make [target]'
Expand Down Expand Up @@ -34,6 +34,12 @@ typecheck: ## Type check code with ty
test: ## Run tests with pytest
uv run pytest tests/ -v

test-integration: ## Run integration tests (requires EXAMPLE_API_KEY)
uv run pytest tests-integration/ -v --ignore=tests-integration/test_skill_llm.py

test-llm: ## Run LLM smoke tests (requires EXAMPLE_API_KEY + ANTHROPIC_API_KEY)
uv run pytest tests-integration/test_skill_llm.py -v

test-cov: ## Run tests with coverage
uv run pytest tests/ -v --cov=src/mcp_example --cov-report=term-missing

Expand Down
18 changes: 18 additions & 0 deletions src/mcp_example/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Example MCP Server — Skill Guide

## Tools

| Tool | Use when... |
|------|-------------|
| `list_items` | You need to browse or search items |
| `get_item` | You have an item ID and need full details |

## Context Reuse

- Use the `id` from `list_items` results when calling `get_item`

## Workflows

### 1. Browse and Inspect
1. `list_items` with a limit to get an overview
2. For interesting items: `get_item` to get full details
2 changes: 1 addition & 1 deletion src/mcp_example/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ async def _request(

raise ExampleAPIError(response.status, error_msg, result)

return result # type: ignore[no-any-return]
return result

except ClientError as e:
raise ExampleAPIError(500, f"Network error: {str(e)}") from e
Expand Down
22 changes: 21 additions & 1 deletion src/mcp_example/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import logging
import os
import sys
from importlib.resources import files

from fastmcp import Context, FastMCP
from starlette.requests import Request
Expand All @@ -31,8 +32,16 @@

logger.info("Example server module loading...")

SKILL_CONTENT = files("mcp_example").joinpath("SKILL.md").read_text()

# Create MCP server
mcp = FastMCP("Example")
mcp = FastMCP(
"Example",
instructions=(
"Before using tools, read the skill://example/usage resource "
"for tool selection guidance and workflow patterns."
),
)

# Global client instance (lazy initialization)
_client: ExampleClient | None = None
Expand Down Expand Up @@ -110,6 +119,17 @@ async def get_item(
raise


# ============================================================================
# Resources
# ============================================================================


@mcp.resource("skill://example/usage")
def skill_usage() -> str:
"""Usage guide for the Example MCP server tools."""
return SKILL_CONTENT


# ============================================================================
# Entrypoints
# ============================================================================
Expand Down
Empty file added tests-integration/__init__.py
Empty file.
47 changes: 47 additions & 0 deletions tests-integration/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
Shared fixtures and configuration for integration tests.

These tests require a valid EXAMPLE_API_KEY environment variable.
They make real API calls and should not be run in CI without proper setup.
"""

import os

import pytest
import pytest_asyncio

from mcp_example.api_client import ExampleClient


def pytest_configure(config):
"""Check for required environment variables before running tests."""
if not os.environ.get("EXAMPLE_API_KEY"):
pytest.exit(
"ERROR: EXAMPLE_API_KEY environment variable is required.\n"
"Set it before running integration tests:\n"
" export EXAMPLE_API_KEY=your_key_here\n"
" make test-integration"
)


@pytest.fixture
def api_key() -> str:
"""Get the API key from environment."""
key = os.environ.get("EXAMPLE_API_KEY")
if not key:
pytest.skip("EXAMPLE_API_KEY not set")
return key


@pytest_asyncio.fixture
async def client(api_key: str) -> ExampleClient:
"""Create a client for testing."""
client = ExampleClient(api_key=api_key)
yield client
await client.close()


# TODO: Add well-known test data constants
# class TestData:
# """Well-known test data for integration tests."""
# KNOWN_ID = "abc123"
35 changes: 35 additions & 0 deletions tests-integration/test_core_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
Core tools integration tests.

Tests basic API functionality with real API calls.
Replace with your actual endpoints and assertions.
"""

# import pytest
# from mcp_example.api_client import ExampleAPIError, ExampleClient


# TODO: Add integration tests for each tool group. Example:
#
# class TestListItems:
# """Test list items endpoint."""
#
# @pytest.mark.asyncio
# async def test_list_items(self, client: ExampleClient):
# """Test listing items."""
# result = await client.list_items(limit=5)
# assert isinstance(result, list)
# print(f"Found {len(result)} items")
#
#
# For tier-gated endpoints, add a helper:
#
# async def has_premium_access(client: ExampleClient) -> bool:
# """Check if the plan supports premium endpoints."""
# try:
# await client.premium_method()
# return True
# except ExampleAPIError as e:
# if e.status in (400, 401, 403):
# return False
# raise
86 changes: 86 additions & 0 deletions tests-integration/test_skill_llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""
Smoke test: verify the LLM reads the skill resource and selects the correct tool.

Requires ANTHROPIC_API_KEY and EXAMPLE_API_KEY in environment.
"""

import os

import anthropic
import pytest
from fastmcp import Client

from mcp_example.server import mcp


def get_anthropic_client() -> anthropic.Anthropic:
token = os.environ.get("ANTHROPIC_API_KEY")
if not token:
pytest.skip("ANTHROPIC_API_KEY not set")
return anthropic.Anthropic(api_key=token)


async def get_server_context() -> dict:
"""Extract instructions, skill content, and tool definitions from the MCP server."""
async with Client(mcp) as client:
init = await client.initialize()
instructions = init.instructions

resources = await client.list_resources()
skill_text = ""
for r in resources:
if "skill://" in str(r.uri):
contents = await client.read_resource(str(r.uri))
skill_text = contents[0].text if hasattr(contents[0], "text") else str(contents[0])

tools_list = await client.list_tools()
tools = []
for t in tools_list:
tool_def = {
"name": t.name,
"description": t.description or "",
"input_schema": t.inputSchema,
}
tools.append(tool_def)

return {
"instructions": instructions,
"skill": skill_text,
"tools": tools,
}


class TestSkillLLMInvocation:
"""Test that an LLM reads the skill and makes correct tool choices.

TODO: Replace with tests specific to your server's tools and skill.

Each test should:
1. Send a user prompt that maps to a specific tool per the SKILL.md
2. Assert the LLM calls the expected tool (not a similar one)
"""

# @pytest.mark.asyncio
# async def test_query_selects_correct_tool(self):
# """When asked to X, the LLM should call tool_name."""
# ctx = await get_server_context()
# client = get_anthropic_client()
#
# system = (
# f"You are an assistant.\n\n"
# f"## Server Instructions\n{ctx['instructions']}\n\n"
# f"## Skill Resource\n{ctx['skill']}"
# )
#
# response = client.messages.create(
# model="claude-haiku-4-5-20251001",
# max_tokens=1024,
# system=system,
# messages=[{"role": "user", "content": "Your test prompt here"}],
# tools=[{"type": "custom", **t} for t in ctx["tools"]],
# )
#
# tool_calls = [b for b in response.content if b.type == "tool_use"]
# assert len(tool_calls) > 0, "LLM did not call any tool"
# assert tool_calls[0].name == "expected_tool_name"
pass
33 changes: 33 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Shared fixtures for unit tests."""

from unittest.mock import AsyncMock

import pytest

from mcp_example.server import mcp


@pytest.fixture
def mcp_server():
"""Return the MCP server instance."""
return mcp


@pytest.fixture
def mock_client():
"""Create a mock API client."""
client = AsyncMock()
client.list_items = AsyncMock(
return_value=[
{"id": "1", "name": "Item 1"},
{"id": "2", "name": "Item 2"},
]
)
client.get_item = AsyncMock(
return_value={
"id": "1",
"name": "Item 1",
"description": "Test item",
}
)
return client
Loading