클라이언트

클라이언트 (The Client)

Client 는 파이썬 프로그램이 MCP 서버와 대화하는 방법입니다.

라이프사이클이 하나인 객체 하나입니다. 생성하고, async with에 들어가고, 메서드를 호출하죠. 모든 프로토콜 동사(도구 나열, 하나 호출, 리소스 읽기, 프롬프트 렌더링)는 타입이 있는 결과를 반환하는 async 메서드입니다.

첫 번째 클라이언트

클라이언트는 대화할 서버가 필요합니다. 이 Bookshop이 이 페이지의 모든 스니펫이 연결하는 서버예요. server.py로 저장하고 HTTP로 띄워 둡니다.

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference

mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.")

GENRES = ["fiction", "non-fiction", "poetry"]


class Book(BaseModel):
    title: str
    author: str
    year: int


@mcp.tool(title="Search the catalog")
def search_books(query: str, limit: int = 10) -> str:
    """Search the catalog by title or author."""
    return f"Found 3 books matching {query!r} (showing up to {limit})."


@mcp.tool()
def lookup_book(title: str) -> Book:
    """Look up a book by its exact title."""
    if title != "Dune":
        raise ToolError(f"No book titled {title!r} in the catalog.")
    return Book(title="Dune", author="Frank Herbert", year=1965)


@mcp.resource("catalog://genres")
def genres() -> list[str]:
    """The genres the catalog is organised by."""
    return GENRES


@mcp.resource("catalog://genres/{genre}")
def books_in_genre(genre: str) -> str:
    """Every title we stock in one genre."""
    return f"3 books filed under {genre}."


@mcp.prompt(title="Recommend a book")
def recommend(genre: str) -> str:
    """Ask for a recommendation in a genre."""
    return f"Recommend one {genre} book from the catalog and say why."


@mcp.completion()
async def complete_genre(
    ref: PromptReference | ResourceTemplateReference,
    argument: CompletionArgument,
    context: CompletionContext | None,
) -> Completion | None:
    return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)])
uv run mcp run server.py --transport streamable-http

그러면 http://localhost:8000/mcp에서 서빙됩니다. 클라이언트는 그 자신의 프로그램이에요. client.py로 저장하고 두 번째 터미널에서 python client.py를 실행합니다.

import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        print(client.server_info)
        print(client.server_capabilities)
        print(client.protocol_version)
        print(client.instructions)


if __name__ == "__main__":
    anyio.run(main)
  • Client("http://localhost:8000/mcp")에는 URL이 주어져서, 방금 시작한 서버에 Streamable HTTP로 연결합니다.
  • async with라이프사이클입니다. 들어가면 연결하고 협상하고, 나가면 연결을 끊죠. connect()/close() 쌍은 없고, 블록이 끝난 뒤 Client를 재사용할 수 없습니다.
  • 블록 안에서는 연결 사실이 이미 평범한 속성으로 있습니다.

Client에 무엇을 넘길 수 있나요

Client는 위치 인자 하나를 받고 그 타입에서 전송(트랜스포트)을 결정합니다.

  • URL 문자열(Client("http://localhost:8000/mcp")): Streamable HTTP. 여러분이 뒤에 배포하는 전송입니다.
  • StdioServerParameters: stdin/stdout으로 대화할 로컬 서브프로세스로 띄울 명령.
  • 트랜스포트: async with ... as (read, write)할 수 있는 그 무엇이든. 예를 들어 여러분 자신의 HTTP 클라이언트를 감싼 streamable_http_client(url, http_client=...).
  • MCPServer(또는 저수준 Server) 인스턴스: 서브프로세스도 포트도 없이 프로세스 내에서 연결. 그건 테스트용이고, **Testing**이 그 위에 구축합니다.

이 페이지의 나머지는 네 가지 전부에서 동일합니다. 헤더, 서브프로세스, 타임아웃, 그리고 Transport 프로토콜은 자기 페이지가 있습니다. Client transports.

연결된 클라이언트에 있는 것

