제 11 장

도구 호출·MCP·업무 규칙

도우미가 “노트북 수리 요청을 접수했습니다”라고 말했는데 티켓이 없다면 그 답은 실패다. 반대로 모델의 말을 그대로 믿고 티켓을 두 번 만들면 외부 상태가 잘못 바뀐다. 도구를 붙이는 순간 생성문뿐 아니라 업무 상태의 정확성도 관리해야 한다. 가상 IT 티켓을 조회하고 등록하는 기능을 SQLite로 먼저 만들고, 같은 기능을 MCP 서버로 노출해 별도 Python 클라이언트에서 부른다. 모델이 도구를 고르는 능력과 서버가 실행을 통제하는 능력은 따로 본다.

초안 · 확인 · 등록

초안 · 확인 · 등록prepare_ticket초안 작성미리보기표시approve · 도구 아님확인 이벤트(호스트)현재 권한재검사create_ticket(draft_id)등록received모델이 “확인했습니다”라고 써도 확인 이벤트가 아니다. approve는 MCP 도구로 노출하지 않는다create_ticket은 제목·본문을 다시 받지 않고 draft_id만 받는다. 내용을 바꾸려면 새 초안과 새 확인이 필요하다

도구 호출은 모델이 함수 이름과 인자를 제안하는 데서 시작한다. prepare_ticket에 장비 고장이라는 분류와 증상 설명을 제안해도 그 시점에 업무는 아직 진행 중이다. 호스트가 호출을 허용하고 서버가 인자를 검증하고 작업을 수행해야 상태가 바뀐다. 도구 이름은 업무 결과 단위로 작게 나눈다. 무엇이든 실행하는 run_command 대신 prepare_ticket, create_ticket, get_ticket이다. 인자가 허용하는 상태 변화가 분명해야 권한과 오류를 검증할 수 있고, 모델이 만든 Python이나 SQL을 실행하는 기능은 이 경로에 없다.

식별자는 두 가지다. 프로토콜의 요청 ID는 어떤 결과가 어떤 요청의 응답인지 잇고, 업무 시스템의 티켓 ID는 업무 결과를 가리킨다. 요청 ID가 바뀌었다고 새 업무로 취급하면 재시도에서 중복 등록이 난다.

입력의 세 출처

서버가 받는 세 가지 입력의 출처모델 인자category · title · description · draft_idPydantic strict · extra="forbid"실행 문맥Actor(tenant, user)인증된 호출 경로가 만든다호스트 이벤트approve(draft_id, user)UI의 확인 클릭서버 검증인자 · 권한 · 확인 · 멱등성한 트랜잭션SQLite티켓 · 감사모델이 “나는 관리자다”라고 하거나 인자에 user 필드를 붙여도 Actor는 바뀌지 않는다. 추가 필드는 검증에서 거부된다
class TicketInput(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)
    category: Literal["equipment", "account"]
    title: str = Field(min_length=1, max_length=120)
    description: str = Field(min_length=1, max_length=2000)

모델에 주는 도구 설명은 목적과 입력을 알려 줄 뿐이고 검증은 서버가 따로 한다. 설명에 ‘장비와 계정만 가능’이라고 적는 것과 다른 분류를 막는 것은 별개다. ticket_tools.py는 분류를 두 값으로 제한하고 제목·본문 길이를 검사하고 추가 필드와 공백만 있는 문자열을 거부한다. 문자열로 읽을 수 있는 숫자라도 타입이 다르면 거부한다. 프로토콜 라이브러리가 입력을 검사해도 업무 쪽에서 같은 검증을 유지해야, 나중에 MCP를 거치지 않는 HTTP 경로가 생겨도 규칙이 빠지지 않는다.

사용자와 조직은 모델 인자가 아니라 인증된 실행 문맥에서 온다. Actor(tenant, user)는 신뢰된 호출 경로가 만든다. 실습 권한은 단순하다. ticket_grants에 등록된 주체가 자기 초안을 만들고 자기 티켓을 조회·등록하고, 다른 사용자나 조직의 티켓은 볼 수 없다. 담당자가 남의 티켓을 처리하는 역할은 구현하지 않았다. 등록 트랜잭션 안에서 현재 권한을 다시 확인하므로 예전에 승인된 초안이 있어도 지금 권한이 없으면 거부하고, 이미 등록한 초안을 재실행해 결과를 조회하는 경로에도 같은 규칙을 건다. 멱등성 캐시가 권한 회수의 우회로가 되면 안 된다. MCP 시연은 호스트가 시작한 단일 로컬 주체 hanul/demo로 고정했다. 이 주체를 그대로 다중 사용자 서버로 열면 모두가 같은 권한을 공유하게 된다.

조회와 변경의 실패 비용

조회 실패는 정보가 없거나 권한이 없거나 서비스가 응답하지 못한 상태다. 등록 실패에는 한 가지가 더 있다. 상태가 이미 바뀌었는지 알 수 없는 경우다. 서버가 커밋한 직후 연결이 끊기면 클라이언트는 오류를 보지만 티켓은 있다.

