diff --git a/README.ko.md b/README.ko.md index 641a822..cb59d20 100644 --- a/README.ko.md +++ b/README.ko.md @@ -24,7 +24,9 @@ MCP 서버를 만들고, 원클릭 배포. 시크릿 불필요. - **MCP SDK** — `mcp` (FastMCP) + stdio 전송 - **Python 3.11+** — 타입 힌트, async/await, hatchling 빌드 +- **MCP 3대 프리미티브** — Tools, Resources, Prompts 예제 전부 포함 - **Safety Annotations** — 모든 도구에 readOnly/destructive/idempotent 힌트 +- **검증된 Prompt** — pydantic `@validate_call`로 핸들러 실행 전 인자 검증 - **응답 헬퍼** — `ok()`, `err()`로 일관된 응답 - **CI** — gitleaks, ruff, 라이선스 검증, pytest (3.11/3.12/3.13) - **CD** — OIDC trusted publishing으로 PyPI 배포 (시크릿 불필요) @@ -58,6 +60,60 @@ async def your_tool(input: str) -> str: return f"처리 완료: {input}" ``` +## Resource 추가 + +Resource는 고정된 URI로 클라이언트에 데이터를 노출합니다 (동작을 수행하는 Tool과 대비). + +예시: `src/my_mcp_server/resources/server_info.py` + +```python +from mcp.server.fastmcp import FastMCP + + +def register(mcp: FastMCP) -> None: + @mcp.resource( + "info://your/resource", + name="your-resource", + description="리소스가 노출하는 데이터.", + mime_type="application/json", + ) + async def your_resource() -> str: + return "..." # str, bytes, 또는 JSON 직렬화 가능한 객체 +``` + +`server.py`에서: + +```python +from my_mcp_server.resources.your_resource import register as register_your_resource +register_your_resource(mcp) +``` + +## Prompt 추가 + +Prompt는 파라미터화된 재사용 가능한 메시지 템플릿입니다. pydantic으로 인자를 검증합니다. + +예시: `src/my_mcp_server/prompts/code_review.py` + +```python +from typing import Annotated, Literal + +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts.base import UserMessage +from pydantic import Field, validate_call + + +@validate_call +def your_prompt( + mode: Literal["short", "long"], + topic: Annotated[str, Field(min_length=1)], +) -> list[UserMessage]: + return [UserMessage(content=f"{topic}에 대한 {mode} 노트를 작성해주세요.")] + + +def register(mcp: FastMCP) -> None: + mcp.prompt(name="your-prompt", title="Your Prompt")(your_prompt) +``` + ## CI/CD ### CI (push/PR마다 실행) diff --git a/README.md b/README.md index d6fd9a1..679b873 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ Build your MCP server. One-click publish. Zero secrets needed. - **MCP SDK** — `mcp` (FastMCP) with stdio transport - **Python 3.11+** — Type hints, async/await, hatchling build +- **All three MCP primitives** — Tools, Resources, and Prompts with working examples - **Safety Annotations** — readOnly/destructive/idempotent hints on every tool +- **Validated Prompts** — pydantic `@validate_call` rejects bad args before the handler runs - **Response Helpers** — `ok()` and `err()` for consistent tool responses - **Config** — Environment variable parsing pattern - **CI** — gitleaks, ruff, license compliance, pytest (3.11/3.12/3.13) @@ -99,6 +101,60 @@ from my_mcp_server.tools.your_tool import register register(mcp) ``` +## Adding Resources + +Resources expose read-only data to the client at a stable URI (contrast with Tools, which perform actions). + +See `src/my_mcp_server/resources/server_info.py` for the example. Pattern: + +```python +from mcp.server.fastmcp import FastMCP + + +def register(mcp: FastMCP) -> None: + @mcp.resource( + "info://your/resource", + name="your-resource", + description="What this resource exposes.", + mime_type="application/json", + ) + async def your_resource() -> str: + return "..." # str, bytes, or JSON-serializable object +``` + +Then in `server.py`: + +```python +from my_mcp_server.resources.your_resource import register as register_your_resource +register_your_resource(mcp) +``` + +## Adding Prompts + +Prompts are reusable, parameterized message templates. Arguments are validated via pydantic before the handler runs. + +See `src/my_mcp_server/prompts/code_review.py` for the example. Pattern: + +```python +from typing import Annotated, Literal + +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts.base import UserMessage +from pydantic import Field, validate_call + + +@validate_call +def your_prompt( + mode: Literal["short", "long"], + topic: Annotated[str, Field(min_length=1)], +) -> list[UserMessage]: + return [UserMessage(content=f"Write a {mode} note about {topic}.")] + + +def register(mcp: FastMCP) -> None: + mcp.prompt(name="your-prompt", title="Your Prompt")(your_prompt) +``` + ## Configuration Environment variables: @@ -150,11 +206,19 @@ src/my_mcp_server/ ├── __init__.py # Version ├── __main__.py # python -m entry point ├── server.py # FastMCP server + inline tools + helpers -└── tools/ +├── tools/ +│ ├── __init__.py +│ └── greet.py # Example modular tool +├── resources/ +│ ├── __init__.py +│ └── server_info.py # Example resource (info://server/status) +└── prompts/ ├── __init__.py - └── greet.py # Example modular tool + └── code_review.py # Example prompt (validated args) tests/ -└── test_tools.py # Tool tests +├── test_tools.py # Tool tests +├── test_server_info.py # Resource tests +└── test_code_review.py # Prompt tests .github/ ├── workflows/ │ ├── ci.yml # Lint, test, security diff --git a/src/my_mcp_server/prompts/__init__.py b/src/my_mcp_server/prompts/__init__.py new file mode 100644 index 0000000..3ef684e --- /dev/null +++ b/src/my_mcp_server/prompts/__init__.py @@ -0,0 +1 @@ +"""Prompts are reusable, parameterized message templates for MCP clients.""" diff --git a/src/my_mcp_server/prompts/code_review.py b/src/my_mcp_server/prompts/code_review.py new file mode 100644 index 0000000..b22bf7e --- /dev/null +++ b/src/my_mcp_server/prompts/code_review.py @@ -0,0 +1,56 @@ +"""Example MCP Prompt — a templated code review prompt. + +Prompts are reusable, parameterized message templates the client can surface to +the user or feed to the model. Arguments are validated via pydantic so empty +code or unsupported languages are rejected before the handler runs. +""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts.base import UserMessage +from pydantic import Field, validate_call + +NAME = "code-review" +TITLE = "Code Review" +DESCRIPTION = "Ask the model to review a snippet of code in the given language." + +Language = Literal["py", "js", "ts", "go"] + +_LANGUAGE_LABEL: dict[str, str] = { + "py": "Python", + "js": "JavaScript", + "ts": "TypeScript", + "go": "Go", +} + + +@validate_call +def code_review( + language: Language, + code: Annotated[str, Field(min_length=1, description="Source code to review.")], +) -> list[UserMessage]: + """Render a code-review prompt as a single-user-message template. + + Args: + language: Programming language of the snippet (one of py/js/ts/go). + code: Source code to review. Must be non-empty. + + Returns: + A list containing one user message with a language-labeled fenced + code block. + """ + label = _LANGUAGE_LABEL[language] + text = ( + f"Review this {label} code for bugs, readability, and idiomatic style. " + "Be specific and actionable.\n\n" + f"```{language}\n{code}\n```" + ) + return [UserMessage(content=text)] + + +def register(mcp: FastMCP) -> None: + """Register the code-review prompt on the server.""" + mcp.prompt(name=NAME, title=TITLE, description=DESCRIPTION)(code_review) diff --git a/src/my_mcp_server/resources/__init__.py b/src/my_mcp_server/resources/__init__.py new file mode 100644 index 0000000..144429c --- /dev/null +++ b/src/my_mcp_server/resources/__init__.py @@ -0,0 +1 @@ +"""Resources expose data to the MCP client at a stable URI.""" diff --git a/src/my_mcp_server/resources/server_info.py b/src/my_mcp_server/resources/server_info.py new file mode 100644 index 0000000..a2a235d --- /dev/null +++ b/src/my_mcp_server/resources/server_info.py @@ -0,0 +1,82 @@ +"""Example MCP Resource — exposes server metadata (name, version, runtime) at a +fixed URI. + +Resources are how you expose data to the client (in contrast to Tools which +perform actions). Replace with your own resource. +""" + +from __future__ import annotations + +import json +import platform +import sys +import tomllib +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +NAME = "server-info" +URI = "info://server/status" +TITLE = "Server Info" +DESCRIPTION = "Server metadata: name, version, Python runtime, and platform." +MIME_TYPE = "application/json" + + +def _read_pyproject() -> dict[str, str] | None: + """Walk up from this file to locate pyproject.toml and parse it. + + Returns the ``[project]`` table as a dict, or None if not found + (e.g. when installed from a wheel without the source tree). + """ + here = Path(__file__).resolve() + for parent in here.parents: + candidate = parent / "pyproject.toml" + if candidate.is_file(): + with candidate.open("rb") as fh: + data = tomllib.load(fh) + project = data.get("project") + if isinstance(project, dict): + return project + return None + return None + + +def _server_metadata() -> dict[str, object]: + project = _read_pyproject() + if project is not None: + pkg_name = str(project.get("name", "my-mcp-server")) + pkg_version = str(project.get("version", "0.0.0")) + else: + # Fallback for wheel installs where pyproject.toml isn't shipped. + pkg_name = "my-mcp-server" + try: + pkg_version = version(pkg_name) + except PackageNotFoundError: + pkg_version = "0.0.0" + + return { + "name": pkg_name, + "version": pkg_version, + "runtime": { + "python": sys.version.split()[0], + "platform": platform.system().lower(), + "arch": platform.machine(), + }, + } + + +async def server_info() -> str: + """Return server metadata as a JSON string.""" + return json.dumps(_server_metadata(), indent=2) + + +def register(mcp: FastMCP) -> None: + """Register the server-info resource on the server.""" + mcp.resource( + URI, + name=NAME, + title=TITLE, + description=DESCRIPTION, + mime_type=MIME_TYPE, + )(server_info) diff --git a/src/my_mcp_server/server.py b/src/my_mcp_server/server.py index 7828376..65ec224 100644 --- a/src/my_mcp_server/server.py +++ b/src/my_mcp_server/server.py @@ -10,6 +10,9 @@ from mcp.server.fastmcp import FastMCP from mcp.types import ToolAnnotations +from my_mcp_server.prompts.code_review import register as register_code_review +from my_mcp_server.resources.server_info import register as register_server_info + logger = logging.getLogger("my_mcp_server") # --------------------------------------------------------------------------- @@ -78,6 +81,20 @@ async def greet(name: str) -> str: # See tools/greet.py for the modular pattern. +# --------------------------------------------------------------------------- +# Resources — expose data to the client at a stable URI +# --------------------------------------------------------------------------- + +register_server_info(mcp) + + +# --------------------------------------------------------------------------- +# Prompts — reusable, parameterized message templates +# --------------------------------------------------------------------------- + +register_code_review(mcp) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/tests/test_code_review.py b/tests/test_code_review.py new file mode 100644 index 0000000..961a0a6 --- /dev/null +++ b/tests/test_code_review.py @@ -0,0 +1,77 @@ +"""Tests for the code-review prompt.""" + +import pytest +from mcp.server.fastmcp.prompts.base import UserMessage +from pydantic import ValidationError + +from my_mcp_server.prompts.code_review import ( + DESCRIPTION, + NAME, + TITLE, + code_review, +) + + +def test_identity_metadata_is_stable() -> None: + """Name, title, and description are part of the public contract.""" + assert NAME == "code-review" + assert TITLE == "Code Review" + assert isinstance(DESCRIPTION, str) and DESCRIPTION + + +def test_rejects_empty_code() -> None: + """Empty ``code`` fails pydantic validation before the handler runs.""" + with pytest.raises(ValidationError): + code_review(language="py", code="") + + +def test_rejects_unsupported_language() -> None: + """Language outside the Literal enum is rejected.""" + with pytest.raises(ValidationError): + code_review(language="ruby", code="puts 1") # type: ignore[arg-type] + + +def test_rejects_missing_fields() -> None: + """Both arguments are required.""" + with pytest.raises(ValidationError): + code_review(language="py") # type: ignore[call-arg] + with pytest.raises(ValidationError): + code_review(code="x = 1") # type: ignore[call-arg] + + +def test_happy_path_returns_single_user_message() -> None: + """Valid input produces one user message with the expected shape.""" + code = "def add(a, b):\n return a + b" + messages = code_review(language="py", code=code) + + assert isinstance(messages, list) + assert len(messages) == 1 + + msg = messages[0] + assert isinstance(msg, UserMessage) + assert msg.role == "user" + assert msg.content.type == "text" # type: ignore[union-attr] + text = msg.content.text # type: ignore[union-attr] + assert "Python" in text + assert "```py" in text + assert code in text + + +def test_language_label_interpolated_per_enum_value() -> None: + """Each supported language maps to a human-readable label.""" + code = "x = 1" + cases = {"py": "Python", "js": "JavaScript", "ts": "TypeScript", "go": "Go"} + for lang, label in cases.items(): + messages = code_review(language=lang, code=code) # type: ignore[arg-type] + text = messages[0].content.text # type: ignore[union-attr] + assert label in text + assert f"```{lang}\n{code}\n```" in text + + +async def test_registered_on_server() -> None: + """The prompt is wired into the server at import time.""" + from my_mcp_server.server import mcp + + prompts = await mcp.list_prompts() + names = [p.name for p in prompts] + assert NAME in names diff --git a/tests/test_server_info.py b/tests/test_server_info.py new file mode 100644 index 0000000..183eaf0 --- /dev/null +++ b/tests/test_server_info.py @@ -0,0 +1,65 @@ +"""Tests for the server-info resource.""" + +import json +import sys +import tomllib +from pathlib import Path + +from my_mcp_server.resources.server_info import ( + DESCRIPTION, + MIME_TYPE, + NAME, + URI, + server_info, +) + + +def _pyproject_project() -> dict[str, str]: + root = Path(__file__).resolve().parents[1] + with (root / "pyproject.toml").open("rb") as fh: + return tomllib.load(fh)["project"] + + +def test_identity_metadata_is_stable() -> None: + """Name, URI, and mime type are part of the public contract.""" + assert NAME == "server-info" + assert URI == "info://server/status" + assert MIME_TYPE == "application/json" + assert isinstance(DESCRIPTION, str) and DESCRIPTION + + +async def test_returns_json_with_expected_shape() -> None: + """Handler returns a JSON string shaped per the MCP resource contract.""" + raw = await server_info() + assert isinstance(raw, str) + + payload = json.loads(raw) + assert set(payload) == {"name", "version", "runtime"} + assert set(payload["runtime"]) == {"python", "platform", "arch"} + + assert isinstance(payload["name"], str) and payload["name"] + assert isinstance(payload["version"], str) and payload["version"] + + +async def test_version_matches_pyproject() -> None: + """Version field reflects pyproject.toml, not a hardcoded constant.""" + project = _pyproject_project() + payload = json.loads(await server_info()) + + assert payload["name"] == project["name"] + assert payload["version"] == project["version"] + + +async def test_runtime_reflects_current_interpreter() -> None: + """Python version in runtime matches sys.version.""" + payload = json.loads(await server_info()) + assert payload["runtime"]["python"] == sys.version.split()[0] + + +async def test_registered_on_server() -> None: + """The resource is wired into the server at import time.""" + from my_mcp_server.server import mcp + + resources = await mcp.list_resources() + uris = [str(r.uri) for r in resources] + assert URI in uris