블록에 들어가는 순간 채워지는 읽기 전용 속성 네 개.

  • client.server_info: 서버의 정체, 또는 2026 시대 서버가 보고하지 않는 경우 None(python-sdk 서버는 기본적으로 보고합니다). 여기서 server_info.name"Bookshop", server_info.version은 서버가 보고하는 대로입니다.
  • client.server_capabilities: 서버가 할 수 있는 것(tools, resources, prompts, completions, ...). 서버에 없는 캐퍼빌리티는 None입니다.
  • client.protocol_version: 양측이 합의한 프로토콜 버전. 여기선 "2026-07-28"이에요.
  • client.instructions: 서버의 instructions= 문자열, 설정 안 했으면 None.

여러분은 프로토콜 버전을 고른 적이 없어요. 기본적으로 Client는 서버를 탐색하고, 더 오래된 서버에선 클래식 핸드셰이크로 폴백합니다. 그래서 클라이언트 하나가 어떤 시대의 서버에도 동작해요. 그걸 제어해야 할 때는 Protocol versions 에 전체 이야기가 있습니다.

!!! tip client.session은 내부의 ClientSession, 저수준 탈출구입니다. 이 페이지의 어떤 것에도 필요하지 않을 거예요.

도구 나열하기

import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        result = await client.list_tools()
        for tool in result.tools:
            print(tool.name)
            print(tool.title)
            print(tool.description)
            print(tool.input_schema)


if __name__ == "__main__":
    anyio.run(main)

list_tools()ListToolsResult를 반환하고, 도구들은 .tools에 있습니다. 각각은 호스트가 모델에 넘겨줄 완전한 정의입니다. 첫 번째 것:

tool.name          # 'search_books'
tool.title         # 'Search the catalog'
tool.description   # 'Search the catalog by title or author.'

그리고 tool.input_schema는 서버가 함수의 타입 힌트에서 유도한 JSON Schema입니다.

{
  "type": "object",
  "properties": {
    "query": {"title": "Query", "type": "string"},
    "limit": {"default": 10, "title": "Limit", "type": "integer"}
  },
  "required": ["query"],
  "title": "search_booksArguments"
}

그 스키마는 UI가 인자 폼을 렌더링하는 데 필요한 전부이고, 모델이 유효한 인자를 만들기 위해 필요한 전부입니다.

두 번째 도구 lookup_booktitle= 없이 등록되어서 tool.titleNone입니다.

!!! tip title은 선택적이라, 사람에게 도구를 보여주는 UI는 골라야 합니다. 있으면 title, 없으면 name. from mcp.shared.metadata_utils import get_display_name이 정확히 그걸 하는데, 도구·리소스·리소스 템플릿·프롬프트 모두에 대해서요.

도구 호출하기

call_tool(name, arguments)는 도구를 실행하고 CallToolResult를 돌려줍니다.

import anyio

from mcp import Client
from mcp.types import TextContent


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        result = await client.call_tool("lookup_book", {"title": "Dune"})

        for block in result.content:
            if isinstance(block, TextContent):
                print(block.text)

        print(result.structured_content)
        print(result.is_error)


if __name__ == "__main__":
    anyio.run(main)

서버의 lookup_book은 Pydantic Book을 반환합니다. 클라이언트가 보는 것은:

result.content             # [TextContent(type='text', text='{\n  "title": "Dune",\n  "author": "Frank Herbert",\n  "year": 1965\n}')]
result.structured_content  # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
result.is_error            # False

반환값 하나, 읽을 것 세 가지. 각각 소비자가 다릅니다.

content: 모델이 읽는 것

content콘텐츠 블록list이고, 콘텐츠 블록은 공용체(union)입니다. TextContent, ImageContent, AudioContent, ResourceLink, EmbeddedResource. 도구는 여러 종류를 여럿 반환할 수 있어요.

그래서 mainblock.text를 건드리기 전에 isinstance(block, TextContent)로 좁힙니다. isinstance 바깥에는 .text가 없다는 걸 주목하세요. 타입 체커가 허용하지 않아요. ImageContent.text가 아니라 .data를 가지니까요. 그 공용체는 도구가 여러분에게 보낼 수 있는 것이 무엇인지 정직하게 말합니다. 여러분의 코드도 그래야 해요.

structured_content: 애플리케이션이 읽는 것

structured_content는 도구의 반환값을 JSON으로, 도구가 선언한 output_schema와 일치하는 형태입니다. 문자열 파싱도, 추측도 없어요.

