제 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는 바뀌지 않는다. 추가 필드는 검증에서 거부된다

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

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

조회와 변경의 실패 비용

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

응답이 유실된 뒤의 두 가지 재시도같은 초안으로 재시도등록 요청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 연결

서버는 준비·등록·조회 세 함수를 도구로 등록하되 프로토콜 뒤에서 부르는 것은 앞에서 쓰던 업무 코드 그대로다. 그래야 프로토콜을 거쳐도 초안·권한·멱등성 검사가 빠지지 않는다.

로컬 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를 명시해 해결

‘객체를 돌려준다’는 표시만으로는 그 안에 무엇이 들어 있는지 알 수 없다. 필드별 업무 스키마는 따로 적고, 실제로 운영할 API라면 등록·조회 결과마다 구체적인 출력 모델을 둔다. 클라이언트는 쓰고 난 연결과 서버 프로세스를 함께 닫는다. stdio 서버의 표준 출력은 프로토콜 메시지용이라 디버깅 print를 섞으면 통신이 깨진다. 최종 JSON을 찍는 것은 서버가 아니라 호스트 클라이언트다. MCP stdio 전송 규칙

확인은 임시 저장소를 만들고 별도 프로세스로 띄운 서버에 붙어서 한다. 도구 목록에 준비·등록·조회만 있고 승인 도구가 없는지 본다. 승인 전 등록은 오류, 호스트가 확인 이벤트를 넣은 뒤의 등록은 성공, 같은 초안의 재호출은 같은 티켓 ID, 권한 회수 뒤의 조회는 오류다. 여기서 쓴 확인 이벤트는 사람이 넣어 준 값이지 실제 사용자의 화면 기록이 아니고, 모델의 도구 선택도 확인하지 않았다. 끝나고 저장소에는 티켓 한 개만 남았다.

원격 연결

원격 연결은 로컬 실행 명령을 URL로 바꾸는 일보다 크다. Streamable HTTP에서는 서버가 독립 프로세스로 여러 연결을 처리하므로 호스트·서버 사이의 인증, 세션 수명, 요청 취소·시간 제한·재연결이 필요하다. 연결이 끊겨도 서버는 받은 일을 그대로 처리하고 있을 수 있다.

서버는 명세의 권한 부여 흐름과 토큰 검증을 따르고, 검증한 주체를 업무 주체에 연결한다. 도구 인자로 온 사용자 이름이나 세션 ID만으로 사용자를 인증하지 않는다. 토큰의 대상과 권한 범위를 검증하고, 다른 시스템에 그 토큰을 그대로 넘기지 않는다. 등록 중 연결이 끊어지면 초안 ID를 유지해 재시도하거나 상태를 조회한다. 프로토콜 세션이 새로 열려도 업무 멱등성 단위는 그대로다. 재연결을 이유로 승인되지 않은 새 초안을 만들어 실행하지 않는다. MCP HTTP 전송 명세, MCP 권한 부여 명세

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

사흘을 기다린 그 직원이 이제 다시 묻는다면 봇은 티켓 번호를 돌려준다. 그 번호를 만든 것은 모델이 아니라 서버다.

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