MCP Python SDK v2와 FastMCP 4로 MCP 서버 만들기 비교
2026년 7월 28일, MCP Python SDK 2.0.0이 나왔다. MCP 스펙 2026-07-28 개정판을 구현한 버전인데, 이 개정판에서는 initialize 핸드셰이크와 세션이 없어지고 서버가 클라이언트에 먼저 요청을 보내는 방식도 빠졌다. SDK 쪽 변화도 컸다. v1에서 고수준 서버 클래스로 쓰던 FastMCP가 MCPServer로 이름을 바꿨고, 저수준 Server는 사실상 새로 만들어졌다. 그리고 한 달 뒤인 8월 31일, 별도 프로젝트인 FastMCP가 4.0.0을 내면서 SDK v2 위로 올라갔다.
SDK v1에는 SDK 안의 mcp.server.fastmcp.FastMCP와 fastmcp 패키지의 FastMCP라는, 이름이 같은 클래스가 두 개 있었다. 이번에 SDK 쪽 이름이 MCPServer로 바뀌면서 둘은 이름부터 구분되게 됐다. 예전에는 이름이 겹쳐서 업무 중에 FastMCP 이야기를 할 때면 SDK에 들어 있는 쪽이 아니라 gofastmcp 쪽이라고 굳이 덧붙여 설명해야 했는데, 이제는 그럴 필요가 없어졌다. 이번 글에서는 같은 MCP 서버를 SDK v2의 MCPServer와 FastMCP 4로 각각 만들어 보고, 설치와 tool 스키마부터 에러 메시지, 실행 방식, 테스트처럼 사소한 부분까지 하나씩 비교해 보려고 한다.
비교에 쓴 버전은 mcp 2.3.0과 fastmcp 4.1.1이다. 글에 나오는 출력은 모두 macOS, Python 3.12 환경에서 실행했다.
1. 두 프로젝트의 관계
FastMCP는 원래 jlowin이 만든 프로젝트다. 공개 한 달여 만에 공식 SDK에 포함됐고, 그 뒤로는 SDK 안의 FastMCP와 독립 패키지 FastMCP가 따로 발전해 왔다. 연혁을 정리하면 다음과 같다.
| 날짜 | 내용 |
|---|---|
| 2024-11-25 | MCP Python SDK 1.0.0 공개 |
| 2024-11-30 | FastMCP 0.1.0 공개 |
| 2025-01-03 | SDK 1.2.0에 FastMCP가 mcp.server.fastmcp 모듈로 포함됨 (FastMCP 문서에서는 이것을 FastMCP 1.0이라고 부른다) |
| 2025-04-11 | 독립 패키지 FastMCP 2.0.0 공개 |
| 2026-02-18 | FastMCP 3.0.0 공개 |
| 2026-07-28 | SDK 2.0.0 공개, FastMCP 클래스가 MCPServer로 이름 변경 |
| 2026-08-31 | FastMCP 4.0.0 공개, SDK v2 기반으로 전환 |
두 프로젝트의 기본 정보는 다음과 같다.
| 구분 | MCP Python SDK v2 | FastMCP 4 |
|---|---|---|
| 패키지 | mcp |
fastmcp |
| 저장소 | modelcontextprotocol/python-sdk |
PrefectHQ/fastmcp |
| 관리 주체 | MCP 공식 SDK | Prefect |
| 라이선스 | MIT | Apache 2.0 |
| 고수준 서버 클래스 | mcp.server.MCPServer |
fastmcp.FastMCP |
| 저수준 서버 | mcp.server.Server |
없음 |
| 지원 프로토콜 | 2026-07-28과 그 이전 개정판 | 같음 |
| Python | 3.10 이상 | 3.10 이상 |
여기서 짚고 갈 점은 FastMCP 4가 SDK v2와 별개로 프로토콜을 구현한 프로젝트가 아니라는 것이다. fastmcp를 설치하면 mcp 2.x가 함께 설치되고, 프로토콜 타입(mcp_types)과 transport는 SDK v2의 것을 그대로 쓴다. FastMCP는 그 위에 서버를 구성하는 기능을 더 얹은 프레임워크다. 그래서 이 비교는 SDK v2만 쓸지, SDK v2 위에 FastMCP를 더 얹을지 고르는 문제에 가깝다.
2. 설치와 의존성
# MCP Python SDK v2
uv add "mcp[cli]"
# FastMCP 4
uv add fastmcp
SDK는 mcp 명령어(mcp dev, mcp run, mcp install)를 쓰려면 [cli] extra를 붙여야 한다. FastMCP는 extra 없이도 fastmcp 명령어가 함께 설치된다.
설치되는 양은 꽤 차이가 났다. 빈 가상환경에 extra 없이 각각 설치해 보니 다음과 같았다.
| 구분 | mcp 2.3.0 |
fastmcp 4.1.1 |
|---|---|---|
| 설치된 패키지 수 | 28개 | 66개 |
| site-packages 크기 | 약 32MB | 약 69MB |
FastMCP는 SDK 전체를 의존성으로 가져가고, 그 위에 인증(authlib, joserfc), CLI(cyclopts, rich), OpenAPI 변환, 저장소(py-key-value-aio) 같은 의존성이 더 붙는다. 컨테이너 이미지 크기에 민감한 환경이라면 고려할 만한 차이다.
실제로 라이브러리로 사용하면서 영향을 받은 부분은 SDK v2부터 HTTP 클라이언트가 httpx에서 httpx2로 바뀐 점이었다. FastMCP 4도 같은 httpx2를 쓴다. httpx2는 certifi에 들어 있는 CA 목록 대신 운영체제의 인증서 저장소로 TLS 인증서를 검증한다. 그래서 시스템 CA가 없는 최소 컨테이너나 사설 CA를 쓰는 환경에서는 SSL_CERT_FILE이나 SSL_CERT_DIR을 따로 지정해야 할 수 있다.
전환하면서 고쳐야 했던 부분은 두 가지였다. transport에 넘기는 인증 객체가 httpx2.Auth여야 해서 직접 만든 Auth 클래스를 모두 바꿔야 했고, streamable HTTP 클라이언트가 401 응답을 상태 코드 없이 JSON-RPC INTERNAL_ERROR로 바꿔 버려서 401로 하던 재인증 판단이 동작하지 않았다.
3. 최소 구현 서버로 비교해 보면
tool 하나짜리 서버를 두 방식으로 만들면 다음과 같다.
# MCP Python SDK v2
from mcp.server import MCPServer
mcp = MCPServer("Demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
if __name__ == "__main__":
mcp.run()
# FastMCP 4
from fastmcp import FastMCP
mcp = FastMCP("Demo")
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
if __name__ == "__main__":
mcp.run()
거의 같고, 다른 곳은 import 경로와 데코레이터 괄호 정도다.
- import 경로: SDK v2는
from mcp.server import MCPServer이고,mcp.server.mcpserver에서도 가져올 수 있다. v1의from mcp.server.fastmcp import FastMCP는 deprecated 단계 없이 바로 삭제됐다. - 데코레이터 괄호: SDK v2는
@mcp.tool()처럼 괄호를 반드시 붙여야 한다. 괄호를 빼면 등록하는 시점에TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool가 난다. FastMCP는@mcp.tool과@mcp.tool()을 모두 받는다. - 데코레이터의 반환값: 둘 다 원래 함수를 그대로 돌려준다. 그래서
add(1, 2)처럼 함수를 직접 호출해서 단위 테스트를 할 수 있다. - 기본 transport:
mcp.run()에 아무것도 넘기지 않으면 둘 다 stdio로 실행된다.
3-1. SDK의 저수준 Server
SDK v2에는 MCPServer 아래에 저수준 Server도 있다. 스키마와 결과 객체를 직접 만들어야 하는 대신 프로토콜을 세밀하게 다룰 수 있는 API다. 같은 tool을 저수준 Server로 만들면 다음과 같다.
from mcp import MCPError
from mcp.server import Server, ServerRequestContext
from mcp.types import (
INVALID_PARAMS,
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
ADD = Tool(
name="add",
description="Add two numbers.",
input_schema={
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
},
)
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[ADD])
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
if params.name != "add":
raise MCPError(INVALID_PARAMS, f"Unknown tool: {params.name}")
args = params.arguments or {}
return CallToolResult(content=[TextContent(type="text", text=str(args["a"] + args["b"]))])
server = Server("Demo", on_list_tools=list_tools, on_call_tool=call_tool)
v1의 저수준 Server는 @server.call_tool() 같은 데코레이터로 handler를 등록했는데, v2에서는 생성자 인자(on_list_tools, on_call_tool)로 넘긴다. 필드 이름도 inputSchema에서 input_schema처럼 snake_case로 바뀌었다. (pythonic해진 건가?) 다만 바뀐 것은 파이썬에서 쓰는 속성 이름뿐이고, 클라이언트와 주고받는 JSON에는 그대로 inputSchema로 나간다. 모델을 model_dump()로 직접 직렬화할 때 by_alias=True를 빠뜨리면 input_schema 같은 snake_case 키가 그대로 나가서 다른 MCP 구현이 알아보지 못한다.
v1과 가장 크게 달라진 점은 검증이다. v2의 저수준 Server는 input_schema를 클라이언트에 알려 주기만 하고 인자를 검증하지 않는다. 실제로 위 서버에 {"a": "x", "b": 2}를 보내 보니 handler 안에서 TypeError가 났고, 클라이언트는 내용이 가려진 Internal server error(-32603)만 받았다. params.arguments가 None일 수도 있어서 or {}도 빼면 안 된다.
FastMCP에는 이 수준의 API가 따로 없다. 그래서 아래부터는 MCPServer와 FastMCP를 비교한다.
4. Tool 정의
tool 몇 개를 가진 서점 서버를 두 방식으로 똑같이 만들어서 비교했다. 그리고 각 프레임워크의 인메모리 클라이언트로 2026-07-28 방식으로 연결해서 tools/list와 tools/call 결과를 뽑았다. 아래 코드는 FastMCP 쪽이고, SDK 쪽은 import와 데코레이터 괄호만 다르다.
from typing import Annotated
from pydantic import BaseModel, Field
from fastmcp import FastMCP
mcp = FastMCP("Bookshop")
class Book(BaseModel):
title: str
author: str
year: int
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@mcp.tool
def search_books(query: str, limit: int = 10) -> list[Book]:
"""Search the catalog.
Args:
query: Title or author to search for.
limit: Maximum number of results.
"""
return [Book(title="Dune", author="Frank Herbert", year=1965)][:limit]
@mcp.tool
def describe(
genre: Annotated[str, "Book genre"],
page: Annotated[int, Field(description="Page number", ge=1, le=50)] = 1,
) -> dict:
"""Describe a genre."""
return {"genre": genre, "page": page}
@mcp.tool
def add_book(book: Book) -> str:
"""Add a book."""
return f"added {book.title}"
4-1. 이름, 설명, title
tool 이름은 둘 다 함수 이름을 쓰고, 설명은 docstring에서 가져온다. 차이는 docstring을 얼마나 해석하느냐와 title에서 생긴다.
search_books처럼 docstring에 Args: 섹션이 있으면 결과가 꽤 다르다. SDK는 docstring 전체를 description에 그대로 넣는다.
"description": "Search the catalog.\n\n Args:\n query: Title or author to search for.\n limit: Maximum number of results.\n "
FastMCP는 Args: 섹션을 파싱해서 위쪽 문장만 tool 설명으로 쓰고, 각 항목은 파라미터 설명으로 옮긴다. Google, NumPy, Sphinx 스타일 docstring을 모두 지원한다.
"description": "Search the catalog.",
"inputSchema": {
"properties": {
"query": {"type": "string", "description": "Title or author to search for."},
"limit": {"default": 10, "type": "integer", "description": "Maximum number of results."}
}
}
SDK에서 파라미터 설명을 넣으려면 Annotated[int, Field(description="...")]를 써야 한다. FastMCP는 이 방식도 지원하고, Annotated[str, "Book genre"]처럼 문자열만 넣어도 설명으로 인식한다. SDK는 이 문자열을 무시해서 describe의 genre에는 설명이 붙지 않았다.
title도 다르다. FastMCP는 title을 지정하지 않으면 tool 이름으로 만들어 붙인다. search_books는 Search Books, add는 Add가 된다. FastMCP 문서에 따르면 title이 없는 tool을 목록에서 빼 버리는 클라이언트가 있어서다. SDK는 지정하지 않으면 title을 보내지 않는다.
그리고 FastMCP는 tool, resource, prompt 목록의 _meta에 {"fastmcp": {"tags": []}}를 붙여 보낸다. 태그를 쓰지 않아도 빈 배열이 들어간다.
4-2. 입력 스키마
같은 add 함수에서 나온 입력 스키마를 비교해 보면 다음과 같다. 먼저 SDK v2다.
{
"type": "object",
"properties": {
"a": {"title": "A", "type": "integer"},
"b": {"title": "B", "type": "integer"}
},
"required": ["a", "b"],
"title": "addArguments"
}
다음은 FastMCP 4다.
{
"type": "object",
"additionalProperties": false,
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"}
},
"required": ["a", "b"]
}
세 가지가 다르다.
- SDK는 Pydantic이 만든
title키("A","addArguments")를 그대로 남기고, FastMCP는 이 키를 지운다. - FastMCP는
"additionalProperties": false를 붙인다. 이 차이는 아래 4-3에서 실제 동작 차이로 이어진다. add_book(book: Book)처럼 파라미터에 Pydantic 모델이 들어가면 SDK는$defs와$ref로 참조를 남긴다. FastMCP는 참조를 풀어서 스키마 안에 펼쳐 넣는다.$ref를 제대로 처리하지 못하는 클라이언트가 있어서 넣은 동작이고,FastMCP("Bookshop", dereference_schemas=False)로 끌 수 있다.
4-3. 인자 검증
검증은 둘 다 Pydantic으로 한다. 같은 입력을 두 서버에 보내 보니 결과는 다음과 같았다.
| 입력 | SDK v2 | FastMCP 4 |
|---|---|---|
add에 {"a": 1, "b": 2} |
성공, 3 |
성공, 3 |
add에 {"a": "10", "b": "20"} |
문자열을 정수로 바꿔서 성공, 30 |
문자열을 정수로 바꿔서 성공, 30 |
add에 {"a": "ten", "b": 2} |
is_error=True |
is_error=True |
add에 {"a": 1, "b": 2, "extra": "x"} |
extra를 무시하고 성공, 3 |
is_error=True, Unexpected keyword argument |
describe에 page=999 (le=50) |
is_error=True |
is_error=True |
가장 눈에 띄는 차이는 정의하지 않은 인자다. SDK는 아는 인자만 꺼내 쓰고 나머지는 조용히 버리지만, FastMCP는 스키마의 additionalProperties: false대로 호출 자체를 거부한다. 클라이언트가 인자 외에 다른 값을 같이 실어 보내는 환경이라면, SDK에서 FastMCP로 바꾸는 순간 그 호출이 실패하기 시작한다.
FastMCP 서버에서는 이 동작 때문에 모델마다 결과가 달랐다. Claude는 tool을 해석하고 호출할 때 필요 없는 인자는 알아서 빼고 호출해서 성공했는데, OpenAI 모델은 "extra": ""처럼 스키마에 없는 인자를 빈 값으로 채워 보내서 실패하는 경우가 꽤 많았다. 그래서 시스템 프롬프트에 더 엄격한 지시를 넣고 tool description도 더 자세하게 써야 했다.
에러 메시지 형식도 조금 다르다. SDK는 Error executing tool add: 1 validation error for addArguments ...처럼 tool 이름을 앞에 붙이고, FastMCP는 1 validation error for call[add] ...로 시작한다.
"10"을 정수로 바꿔 주는 동작은 FastMCP에서 FastMCP("Bookshop", strict_input_validation=True)로 끌 수 있다. SDK의 MCPServer에는 서버 단위로 이 동작을 바꾸는 옵션이 없다.
4-4. 반환값과 structured output
반환값은 모델이 읽는 content와 클라이언트 애플리케이션이 쓰는 structuredContent로 나뉘어 나간다. 기본 흐름은 같지만 반환 타입에 따라 결과가 꽤 달랐다.
| 반환 타입 | SDK v2 | FastMCP 4 |
|---|---|---|
-> int |
content는 "3", structuredContent는 {"result": 3} |
같음 |
-> list[...] |
항목마다 text 블록을 하나씩 만들고, 들여쓰기한 JSON을 넣음 | 리스트 전체를 공백 없는 JSON 한 줄로 만들어 블록 하나에 넣음 |
-> dict |
outputSchema와 structuredContent가 없음 |
outputSchema는 {"type": "object", "additionalProperties": true}, structuredContent에는 dict가 그대로 들어감 |
-> None |
content는 비어 있고, structuredContent는 {"result": null} |
content와 structuredContent 모두 비어 있음 |
리스트를 반환할 때 차이가 잘 보인다. [{"t": 1}, {"t": 2}]를 반환하는 tool의 content를 비교해 보면 다음과 같다.
# SDK v2: text 블록 2개
['{\n "t": 1\n}', '{\n "t": 2\n}']
# FastMCP 4: text 블록 1개
['[{"t":1},{"t":2}]']
모델 입장에서 보면 SDK는 사람이 읽기 좋은 들여쓰기 JSON을, FastMCP는 토큰을 덜 쓰는 압축 JSON을 받는 셈이다.
FastMCP는 결과의 _meta에 {"fastmcp": {"wrap_result": true}}를, outputSchema에는 x-fastmcp-wrap-result를 붙인다. FastMCP 클라이언트는 이 표시를 보고 {"result": 3}으로 감싼 값을 다시 풀어서 result.data로 3을 돌려준다.
structured output을 아예 끄고 싶다면 SDK는 @mcp.tool(structured_output=False), FastMCP는 @mcp.tool(output_schema=None)을 쓴다. 둘 다 outputSchema와 structuredContent가 빠지는 것을 확인했다.
4-5. 동기 함수 실행과 timeout
def로 만든 동기 tool은 둘 다 이벤트 루프가 아닌 worker thread에서 실행된다. 실제로 tool 안에서 threading.current_thread().name을 찍어 보면 둘 다 AnyIO worker thread가 나온다. SDK는 v2에서 이렇게 바뀌었는데, v1에서는 동기 tool이 이벤트 루프에서 바로 실행돼서 오래 걸리는 tool 하나가 다른 요청까지 막았다.
차이는 이 동작을 끄는 옵션에 있다. FastMCP는 @mcp.tool(run_in_thread=False)로 동기 함수를 이벤트 루프 스레드에서 실행하게 할 수 있다. Windows COM이나 tkinter처럼 스레드에 묶이는 라이브러리를 쓸 때 필요한 옵션이다. SDK에는 이 옵션이 없고, 이벤트 루프에서 실행하려면 async def로 바꾸라고 안내한다.
FastMCP는 @mcp.tool(timeout=30)처럼 tool별 실행 시간 제한도 걸 수 있다. SDK의 @mcp.tool()에는 이 인자가 없다.
4-6. 같은 이름의 tool을 다시 등록할 때
같은 이름의 함수를 두 번 등록하면 둘 다 경고를 남기지만, 어느 쪽이 남는지가 반대다.
| 구분 | SDK v2 | FastMCP 4 |
|---|---|---|
| 경고 메시지 | Tool already exists: dup |
Component already exists: tool:dup@ |
| 실제로 남는 tool | 먼저 등록한 것 | 나중에 등록한 것 |
| 동작 변경 | warn_on_duplicate_tools=False로 경고만 끔 |
on_duplicate="error", "replace", "ignore", "warn" 중 선택 |
모듈을 나눠서 tool을 등록하다 보면 이름이 겹치는 일이 생길 수 있다. FastMCP에서는 on_duplicate="error"로 두면 이런 실수를 서버가 뜨는 시점에 잡을 수 있다.
5. 에러 처리
tool 안에서 예외가 났을 때의 동작이 가장 크게 다른 부분이었다. ToolError, 일반 예외(ValueError), 없는 tool 호출을 두 서버에 똑같이 보내 보니 결과는 다음과 같았다.
| 상황 | SDK v2 | FastMCP 4 |
|---|---|---|
raise ToolError("No book titled 'Nothing'") |
Error executing tool fail_tool_error: No book titled 'Nothing' |
No book titled 'Nothing' |
raise ValueError("secret internal detail for Nothing") |
Error executing tool fail_value_error |
Error calling tool 'fail_value_error': secret internal detail for Nothing |
| 없는 tool 호출 | Unknown tool: nope |
Unknown tool: 'nope' |
세 경우 모두 결과는 is_error=True로 돌아온다. ToolError의 import 경로는 다른데, SDK는 mcp.server.mcpserver.exceptions, FastMCP는 fastmcp.exceptions에서 가져온다.
중요한 것은 두 번째 줄이다. SDK는 ToolError가 아닌 예외의 메시지를 모델에게 보내지 않는다. 예외 메시지에 DB 접속 정보나 내부 경로처럼 서버 내부 정보가 섞여 있을 수 있어서이고, 전체 traceback은 서버 로그에만 남긴다. 반면 FastMCP는 기본값에서 예외 메시지를 그대로 붙여서 보낸다. SDK와 같은 동작을 원하면 mask_error_details를 켜야 한다.
mcp = FastMCP("Bookshop", mask_error_details=True)
# 이제 ValueError가 나면 "Error calling tool 'fail_value_error'"만 전달된다.
환경 변수 FASTMCP_MASK_ERROR_DETAILS=true로도 켤 수 있다. 마스킹을 켜도 FastMCP는 외부 API가 429를 돌려준 경우와 timeout은 Rate limited by upstream API, please retry later, Upstream request timed out, please retry처럼 모델이 다시 시도할 수 있는 메시지로 바꿔 보낸다.
SDK 문서는 tool에서 낼 수 있는 오류를 세 가지로 나눈다. 모델이 인자를 바꿔서 해결할 수 있는 오류는 ToolError로 내고, 요청 자체를 거부해야 하는 경우는 MCPError로 낸다. MCPError는 tool 결과가 아니라 JSON-RPC 오류로 나가서 모델은 볼 수 없다. 그 밖의 예외는 서버 오류로 취급해서 메시지를 숨긴다. 기준이 분명해서 FastMCP를 쓰더라도 참고할 만하다.
6. Resource와 Prompt
resource와 prompt는 두 방식의 차이가 가장 작은 부분이다.
# MCP Python SDK v2
@mcp.resource("config://app", mime_type="application/json")
def app_config() -> str:
"""App configuration."""
return '{"debug": false}'
@mcp.resource("books://{title}")
def book_by_title(title: str) -> str:
"""A book by title."""
return f"Book: {title}"
@mcp.prompt()
def summarize(text: str) -> str:
"""Summarize text."""
return f"Summarize:\n\n{text}"
FastMCP는 @mcp.prompt에서 괄호를 뺄 수 있다는 점 말고는 코드가 같다. resources/read와 prompts/get 결과도 같았고, 목록 결과에 FastMCP가 태그 정보를 붙이는 것만 달랐다.
resource 템플릿의 보안 기본값도 같다. 두 서버 모두 템플릿 파라미터에 ../ 같은 경로 이동, 절대 경로, null 바이트가 들어오면 handler를 실행하기 전에 거부한다. 생성자의 resource_security 기본값이 둘 다 이 세 가지를 막도록 되어 있다.
7. Context
tool 함수에 ctx: Context 파라미터를 선언하면 요청 Context를 주입받는 방식은 같다. 이 파라미터는 입력 스키마에 나타나지 않는다. 다만 두 Context는 서로 다른 클래스(mcp.server.mcpserver.Context, fastmcp.Context)라서, 이름이 같은 메서드도 사용법이 다른 경우가 있다. import만 바꾸면 실행은 되는데 호출하는 순간 실패하는 부분이라 정리해 두었다.
| 용도 | SDK v2 | FastMCP 4 |
|---|---|---|
| 서버 객체 | ctx.mcp_server |
ctx.fastmcp |
| lifespan에서 만든 값 | ctx.request_context.lifespan_context |
ctx.lifespan_context |
| HTTP 헤더 | ctx.headers |
get_http_headers() 함수 |
| 프로토콜 버전 | ctx.protocol_version |
ctx.request_context.protocol_version |
| 로그 | ctx.log(level, data) |
ctx.log(message, level=...) |
ctx.info() 등의 인자 |
JSON으로 바꿀 수 있는 값이면 무엇이든 | 문자열 message |
| elicitation | ctx.elicit(message, schema=Model) |
ctx.elicit(message, response_type=Model) |
| 목록 변경 알림 | ctx.notify_tools_changed() 등 |
ctx.send_notification(...) |
| 파라미터 없이 Context 얻기 | 없음 (get_context() 삭제) |
get_context() |
| 요청 하나 안에서 값 공유 | 없음 | ctx.set_state(), ctx.get_state() |
로그와 elicitation은 인자 순서와 키워드가 다르다. 예를 들어 ctx.log("info", "hello")는 SDK에서는 정상이지만 FastMCP에서는 "info"가 메시지가 된다.
MCP 로깅 자체도 변화가 있다. 2026-07-28 개정판과 함께 SEP-2577로 sampling, roots, MCP 레벨 로깅이 deprecated 됐다. 그래서 두 방식 모두 ctx.info()를 호출하면 MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). 경고가 뜬다. 동작은 계속하지만, 새로 만드는 서버라면 일반 Python 로깅이나 OpenTelemetry 쪽으로 가는 편이 좋다.
8. 사용자에게 입력 받기
2026-07-28 개정판에서는 서버가 클라이언트에 먼저 요청을 보내는 통로가 없어졌다. 그래서 tool 실행 중에 사용자에게 값을 물어보는 elicitation 방식이 바뀌었는데, 이 부분에서 두 프로젝트의 설계가 가장 크게 갈린다.
SDK v2는 Resolve라는 의존성 주입 방식을 새로 넣었다. resolver 함수가 Elicit(...)를 반환하면 SDK가 연결의 프로토콜 버전을 보고 알맞은 방식으로 질문을 보낸다. 2025-11-25 이전 개정판으로 연결한 클라이언트(이하 legacy)에는 예전처럼 elicitation 요청을 보낸다. 2026-07-28 방식으로 연결한 클라이언트에는 tool 결과로 질문을 돌려준 뒤, 답이 붙은 재호출을 받아 이어서 실행하는 multi-round-trip 방식을 쓴다.
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
mcp = MCPServer("Bookshop")
class Quantity(BaseModel):
copies: int
async def ask_quantity() -> Elicit[Quantity]:
return Elicit("How many copies?", Quantity)
@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
"""Reserve copies of a book."""
if isinstance(quantity, AcceptedElicitation):
return f"Reserved {quantity.data.copies} of {title!r}."
return "Nothing reserved."
quantity는 resolver가 채우는 값이라 입력 스키마에 나타나지 않는다. 실제로 reserve의 입력 스키마에는 title만 있었다.
FastMCP 4에는 Resolve가 없다. 대신 연결 방식에 따라 코드를 직접 나눠 써야 한다. legacy 연결에서는 ctx.elicit()가 동작하고, 2026-07-28 연결에서는 tool이 InputRequiredResult를 반환한 뒤 다음 호출에서 ctx.input_responses로 답을 읽는 guard 패턴을 쓴다.
from fastmcp import Context, FastMCP
from mcp.types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult
mcp = FastMCP("Bookshop")
@mcp.tool
async def reserve(title: str, ctx: Context) -> str | InputRequiredResult:
"""Reserve copies of a book."""
answers = ctx.input_responses
if answers is None:
return InputRequiredResult(
result_type="input_required",
input_requests={
"quantity": ElicitRequest(
method="elicitation/create",
params=ElicitRequestFormParams(
message="How many copies?",
requested_schema={
"type": "object",
"properties": {"copies": {"type": "integer"}},
"required": ["copies"],
},
),
)
},
)
response = answers["quantity"]
if response.action != "accept" or response.content is None:
return "Nothing reserved."
return f"Reserved {response.content['copies']} of {title!r}."
같은 tool을 네 가지 방식으로 만들어서 두 종류의 연결로 호출해 보니 결과는 다음과 같았다.
| 방식 | 2026-07-28 연결 | legacy 연결 (2025-11-25) |
|---|---|---|
SDK Resolve + Elicit |
동작 | 동작 |
SDK ctx.elicit() |
NoBackChannelError |
동작 |
FastMCP ctx.elicit() |
is_error=True, elicitation via server-initiated requests is unavailable on 2026-07-28 connections. |
동작 |
| FastMCP guard 패턴 | 동작 | MCPError, multi-round-trip 결과 타입은 2026-07-28에만 있다는 메시지 |
FastMCP에서 두 연결을 모두 지원하려면 ctx.request_context.protocol_version을 보고 분기해야 한다. tool 코드 하나로 두 세대의 클라이언트를 함께 상대해야 한다면 SDK의 Resolve 쪽이 손이 덜 간다. FastMCP 문서도 Resolve를 쓰던 서버를 옮기는 일은 이름만 바꾸는 작업이 아니라고 따로 안내한다.
sampling과 roots도 비슷하게 갈린다. FastMCP 4는 ctx.sample()과 ctx.list_roots()를 아예 없앴고, 서버에서 직접 LLM을 호출하거나 guard 패턴으로 요청하라고 안내한다. SDK v2는 deprecated 경고만 띄우고 legacy 연결에서는 계속 동작하게 두었으며, resolver에서 Sample이나 ListRoots를 반환하는 방법도 제공한다.
9. lifespan과 의존성 주입
서버가 뜰 때 DB 연결 같은 공용 자원을 만들고, 내려갈 때 정리하는 lifespan은 두 방식 모두 있다. 다만 작성법이 다르다.
# MCP Python SDK v2
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
@dataclass
class AppContext:
db: dict
@asynccontextmanager
async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]:
db = {"Dune": 7}
try:
yield AppContext(db=db)
finally:
db.clear()
mcp = MCPServer("Bookshop", lifespan=app_lifespan)
@mcp.tool()
def count_books(title: str, ctx: Context[AppContext]) -> str:
"""Count copies of a book."""
db = ctx.request_context.lifespan_context.db
return f"{db.get(title, 0)} copies of {title!r}"
# FastMCP 4
from fastmcp import Context, FastMCP
from fastmcp.server.lifespan import lifespan
@lifespan
async def app_lifespan(server):
db = {"Dune": 7}
try:
yield {"db": db}
finally:
db.clear()
mcp = FastMCP("Bookshop", lifespan=app_lifespan)
@mcp.tool
def count_books(title: str, ctx: Context) -> str:
"""Count copies of a book."""
db = ctx.lifespan_context["db"]
return f"{db.get(title, 0)} copies of {title!r}"
SDK는 표준 asynccontextmanager를 쓰고 원하는 객체를 yield하며, Context[AppContext]로 타입을 붙일 수 있다. FastMCP는 @lifespan 데코레이터를 쓰고 dict를 yield한다. 대신 config_lifespan | data_lifespan처럼 | 연산자로 여러 lifespan을 합칠 수 있다. 둘 다 streamable HTTP에서 lifespan은 서버가 뜰 때 한 번만 실행된다. SDK v1에서는 세션마다 실행됐기 때문에, v1에서 넘어온다면 세션별 자원을 lifespan에서 만들던 코드는 handler 쪽으로 옮겨야 한다.
tool 인자 중 모델이 아니라 서버가 채워야 하는 값을 주입하는 방법도 다르다. SDK는 8장에서 본 Resolve를, FastMCP는 FastAPI와 비슷한 Depends를 쓴다.
# MCP Python SDK v2
from typing import Annotated
from mcp.server.mcpserver import Resolve
async def current_user() -> str:
return "seoyul"
@mcp.tool()
def whoami(user: Annotated[str, Resolve(current_user)]) -> str:
return user
# FastMCP 4
from fastmcp.dependencies import Depends
def current_user() -> str:
return "seoyul"
@mcp.tool
def whoami(user: str = Depends(current_user)) -> str:
return user
둘 다 주입되는 파라미터는 입력 스키마에서 빠진다. SDK의 Resolve는 resolver 함수의 파라미터 이름이 tool 인자 이름과 같으면 검증된 tool 인자를 넘겨주고, Elicit을 반환해서 사용자에게 값을 물어볼 수도 있다. FastMCP의 Depends는 요청 단위 캐시, 중첩 의존성, async context manager를 이용한 정리 작업을 지원한다. 그리고 CurrentContext(), get_http_headers(), access token처럼 자주 쓰는 값은 미리 만들어 둔 의존성으로 제공한다.
10. 미들웨어
미들웨어는 구조부터 다르다. SDK는 모든 메시지를 감싸는 함수 하나로 만든다.
# MCP Python SDK v2
import time
from mcp.server import MCPServer, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
async def log_timing(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
start = time.perf_counter()
try:
return await call_next(ctx)
finally:
print(f"{ctx.method} took {(time.perf_counter() - start) * 1000:.1f} ms")
mcp = MCPServer("Bookshop", middleware=[log_timing])
FastMCP는 Middleware 클래스를 상속하고, 필요한 작업의 hook만 구현한다.
# FastMCP 4
import time
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware, MiddlewareContext
class LogTiming(Middleware):
async def on_call_tool(self, context: MiddlewareContext, call_next):
start = time.perf_counter()
try:
return await call_next(context)
finally:
print(f"{context.message.name} took {(time.perf_counter() - start) * 1000:.1f} ms")
mcp = FastMCP("Bookshop", middleware=[LogTiming()])
클라이언트로 연결해서 tool 목록을 보고 tool을 한 번 호출해 보면 차이가 바로 보인다. SDK 미들웨어는 server/discover, tools/list, tools/call로 로그가 세 줄 찍혔고, FastMCP의 on_call_tool은 tool 호출 한 줄만 찍혔다. SDK 미들웨어는 연결 설정을 포함한 모든 메시지를 감싸서, 특정 작업만 다루려면 ctx.method를 보고 직접 분기해야 한다. 그리고 SDK 문서에는 이 미들웨어 API가 아직 provisional이라서 2.x 마이너 버전에서도 바뀔 수 있다고 적혀 있다.
FastMCP는 on_call_tool, on_read_resource, on_list_tools 같은 작업별 hook을 제공하고, 바로 가져다 쓸 수 있는 미들웨어도 함께 준다. timing, logging, rate limiting, 에러 처리와 재시도, 응답 캐시, 응답 크기 제한 미들웨어가 들어 있고, 인가를 처리하는 AuthMiddleware도 있다. AuthMiddleware는 권한이 없는 tool을 목록에서 걸러 내고, 호출하기 전에 다시 권한을 검사한다.
나는 실제로 FastMCP에서 미들웨어 주입을 활용해서 IdP 의존성을 제거한 tool 인가를 구현했었다. 어떤 사용자가 어떤 tool을 쓸 수 있는지를 IdP의 scope나 role이 아니라 미들웨어에서 판단하는 방식이다. 이 부분은 나중에 새로운 포스트로 다뤄 볼 주제라고 생각한다.
11. 인증
인증은 기능 범위 차이가 크다. SDK는 토큰을 검증하는 TokenVerifier 프로토콜과 AuthSettings를 제공하고, 실제 검증 로직은 직접 구현해야 한다.
# MCP Python SDK v2
from pydantic import AnyHttpUrl
from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
class MyTokenVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
... # JWT 서명 검증이나 introspection을 직접 구현
mcp = MCPServer(
"Notes",
token_verifier=MyTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
required_scopes=["notes:read"],
),
)
FastMCP는 auth= 하나에 provider 객체를 넘긴다. JWKS로 JWT를 검증하는 JWTVerifier가 들어 있어서 직접 구현할 부분이 거의 없다.
# FastMCP 4
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier
verifier = JWTVerifier(
jwks_uri="https://auth.example.com/.well-known/jwks.json",
issuer="https://auth.example.com",
audience="notes-api",
)
mcp = FastMCP("Notes", auth=verifier)
이 밖에도 FastMCP에는 외부 인가 서버에 맡기는 RemoteAuthProvider, Dynamic Client Registration을 지원하지 않는 IdP를 감싸는 OAuthProxy, 그리고 GitHub, Google, Auth0, Keycloak, Azure, AWS, WorkOS 같은 IdP별 provider가 들어 있다. tool마다 @mcp.tool(auth=...)로 권한 검사를 거는 기능도 있다.
이 장의 코드는 실제 IdP가 필요해서 직접 실행하지 않았고, 두 프로젝트의 공식 문서 예제를 바탕으로 정리했다.
12. 실행과 transport
| 구분 | SDK v2 | FastMCP 4 |
|---|---|---|
| 기본 transport | stdio | stdio |
| HTTP로 실행 | mcp.run(transport="streamable-http") |
mcp.run(transport="http") |
| 기본 주소 | http://127.0.0.1:8000/mcp |
http://127.0.0.1:8000/mcp |
| host, port 지정 | run() 인자 |
run() 인자 또는 FASTMCP_PORT 같은 환경 변수 |
| ASGI 앱으로 꺼내기 | mcp.streamable_http_app() |
mcp.http_app() |
| 실행 배너 | 없음 | 있음 |
version을 지정하지 않았을 때 서버 버전 |
빈 문자열 | FastMCP 라이브러리 버전 (4.1.1) |
SDK v2와 FastMCP 4 모두 서버가 무엇인지(이름, 설명, 인증, lifespan)는 생성자에, 서버를 어떻게 띄울지(host, port, 경로)는 run()에 넘기도록 나뉘어 있다. 생성자에 port=를 넣으면 둘 다 TypeError가 나는데, 메시지는 꽤 다르다.
# SDK v2
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
# FastMCP 4
TypeError: FastMCP() no longer accepts `port`. Pass `port` to `run_http_async()`, or set FASTMCP_PORT.
FastMCP는 실행할 때 큰 배너를 출력하고, 배너 안에는 Prefect Horizon 무료 배포 안내도 들어 있다. 로그 수집기에 그대로 남는 것이 싫다면 mcp.run(show_banner=False), CLI의 --no-banner, 환경 변수 FASTMCP_SHOW_SERVER_BANNER=false 중 하나로 끌 수 있다.
서버 버전 기본값도 확인해 둘 만하다. FastMCP에서 version을 지정하지 않으면 서버 버전으로 FastMCP 라이브러리 버전이 나간다. 클라이언트 입장에서는 우리 서버의 버전이 4.1.1로 보이는 셈이라, FastMCP("Bookshop", version="1.0.0")처럼 직접 지정하는 편이 좋다.
세션 처리도 확인했다. 두 서버 모두 2025-11-25 클라이언트가 initialize를 보내면 예전처럼 mcp-session-id 헤더를 돌려준다. 세션이 없는 2026-07-28 방식의 요청은 어느 replica로 보내도 처리할 수 있지만, legacy 클라이언트가 남아 있다면 sticky session 설정은 여전히 필요하다.
13. 테스트
테스트 방식은 같다. 두 프로젝트 모두 클라이언트에 서버 객체를 직접 넘기면 프로세스나 포트 없이 메모리 안에서 연결된다. 아래 두 테스트는 pytest로 실제로 통과하는 것을 확인했다.
# MCP Python SDK v2
import pytest
from mcp import Client
from server import mcp
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_add():
async with Client(mcp) as client:
tools = await client.list_tools()
assert [t.name for t in tools.tools] == ["add"]
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.is_error is False
assert result.structured_content == {"result": 3}
# FastMCP 4
import pytest
from fastmcp import Client
from server import mcp
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_add():
async with Client(mcp) as client:
tools = await client.list_tools()
assert [t.name for t in tools] == ["add"]
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.is_error is False
assert result.data == 3
비슷해 보이지만 클라이언트 API에 작은 차이가 있다.
list_tools(): SDK는ListToolsResult를 돌려줘서.tools로 꺼내야 하고, FastMCP는 tool 리스트를 바로 돌려준다.- 결과 값: SDK는
structured_content에{"result": 3}이 그대로 있고, FastMCP는result.data로 감싼 것을 푼3을 준다. - tool 오류: SDK의
call_tool()은is_error=True인 결과를 그대로 돌려준다. FastMCP의call_tool()은 기본값에서ToolError예외를 던지고, 결과로 받으려면raise_on_error=False를 넘겨야 한다. - 프로토콜 버전: 두 클라이언트 모두 기본값으로 2026-07-28 방식으로 연결한다. 그래서
ctx.elicit()를 쓰는 tool은 테스트에서 바로 실패한다. legacy 동작을 테스트하려면Client(mcp, mode="legacy")로 연결한다.
참고로 FastMCP의 SDK v2 이전 가이드에는 MCPServer에 대응하는 클라이언트가 없다고 적혀 있는데, 실제로는 SDK v2에도 위처럼 쓰는 mcp.Client가 있다. URL을 넘기면 streamable HTTP, StdioServerParameters를 넘기면 stdio 하위 프로세스, 서버 객체를 넘기면 인메모리로 연결한다.
14. 한쪽에만 있는 기능
여기까지는 양쪽에 다 있는 기능을 비교했다. 기능 범위만 보면 FastMCP 쪽이 훨씬 넓다. FastMCP에만 있는 기능은 다음과 같다.
| 기능 | 내용 |
|---|---|
| 서버 조합 | main.mount(weather, namespace="weather")로 다른 서버를 붙인다. tool 이름은 weather_get_forecast처럼 바뀐다. |
| 프록시 | create_proxy("http://.../mcp")로 원격 MCP 서버 앞에 FastMCP 서버를 세운다. transport를 바꾸거나 인증을 덧붙일 때 쓴다. |
| OpenAPI 변환 | FastMCP.from_openapi(openapi_spec=..., client=...)로 기존 REST API 명세에서 서버를 만든다. |
| 태그와 노출 제어 | @mcp.tool(tags={"internal"})로 태그를 붙이고 mcp.disable(tags={"internal"})로 숨긴다. |
| tool 변환 | 코드를 바꾸지 않고 tool 이름을 바꾸거나 인자를 숨긴다. |
| 버전 관리 | @mcp.tool(version=...)로 같은 tool의 여러 버전을 함께 둔다. |
| 백그라운드 작업 | fastmcp-tasks 패키지와 @mcp.tool(task=True)로 오래 걸리는 작업을 비동기로 돌린다. |
| 사용자별 상태 | UserSession 파라미터로 인증된 사용자마다 상태를 저장한다. |
| 여러 서버를 묶는 클라이언트 | ClientGroup으로 여러 서버의 tool을 이름 충돌 검사와 함께 하나로 묶는다. |
서버 조합과 태그로 숨기는 기능은 직접 확인했다. weather 서버를 namespace="weather"로 붙이고 internal 태그를 숨기니, 목록에는 weather_get_forecast만 남았다.
SDK에만 있는 것도 있다.
Resolve기반 elicitation: 8장에서 본 것처럼 tool 코드 하나로 legacy와 2026-07-28 연결을 모두 처리한다.- replica 간 구독 알림:
MCPServer(subscriptions=...)에 공용SubscriptionBus를 넣으면 resource 변경 알림이 여러 replica의 클라이언트에 전달된다. FastMCP 이전 가이드에는 이 기능에 해당하는 것이 없다고 적혀 있다. - 저수준
Server: 3-1에서 본 것처럼 JSON-RPC 수준에서 직접 handler를 만들 수 있다.
그리고 스펙을 따라가는 속도도 다르다. 2026-07-28 개정판은 SDK v2가 같은 날 지원했고, FastMCP 4는 한 달 뒤에 나왔다. 새 스펙 기능을 가장 먼저 써야 한다면 SDK 쪽이 빠르다.
15. 비교 정리
지금까지 확인한 차이를 표 하나로 모으면 다음과 같다.
| 항목 | SDK v2 MCPServer |
FastMCP 4 |
|---|---|---|
| 설치 패키지 수 | 28개 | 66개 |
| 데코레이터 괄호 | 반드시 @mcp.tool() |
@mcp.tool, @mcp.tool() 모두 가능 |
docstring Args: |
description에 통째로 들어감 | 파라미터 설명으로 나뉨 |
Annotated[str, "설명"] |
무시 | 파라미터 설명으로 인식 |
| tool title | 지정해야만 들어감 | 이름에서 자동 생성 |
스키마의 Pydantic title |
남김 | 지움 |
$ref |
남김 | 풀어서 펼침 |
| 정의하지 않은 인자 | 무시하고 성공 | 거부 |
| 리스트 반환 | 항목별 text 블록, 들여쓰기 JSON | 블록 하나, 압축 JSON |
dict 반환 |
structured output 없음 | structured output 있음 |
| 일반 예외 메시지 | 숨김 | 기본값에서 노출 (mask_error_details로 숨김) |
| 같은 이름 tool 재등록 | 먼저 등록한 것이 남음 | 나중에 등록한 것이 남음 |
| 동기 함수 | worker thread, 끄는 옵션 없음 | worker thread, run_in_thread=False 가능 |
| tool별 timeout | 없음 | timeout= |
| elicitation | Resolve 하나로 두 세대 처리 |
연결 방식별로 따로 작성 |
| lifespan | asynccontextmanager, 임의 객체 |
@lifespan, dict, 연산자로 조합 가능 |
| 의존성 주입 | Resolve |
Depends |
| 미들웨어 | 메시지 단위 함수, provisional | 작업별 hook, 기본 미들웨어 제공 |
| 인증 | TokenVerifier 직접 구현 |
JWTVerifier, OAuth 프록시, IdP별 provider |
| HTTP transport 이름 | "streamable-http" |
"http" |
| 서버 버전 기본값 | 빈 문자열 | FastMCP 버전 |
| 테스트 클라이언트 | mcp.Client, 오류를 결과로 반환 |
fastmcp.Client, 오류를 예외로 던짐 |
정리하면 SDK v2의 MCPServer는 프로토콜을 그대로 다루기 좋은 최소한의 고수준 API이고, FastMCP 4는 그 위에 운영에 필요한 기능을 더 얹은 프레임워크다. tool 몇 개를 노출하는 단순한 서버이거나 의존성을 적게 가져가야 한다면 SDK v2만으로 충분하다. 여러 서버를 묶거나, 기존 OpenAPI 명세를 MCP로 바꾸거나, JWT나 OAuth 인증을 바로 붙여야 한다면 FastMCP가 직접 짜야 할 코드를 많이 줄여 준다.
어느 쪽을 고르더라도 FastMCP의 에러 메시지 노출 기본값, 정의하지 않은 인자를 거부하는 동작, 같은 이름 tool을 덮어쓰는 동작은 미리 알고 있는 편이 좋다. 셋 다 SDK에서 FastMCP로 옮길 때 에러 없이 조용히 동작이 바뀌는 부분이다.
마치며
SDK v1의 FastMCP가 v2에서 MCPServer로 이름을 바꾸면서, 이제 FastMCP라는 이름은 독립 프레임워크만 가리킨다. 그리고 FastMCP 4가 SDK v2 위로 올라가면서, 두 프로젝트는 같은 프로토콜 계층을 공유하고 그 위에 무엇을 더 얹느냐로 갈리게 됐다. 직접 같은 서버를 두 번 만들어 보니 큰 구조는 거의 같았지만, 스키마 모양이나 에러 메시지처럼 문서에 잘 드러나지 않는 부분에서 차이가 꽤 많았다.
참고 자료
- MCP Python SDK
- What's new in v2 (MCP Python SDK)
- Migration Guide: v1 to v2 (MCP Python SDK)
- Handling errors (MCP Python SDK)
- Release v2.0.0 · modelcontextprotocol/python-sdk
- Release v1.2.0 · modelcontextprotocol/python-sdk
- What's New in FastMCP 4
- Upgrading from MCP SDK v2 (FastMCP)
- Tools (FastMCP)
- Authorization (FastMCP)
- User Elicitation (FastMCP)
- FastMCP 4 is GA
- Release v4.0.0: Four Real · PrefectHQ/fastmcp