둘 다 있을 때 같은 말을 두 번 하는 것은 의도적입니다. content는 모델용, structured_content는 코드용이에요. 구조화 반쪽이 어디서 오고 어떻게 제어하는지는 Structured Output 페이지가 다룹니다.

is_error: 도구가 실패했는지

raise하는 도구는 클라이언트에서 raise하지 않습니다. is_error=True인 평범한 결과로 돌아오지요.

!!! check lookup_book"Solaris"를 요청하면(카탈로그에 없는 제목) 함수가 ToolError를 raise합니다. 그래도 호출은 정상적으로 반환됩니다.

```python
result.is_error            # True
result.content             # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
result.structured_content  # None
```

`ToolError`의 메시지는 `content`에 들어갔고, **모델**이 읽고 다시 시도할 수 있는 곳이죠. 그건 의도적입니다. 도구 오류는 크래시가 아니라 대화의 일부니까요. (도구가 다른 예외로 크래시했다면 `content`는 `Error executing tool lookup_book`이라고만 말할 겁니다.) `structured_content`를 신뢰하기 전에 항상 `is_error`를 보세요.

!!! warning is_error=True는 여러분 자신의 raise보다 더 많은 것을 덮습니다. 서버에조차 없는 도구를 요청해도(call_tool("does_not_exist", {})) 아무것도 raise하지 않아요. 같은 모양, contentUnknown tool: does_not_exist가 있는 is_error=True를 받습니다. Client 메서드는 서버가 결과 대신 JSON-RPC 오류로 답할 때만 MCPError를 raise하고, 어떤 서버가 어느 쪽을 만드는지는 Handling errors 가 다룹니다.

리소스

리소스 동사는 쌍으로 옵니다. 나열하는 방법 둘, 읽는 방법 하나.

import anyio

from mcp import Client
from mcp.types import TextResourceContents


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        listed = await client.list_resources()
        print([resource.uri for resource in listed.resources])

        templates = await client.list_resource_templates()
        print([template.uri_template for template in templates.resource_templates])

        result = await client.read_resource("catalog://genres/poetry")
        for contents in result.contents:
            if isinstance(contents, TextResourceContents):
                print(contents.text)


if __name__ == "__main__":
    anyio.run(main)
  • list_resources()구체 리소스, 고정 URI를 가진 것들을 반환합니다. 여기선 ['catalog://genres'].
  • list_resource_templates()파라미터화된 것들을 반환합니다. 여기선 ['catalog://genres/{genre}']. 둘은 다른 목록이에요. 템플릿은 채우기 전까지 읽을 수 없으니까요.
  • read_resource(uri)는 평범한 str URI를 받고 둘 다에 동작합니다. "catalog://genres/poetry"를 넘기면 서버가 그걸 템플릿에 매칭합니다.

read_resourcecontents(즉 TextResourceContents 또는 BlobResourceContents의 리스트)를 반환합니다. 도구 콘텐츠와 같은 발상이죠. isinstance로 좁힌 다음 .text(또는 .blob)를 읽습니다.

클라이언트는 리소스가 바뀔 때 알림을 받을 수도 있습니다. 2025 시대 연결에서는 subscribe_resource(uri)/unsubscribe_resource(uri)MCPServer가 구현하지 않는 메서드 쌍이라, (그 동사들이 더 이상 존재하지 않는) 2026-07-28 와이어에서는 요청이 -32601, Method not found로 답합니다. 2026의 대체물은 subscriptions/listen 스트림인데, MCPServer 그걸 서빙합니다 — 거기서 server_capabilities.resources.subscribeTrue죠 — 그리고 그것을 client.listen(...)로 소비하는 것은 이 섹션의 Subscriptions 페이지입니다.

프롬프트

import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        listed = await client.list_prompts()
        print(listed.prompts)

        result = await client.get_prompt("recommend", {"genre": "poetry"})
        for message in result.messages:
            print(message.role, message.content)


if __name__ == "__main__":
    anyio.run(main)

list_prompts()는 서버가 무엇을 제공하고 각 프롬프트에 무엇이 필요한지 알려줍니다.

prompt.name        # 'recommend'
prompt.title       # 'Recommend a book'
prompt.arguments   # [PromptArgument(name='genre', required=True)]