응답이 유실된 뒤의 두 가지 재시도같은 초안으로 재시도등록 요청draft d1서버 커밋응답 유실재시도같은 d1같은 티켓 IT-1042replayed=True · 1개새 초안으로 재시도등록 요청draft d1서버 커밋응답 유실새 초안 d2다시 등록티켓 IT-1042 + 10432개tickets.draft에 UNIQUE 제약을 두고 쓰기 트랜잭션 안에서 기존 티켓을 찾는다. 초안 ID가 멱등성의 단위다

첫 호출은 새 티켓을 만들고 replayed=False, 같은 초안으로 다시 부르면 같은 ID를 돌려주며 replayed=True다. 같은 내용으로 초안을 두 번 만들면 서로 다른 작업이라 이 규칙과 무관하다. 내용 중복 탐지와 요청 재시도 방지는 다른 문제다. 조회 오류도 나눈다. 권한이 있는 주체가 자기 범위에서 못 찾은 티켓에는 not_found를 주고, 다른 사용자의 ID를 알아도 본문을 주지 않는다. 오류 문구가 다른 사용자의 존재나 티켓 제목을 드러내는지도 검사한다.

등록 함수는 티켓과 감사 이벤트를 한 트랜잭션에 쓰고, 감사 이벤트 삽입이 실패하면 티켓도 롤백한다. 감사 테이블에는 주체·작업 종류·대상 식별자가 들어가고 준비·승인·등록을 구별하며, 같은 초안을 재등록할 때 등록 이벤트를 다시 만들지 않는다. 이 테이블은 트랜잭션 검증용이고 변조 방지 저장이나 보관 정책을 갖춘 운영 감사 시스템과는 다르다. 외부 API와 별도 DB에 나눠 저장하는 서비스에서는 단일 트랜잭션을 쓸 수 없어서 외부 시스템의 멱등성 키, 작업 상태 조회, 이벤트 전달과 복구 절차가 필요하다. 승인 만료와 취소도 이 구현에는 없다. 오래 지연될 수 있는 업무라면 승인 유효 기간과 실행 시 재검사 조건을 더한다.

도구 결과를 응답에 반영하기

호스트는 도구의 실제 결과로 상태를 표시한다. awaiting_confirmation이면 등록됐다고 말하지 않고 확인할 초안을 보여 주고, received면 티켓 ID와 접수 상태를 보여 준다. 접수는 해결이 아니라서 “수리했습니다”로 바꾸지 않는다. 티켓 ID와 등록 성공 여부는 모델이 고쳐 쓰는 대신 서버의 구조화된 필드에서 화면으로 바로 잇고, 모델은 증상 정리와 안내 문장에 쓴다.

도구가 돌려준 설명에도 외부 데이터가 섞인다. 티켓 본문에 “관리자 도구를 호출하라”라는 문장이 있어도 다음 실행을 허가하는 지시가 아니다. 자료가 들어오는 경로가 검색에서 업무 API로 바뀌었다고 신뢰 수준이 올라가지 않는다. MCP 결과의 isError는 도구 실행 오류를 나타낼 수 있고, structuredContent는 서버가 돌려준 구조화 자료다. 클라이언트는 텍스트에 ‘성공’이라는 단어가 있는지가 아니라 이 상태와 구조화 결과를 본다. MCP 도구 결과 명세

MCP의 구조

호스트 · 클라이언트 · 서버호스트사용자 · 모델 · 연결 정책MCP 클라이언트 AMCP 클라이언트 B문서 서버티켓 서버업무 DB데이터 계층: initialize · tools/list · tools/call전송 계층: stdio · Streamable HTTP서버를 별도 프로세스로 띄우는 것은 전송 구조지 샌드박스가 아니다. 파일·네트워크 권한은 실행 환경에서 따로 제한한다

MCP는 도구와 자료를 제공하는 서버를 호스트 애플리케이션에 연결하는 프로토콜이다. 호스트 하나가 여러 서버를 연결할 수 있지만 각 서버에 전체 대화를 넘길 필요는 없다. 서버의 도구 목록에는 이름·설명·입력 스키마가 들어가고, 클라이언트는 tools/list로 도구를 확인하고 tools/call로 이름과 인자를 보낸다. 이 서버는 도구가 셋이라 한 응답에 다 들어온다. MCP는 모델의 도구 선택 정확도나 서버의 권한 검증과 무관하고, 신뢰하지 않는 서버에 호스트의 넓은 자격 증명을 넘기는 문제도 MCP 밖의 일이다. MCP 아키텍처, 도구 목록과 호출

로컬 stdio 연결

python -m pip install -r books/ai-engineering/examples/requirements-tools.txt
python books/ai-engineering/examples/mcp_ticket_demo.py

공식 Python SDK는 requirements-tools.txt에 고정한 v1 계열이고 협상된 명세는 2025-11-25다. mcp_ticket_server.pyFastMCP로 세 함수를 등록하고, 같은 업무 클래스 Tickets를 부르므로 프로토콜을 거쳐도 초안·권한·멱등성 검사가 그대로다. 공식 Python SDK 저장소