get_prompt(name, arguments)는 그걸 렌더링합니다. arguments 딕셔너리는 str -> str입니다. 프롬프트 인자는 항상 문자열이죠. 결과는 messages, 즉 PromptMessage의 리스트이고, 각각 rolecontent 블록이 있습니다.

message.role     # 'user'
message.content  # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')

호스트는 그 메시지들을 모델에 바로 넘깁니다. 그게 전체 기능이에요.

컴플리션(Completions)

컴플리션 핸들러가 있는 서버는 사용자가 타이핑할 때 프롬프트·리소스 템플릿 인자를 자동완성할 수 있습니다.

import anyio

from mcp import Client
from mcp.types import PromptReference


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        result = await client.complete(
            ref=PromptReference(type="ref/prompt", name="recommend"),
            argument={"name": "genre", "value": "p"},
        )
        print(result.completion.values)


if __name__ == "__main__":
    anyio.run(main)
  • ref어떤 프롬프트나 템플릿을 채우는 중인지 말합니다. PromptReference 또는 ResourceTemplateReference.
  • argument{"name": ..., "value": ...}입니다. 인자와 사용자가 지금까지 타이핑한 것.

답은 result.completion.values에 있습니다. "p"를 타이핑하면 서버가 ['poetry']로 답합니다. 서버 쪽과, 핸들러가 이미 채워진 다른 인자들로 제안을 좁히는 방법은 Completions 페이지입니다.

페이지네이션(Pagination)

모든 list_* 메서드는 cursor= 키워드를 받고 모든 결과는 next_cursor를 가집니다. next_cursorNone이면 다 가진 것입니다.

import anyio

from mcp import Client
from mcp.types import Tool


async def list_all_tools(client: Client) -> list[Tool]:
    tools: list[Tool] = []
    cursor: str | None = None
    while True:
        page = await client.list_tools(cursor=cursor)
        tools.extend(page.tools)
        if page.next_cursor is None:
            return tools
        cursor = page.next_cursor


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        tools = await list_all_tools(client)
        print([tool.name for tool in tools])


if __name__ == "__main__":
    anyio.run(main)

list_all_tools는 모든 서버에 대해 정확합니다. MCPServer는 모든 것을 한 페이지에 반환하므로 next_cursorNone이고 루프는 한 번 돌죠. 그래서 대부분의 코드는 그걸 쓰지 않는 것입니다. 진짜로 페이지를 나누는 서버와 커서가 따르는 규칙은 Pagination 에 있습니다.

테스트에서

이 페이지의 모든 client.py는 HTTP로 server.py에 도달했습니다. 테스트에서는 네트워크를 건너뛰고 Client에 서버 객체 자체를 넘깁니다. from server import mcp, 그다음 Client(mcp). 프로세스도 포트도 없고, 위의 모든 메서드가 똑같이 동작합니다.

그걸 위해 만들어진 생성자 플래그가 하나 있습니다. Client(mcp, raise_exceptions=True). 프로세스 내 연결에서만 효과가 있고, Testing 이 그걸 설명하고 그 주위로 전체 패턴을 구축합니다.

정리(Recap)

  • Client(x)는 URL 문자열에 Streamable HTTP로 연결하고, StdioServerParameters엔 서브프로세스를 띄우며, 트랜스포트를 직접 받아들이고, 테스트에선 서버 객체 자체를 받습니다.
  • async with가 전체 라이프사이클입니다. 그 안에서 server_capabilitiesprotocol_version은 이미 채워져 있고, 서버가 제공하면 server_infoinstructions도 마찬가지입니다.
  • list_tools()는 각 도구의 name, title, description, input_schema를 줍니다.
  • call_tool()은 모델용 content, 코드용 structured_content, 그리고 is_error를 반환합니다. raise하는 도구는 예외가 아니라 결과입니다.
  • content는 블록 타입의 공용체입니다. 읽기 전에 isinstance로 좁히세요.
  • list_resources/list_resource_templates/read_resource, list_prompts/get_prompt, complete가 동사를 채웁니다.
  • 모든 list_*cursor=를 받습니다. next_cursorNone이 될 때까지 루프하세요.

서버가 클라이언트에게 요청할 수 있는 것들과, 그것에 답하는 방법은 Client callbacks 입니다.

출처: Python SDK — The Client

더 알아보기 (Learn more)