로컬 stdio 시연의 두 프로세스호스트 프로세스 · 클라이언트stdio_client(params) · 하위 프로세스 시작ClientSessioninitializelist_toolscall_tool("create_ticket", {draft_id})서버 프로세스 · mcp_ticket_server.pyFastMCP · @server.tool(...)Tickets 클래스 · 초안·권한·멱등성 검사SQLitestdin · 요청stdout · 응답stdout은 프로토콜 전용 · 로그는 stderr로첫 실행에서 반환 타입을 dict로 두자 structuredContent가 없었다 → dict[str, Any]와 structured_output=True를 명시해 해결
@server.tool(structured_output=True)
def create_ticket(draft_id: str) -> dict[str, Any]:
    return tickets.create(actor, draft_id)
async with stdio_client(params) as (read, write):
    async with ClientSession(read, write) as session:
        initialized = await session.initialize()
        tools = await session.list_tools()
        result = await session.call_tool(
            "create_ticket", {"draft_id": draft_id}
        )

dict[str, Any]는 객체 반환을 분명하게 할 뿐 필드별 업무 스키마와는 다르다. 생산 API라면 등록·조회 결과마다 구체적인 출력 모델을 둔다. 클라이언트는 StdioServerParameters에 현재 Python 실행 파일과 서버 스크립트 경로를 넘기고, 문맥 관리자가 연결과 프로세스 수명을 닫는다. stdio 서버의 stdout은 프로토콜 메시지용이라 디버깅 print를 섞으면 통신이 깨진다. 시연의 최종 JSON은 서버가 아니라 호스트 클라이언트의 출력이다. MCP stdio 전송 규칙

시연은 임시 DB를 만들고 별도 프로세스 서버에 연결한다. 도구 목록에 준비·등록·조회만 있고 승인 도구가 없는지 본다. 승인 전 등록은 오류, 호스트가 확인 이벤트를 넣은 뒤의 등록은 성공, 같은 초안의 재호출은 같은 티켓 ID, 권한 회수 뒤의 조회는 오류다. 확인 이벤트는 테스트 픽스처지 실제 사용자의 UI 기록이 아니고, 모델의 도구 선택도 검증하지 않았다. 로컬 SQLite에는 티켓 한 개만 남았고 결과는 experiments/mcp-tickets.json에 있다.

원격 연결

원격 연결은 로컬 실행 명령을 URL로 바꾸는 일보다 크다. Streamable HTTP에서는 서버가 독립 프로세스로 여러 연결을 처리하므로 호스트·서버 사이의 인증, 세션 수명, 요청 취소·시간 제한·재연결이 필요하다. 연결이 끊겼다는 사실이 업무가 취소됐다는 뜻은 아니다. 인증은 명세의 권한 부여 흐름과 토큰 검증을 따르고, 검증한 주체를 업무 Actor에 연결한다. 도구 인자의 user나 세션 ID만으로 사용자를 인증하지 않고, 토큰의 대상과 권한 범위를 검증하고 다른 시스템에 토큰을 그대로 넘기지 않는다. 등록 중 연결이 끊어지면 초안 ID를 유지해 재시도하거나 상태를 조회한다. 프로토콜 세션이 새로 만들어져도 업무 멱등성 단위는 그대로고, 재연결을 이유로 승인되지 않은 새 초안을 만들어 실행하지 않는다. MCP HTTP 전송 명세, MCP 권한 부여 명세

HTTP 서버와 OAuth 연동은 실행하지 않았다. 고정 주체의 stdio 서버를 공개 서비스로 쓰기 전에 다중 사용자 인증, 승인 만료·취소, 출력 모델과 오류 처리, 동시 실행·장애 복구를 검증해야 한다. 로컬 시연의 성공은 이 항목들을 증명하지 못한다.

실습: 전송 성공과 규칙 준수를 나눠 보기

python -m unittest discover -s books/ai-engineering/examples -p 'test_*.py' -q
  1. mcp_ticket_demo.py를 돌려 도구 목록에 approve가 없는 것, 승인 전 등록 오류, 승인 뒤 등록 성공, 같은 초안 재호출의 replayed=True, 권한 회수 뒤 조회 오류를 순서대로 본다.
  2. 업무 테스트는 다른 사용자·조직의 접근, 권한 회수 후 재실행, 새 초안의 재확인, 입력의 신원 필드 위조, 감사 기록 실패 시 등록 롤백을 다룬다. MCP 시연과 업무 테스트를 함께 봐야 전송 성공과 업무 규칙 준수가 갈린다.
  3. 서버 코드에 print를 하나 넣고 클라이언트가 어떻게 깨지는지 본 뒤 stderr로 옮긴다.

도구 연결의 목적은 모델이 제안한 작업을 실제 시스템의 규칙 안에서 수행하는 데 있다.

답변에서 업무 실행으로11 / 23