Devin.KR

AI 개발 · 심화

AI 에이전트 활용과 하네스 설계

도구 연결 표준 - MCP 의 구조를 흉내 내며 이해하기

도구를 앱마다 따로 붙이지 않고 표준으로 연결하는 이유, 서버·클라이언트·도구·자원·프롬프트 개념, 메시지 주고받기 흐름, 외부 시스템 연결 시 권한 범위와 신뢰, 에이전트끼리 연결하는 표준도 있다는 점(개념만), 완성 코드는 JSON 메시지로 도구 목록 조회·호출을 주고받는 작은 서버·클라이언트를 한 프로세스 안에서 흉내 낸다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 완료 기준을 코드로 검사했다면, 이제 그 검사를 포함한 작업에 필요한 도구를 어떻게 연결할지 살펴본다. 문서를 읽는 도구, 작업 목록을 조회하는 도구, 외부 저장소를 검색하는 도구를 앱마다 따로 붙이면 비슷한 연결 코드를 반복해서 작성하게 된다. 도구를 제공하는 쪽과 사용하는 쪽이 같은 메시지 규칙을 따르면 이 반복을 줄일 수 있다.

이 장에서는 모델 컨텍스트 프로토콜(Model Context Protocol, MCP)의 구조를 작은 Python 프로그램으로 흉내 낸다. 아래 설명은 2026년 10월 기준 예시이며, 특정 버전의 모든 기능을 구현하지 않는다. 실제 연결에는 초기화, 전송 방식, 기능 협상 등 추가 규칙이 있다. 여기서는 도구 목록 조회와 도구 호출을 직접 만들어 표준 연결의 원리를 이해한다.

  • 앱마다 별도 연결 코드를 만드는 방식과 공통 메시지 규칙을 사용하는 방식을 비교한다.
  • 서버·클라이언트·도구·자원·프롬프트의 역할을 구분한다.
  • 요청 식별자로 응답을 연결하고, 입력과 결과를 검증한다.
  • 표준 연결과 외부 시스템의 권한·신뢰 판단을 구분한다.
  • 네트워크 없이 한 프로세스 안에서 서버와 클라이언트를 실행하고 확인한다.

문제 상황

작은 문서 정리 하네스가 로컬 문서와 외부 문서 저장소를 함께 사용한다고 가정한다. 로컬 문서는 파일 경로를 받아 읽는다. 외부 저장소는 문서 식별자를 받고, 검색 서비스는 검색어와 결과 개수를 받는다. 각 시스템의 입력과 결과 형식이 다르므로 하네스 안에 변환 코드가 계속 늘어난다.

문제는 연결 대상의 수만이 아니다. 같은 문서 저장소를 다른 앱에서도 쓰려면 도구 설명, 인자 변환, 오류 처리 코드를 다시 작성할 수 있다. 한쪽에서 필드 이름을 바꾸면 여러 앱을 찾아 고쳐야 한다. 도구가 무엇을 하는지 알아내는 방법도 앱마다 달라진다.

공통 규칙을 사용하면 제공자는 도구 목록과 호출 방법을 일정한 형식으로 내놓고, 사용자는 그 형식을 이해하는 클라이언트를 만든다. 저장소 내부의 구현은 서버가 맡는다. 하네스는 서버가 어떤 언어로 작성됐는지보다 어떤 도구를 제공하고 어떤 입력을 받는지에 집중할 수 있다.

다만 표준 연결이 업무 의미까지 통일하지는 않는다. 두 서버에 모두 문서 검색 도구가 있어도 검색 범위, 정렬 기준, 접근 가능한 문서가 다를 수 있다. 공통 규칙은 연결의 문법을 줄여 주고, 도구 설명과 검증은 실제 사용의 의미를 확인한다.

공통 클라이언트는 같은 메시지 규칙으로 여러 서버에 연결하고 서버가 시스템별 차이를 처리한다

서버와 클라이언트가 나누는 역할

MCP를 이해할 때 먼저 모델과 클라이언트를 구분해야 한다. 모델은 제공받은 설명을 바탕으로 도구와 인자를 제안할 수 있다. 그러나 연결을 유지하고, 요청을 보내고, 응답을 검사하는 일은 모델을 둘러싼 프로그램이 맡는다. 이 프로그램을 호스트(host)라고 부르며, 호스트 안의 클라이언트(client)가 서버(server)와 통신한다.

서버는 외부 기능을 일정한 인터페이스로 제공한다. 서버라는 이름이 별도 컴퓨터를 뜻하지는 않는다. 같은 컴퓨터의 다른 프로세스일 수도 있고 원격 서비스일 수도 있다. 이 장의 서버는 같은 프로세스 안의 객체다. 프로세스를 나누는 대신 JSON 문자열을 실제로 직렬화하고 역직렬화해 경계를 만든다.

연결 참여자와 서버가 제공하는 항목의 역할
항목역할문서 작업 예시
호스트모델과 도구 사용을 조정한다작업을 받고 결과를 검증하는 하네스
클라이언트서버에 요청하고 응답을 확인한다도구 목록을 조회하는 연결 객체
서버기능과 맥락을 규칙에 맞게 제공한다허용된 문서를 읽는 제공자
도구인자를 받아 작업을 수행한다문서 제목과 본문 줄 수를 계산한다
자원참조할 데이터나 내용을 제공한다문서 본문이나 프로젝트 설명
프롬프트재사용할 요청 형식을 제공한다문서 검토에 필요한 질문 틀

도구(tool)는 호출할 작업이다. 자원(resource)은 맥락으로 참고할 내용이다. 프롬프트(prompt)는 반복해서 사용할 요청 형식이다. 셋 모두 서버가 제공할 수 있지만 용도가 다르다. 문서 본문을 자원으로 제공하는 것과 문서 통계를 계산하는 도구를 제공하는 것은 구분할 수 있다.

한편 자원이 언제나 도구보다 안전하다는 뜻은 아니다. 읽기만 해도 비공개 정보가 노출될 수 있다. 서버에서 받은 프롬프트도 호스트의 상위 지침을 바꾸는 권한을 얻지 않는다. 어느 항목이든 출처, 접근 범위, 사용 목적을 함께 확인해야 한다.

완성 코드는 도구만 구현한다. 자원과 프롬프트를 함께 구현하면 연결 구조보다 기능 종류에 주의가 분산되기 때문이다. 서버는 허용된 문서 하나의 제목과 본문 줄 수를 돌려준다. 클라이언트는 도구 설명을 조회한 뒤 규칙 기반 가짜 모델이 고른 호출을 전송한다.

JSON 메시지로 요청과 응답을 연결한다

실제 MCP는 JSON 원격 프로시저 호출(JSON-RPC)의 메시지 형식을 사용한다. 이 장에서는 그중 요청과 성공·오류 응답의 모양을 좁게 사용한다. 요청에는 메시지 규칙을 나타내는 값, 요청 식별자, 메서드 이름, 매개변수가 들어간다. 응답에는 같은 식별자와 결과 또는 오류가 들어간다.

여기서 요청 식별자는 도구 이름과 다른 역할을 한다. 같은 도구를 여러 번 호출해도 각 요청의 응답을 구분해야 하기 때문이다. 완성 코드에서는 순서대로 증가하는 정수를 쓴다. 동기 호출이라 응답 순서가 뒤집히지 않지만, 클라이언트가 식별자를 확인하는 습관은 그대로 유지한다.

예제에서 주고받는 메시지의 의미
방향주요 값의미
클라이언트 → 서버method: tools/list사용 가능한 도구 설명을 요청한다
서버 → 클라이언트result: tools 목록이름·설명·입력 규칙을 알려 준다
클라이언트 → 서버method: tools/call도구 이름과 인자를 보내 실행한다
서버 → 클라이언트result: content 목록도구 결과를 콘텐츠 형태로 반환한다
서버 → 클라이언트error: code와 message메시지나 호출 인자가 잘못됐음을 알린다

서버가 도구 목록을 공개해도 클라이언트가 모든 도구를 사용해야 하는 것은 아니다. 호스트는 작업에 필요한 도구만 모델에 보여 줄 수 있다. 모델이 고른 이름도 그대로 믿지 않고, 실제 목록에 있는지 확인해야 한다. 이번 가짜 모델은 입력 작업이 정해진 문장일 때만 문서 요약 도구를 선택한다.

도구의 입력 스키마(input schema)는 허용되는 인자의 구조를 설명한다. 하지만 스키마를 목록에 싣는 것만으로 검증이 실행되지는 않는다. 예제 서버는 인자가 딕셔너리인지, 필드가 정확한지, 문서 이름이 허용된 값인지 직접 검사한다. 범용 스키마 검증기를 구현하는 대신 도구 하나에 필요한 규칙을 명시한다.

프로토콜 오류와 도구 실행 중의 업무 오류도 구분해야 한다. 잘못된 메서드나 인자에는 프로토콜 오류 응답을 보낼 수 있다. 유효한 호출이지만 대상 시스템에서 처리가 실패한 경우에는 도구 결과 안의 실패 표시를 사용할 수 있다. 완성 코드는 잘못된 호출만 시험하며, 실제 연결에서 발생하는 실행 실패 처리까지 구현하지 않는다.

실제 클라이언트와 서버는 도구를 쓰기 전에 초기화와 기능 협상도 수행한다. 알림처럼 응답을 요구하지 않는 메시지도 있다. 따라서 아래 프로그램을 실제 MCP 서버에 그대로 연결할 수는 없다. 이것은 메시지 경계, 목록 조회, 호출, 검증을 배우는 모형이다.

도구 목록을 먼저 확인하고 호출하며 응답의 식별자와 결과를 검증한다

연결 가능성과 사용 권한은 다르다

공통 규칙은 연결 방법을 정하지만, 연결된 서버를 신뢰해도 되는지는 별도 판단이다. 도구 설명에 읽기 전용이라고 적혀 있어도 설명만으로 구현의 동작을 보장할 수는 없다. 호스트의 정책, 서버가 가진 자격 증명, 대상 시스템의 접근 제어가 실제 사용 범위를 제한해야 한다.

예제 서버에는 임시 폴더를 전달한다. 그러나 폴더 경로를 받았다는 이유만으로 그 안의 모든 파일을 읽도록 허용하지 않는다. 허용 목록에 들어 있는 report.txt만 읽는다. 이 선택은 기본 문법만으로 권한 경계를 드러낸다. 사용자가 보낸 경로를 그대로 조합하는 것보다 검토하기도 쉽다.

실제 파일 시스템에서는 심볼릭 링크, 파일 교체, 운영체제 권한도 고려해야 한다. 이번에는 프로그램이 만든 임시 폴더와 파일만 사용하므로 그런 상황을 구현하지 않는다. 외부 저장소를 연결할 때는 자격 증명이 접근할 수 있는 범위를 업무에 필요한 수준으로 제한하고, 비공개 결과를 어떤 모델과 기록에 전달하는지 확인해야 한다.

도구 결과의 텍스트 역시 신뢰 경계 밖에서 온 데이터일 수 있다. 문서에 다른 도구를 호출하라는 문장이 있어도 그것은 문서 내용이다. 연결 규칙이 맞는다는 이유로 그 문장을 하네스의 지침으로 승격하지 않는다. 이 장에서는 제목과 줄 수를 구조화해 받고, 완료 기준과 일치하는지 검사한다.

에이전트끼리 작업을 전달하는 표준도 있다. 에이전트 간 통신(Agent-to-Agent, A2A)이 한 예다. 도구 호출 중심의 연결과 에이전트가 작업을 받아 진행 상황과 결과를 돌려주는 연결은 관심사가 다르다. 두 방식은 함께 쓰일 수 있으며, 이름이 비슷하다고 같은 인터페이스로 취급해서는 안 된다. 여기서는 존재와 역할 차이만 이해하고 구현하지 않는다.

완성 코드

다음 내용을 main.py로 저장한다. 외부 패키지와 네트워크는 사용하지 않는다. 실행 중 만드는 파일은 임시 폴더 안에만 있으며, 실행이 끝나면 정리된다. 서버와 클라이언트는 Python 객체지만 그 사이를 통과하는 값은 JSON 문자열이다.

import json
from pathlib import Path
from tempfile import TemporaryDirectory


class RpcError(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code
        self.message = message


def encode(value):
    return json.dumps(value, ensure_ascii=False, sort_keys=True)


class MiniServer:
    def __init__(self, root):
        self.root = root
        self.allowed = {"report.txt"}

    def tool_description(self):
        return {
            "name": "summarize_document",
            "description": "허용된 문서의 제목과 본문 줄 수를 반환한다.",
            "inputSchema": {
                "type": "object",
                "properties": {"name": {"type": "string"}},
                "required": ["name"],
                "additionalProperties": False,
            },
        }

    def dispatch(self, method, params):
        if method == "tools/list":
            if params != {}:
                raise RpcError(-32602, "목록 조회 인자가 잘못됐다.")
            return {"tools": [self.tool_description()]}

        if method != "tools/call":
            raise RpcError(-32601, "지원하지 않는 메서드다.")

        if set(params) != {"name", "arguments"}:
            raise RpcError(-32602, "호출 필드가 잘못됐다.")
        if params["name"] != "summarize_document":
            raise RpcError(-32602, "등록되지 않은 도구다.")

        arguments = params["arguments"]
        if not isinstance(arguments, dict):
            raise RpcError(-32602, "도구 인자는 객체여야 한다.")
        if set(arguments) != {"name"}:
            raise RpcError(-32602, "문서 이름만 전달해야 한다.")

        name = arguments["name"]
        if not isinstance(name, str) or name not in self.allowed:
            raise RpcError(-32602, "허용된 문서 이름이 아니다.")

        lines = (self.root / name).read_text(encoding="utf-8").splitlines()
        summary = {
            "title": lines[0],
            "body_lines": len(lines[1:]),
        }
        return {
            "content": [{"type": "text", "text": encode(summary)}],
            "isError": False,
        }

    def handle(self, raw):
        request_id = None
        try:
            try:
                request = json.loads(raw)
            except json.JSONDecodeError as exc:
                raise RpcError(-32700, "JSON 해석에 실패했다.") from exc

            if not isinstance(request, dict):
                raise RpcError(-32600, "요청은 객체여야 한다.")
            candidate = request.get("id")
            if type(candidate) is int:
                request_id = candidate
            if (
                request.get("jsonrpc") != "2.0"
                or request_id is None
                or not isinstance(request.get("method"), str)
            ):
                raise RpcError(-32600, "요청 형식이 잘못됐다.")

            params = request.get("params", {})
            if not isinstance(params, dict):
                raise RpcError(-32602, "매개변수는 객체여야 한다.")
            result = self.dispatch(request["method"], params)
            response = {
                "jsonrpc": "2.0",
                "id": request_id,
                "result": result,
            }
        except RpcError as exc:
            response = {
                "jsonrpc": "2.0",
                "id": request_id,
                "error": {"code": exc.code, "message": exc.message},
            }
        return encode(response)


class MiniClient:
    def __init__(self, transport):
        self.transport = transport
        self.next_id = 1

    def request(self, method, params):
        request_id = self.next_id
        self.next_id += 1
        outgoing = {
            "jsonrpc": "2.0",
            "id": request_id,
            "method": method,
            "params": params,
        }
        incoming = json.loads(self.transport(encode(outgoing)))
        if not isinstance(incoming, dict):
            raise ValueError("응답은 객체여야 한다.")
        if (
            incoming.get("jsonrpc") != "2.0"
            or type(incoming.get("id")) is not int
            or incoming["id"] != request_id
        ):
            raise ValueError("응답 식별자 또는 버전이 다르다.")
        if ("result" in incoming) == ("error" in incoming):
            raise ValueError("결과와 오류 중 하나만 있어야 한다.")
        if "error" in incoming:
            error = incoming["error"]
            raise RpcError(error["code"], error["message"])
        return incoming["result"]


def fake_model(task, tools):
    names = {tool["name"] for tool in tools}
    if task != "문서 요약" or "summarize_document" not in names:
        raise ValueError("이 작업에 사용할 도구가 없다.")
    return {
        "name": "summarize_document",
        "arguments": {"name": "report.txt"},
    }


def require(condition, message):
    if not condition:
        raise AssertionError(message)


def main():
    with TemporaryDirectory() as folder:
        root = Path(folder)
        (root / "report.txt").write_text(
            "작업 보고서\n명세 확인\n테스트 통과\n",
            encoding="utf-8",
        )
        client = MiniClient(MiniServer(root).handle)

        tools = client.request("tools/list", {})["tools"]
        print("도구 목록:", ", ".join(tool["name"] for tool in tools))

        call = fake_model("문서 요약", tools)
        result = client.request("tools/call", call)
        require(result.get("isError") is False, "도구 실행 실패")
        content = result["content"]
        require(
            len(content) == 1 and content[0]["type"] == "text",
            "결과 콘텐츠 형식 불일치",
        )
        summary = json.loads(content[0]["text"])
        require(
            summary == {"title": "작업 보고서", "body_lines": 2},
            "완료 기준 불일치",
        )
        print("문서 제목:", summary["title"])
        print("본문 줄 수:", summary["body_lines"])

        rejected = [
            {
                "name": "summarize_document",
                "arguments": {"name": "../secret.txt"},
            },
            {"name": "delete_document", "arguments": {}},
            {"name": "summarize_document", "arguments": {}},
        ]
        for invalid_call in rejected:
            try:
                client.request("tools/call", invalid_call)
            except RpcError as exc:
                require(exc.code == -32602, "예상과 다른 오류 코드")
            else:
                raise AssertionError("잘못된 호출이 허용됐다.")
        print("거부 검사: 3개 통과")

        def wrong_id_transport(raw):
            request = json.loads(raw)
            return encode({
                "jsonrpc": "2.0",
                "id": request["id"] + 1,
                "result": {},
            })

        try:
            MiniClient(wrong_id_transport).request("tools/list", {})
        except ValueError:
            print("응답 식별자 검사: 통과")
        else:
            raise AssertionError("다른 요청의 응답을 받아들였다.")

        print("완료 기준 검사: 통과")


if __name__ == "__main__":
    main()

줄별 해설

가져오기와 RpcError에서는 표준 라이브러리만 사용한다. Path는 경로를 다루고 TemporaryDirectory는 실습 파일의 수명을 제한한다. RpcError는 오류 코드와 메시지를 함께 보관한다. 일반적인 Python 예외를 모두 프로토콜 오류로 바꾸지는 않는다. 예상하지 못한 구현 문제까지 정상 오류처럼 숨기면 원인을 찾기 어려워진다.

encode는 메시지를 JSON 문자열로 바꾼다. ensure_ascii=False는 한글을 읽을 수 있게 유지한다. sort_keys=True는 필드의 표시 순서를 일정하게 만든다. 필드 순서는 요청의 의미를 바꾸지 않지만, 실행 간 메시지를 비교하거나 변경 내역을 검토할 때 도움이 된다.

MiniServer의 생성자는 문서를 읽을 위치와 허용 목록을 보관한다. root는 연결 설정이고 allowed는 접근 정책이다. 두 값을 따로 두면 서버가 어디에 연결되는지와 무엇을 읽을 수 있는지 구분해서 검토할 수 있다. 클라이언트는 파일 경로 전체를 전달하지 않는다.

tool_description은 도구 이름, 설명, 입력 규칙을 반환한다. required는 name이 필요함을 나타내고 additionalProperties는 다른 필드를 받지 않는다는 뜻을 나타낸다. 실제 검사는 dispatch가 수행한다. 설명을 고쳤다면 검사 코드도 같은 규칙을 적용하는지 함께 살펴야 한다.

dispatch의 첫 분기는 목록 조회다. 도구 실행과 목록 조회를 다른 메서드로 구분하므로 클라이언트가 먼저 기능을 발견할 수 있다. 이 모형에서는 목록 조회 매개변수를 빈 객체로 제한한다. 실제 규격에서 허용되는 모든 조회 옵션을 구현한 것은 아니다.

호출 검사 부분은 바깥 필드부터 안쪽 인자로 내려간다. 호출 객체에 name과 arguments만 있는지 확인하고, 등록된 도구인지 검사한다. 이어서 도구 인자가 객체인지, 문서 이름 필드만 있는지 검사한다. 이 순서 덕분에 문자열을 딕셔너리처럼 다루는 실수를 피한다.

문서 이름 검사에서는 문자열 여부를 먼저 확인한다. 그다음 허용 목록에 있는지 검사한다. 허용된 이름으로만 경로를 만들기 때문에 상위 폴더를 가리키는 이름은 읽기 전에 거절된다. 이번 모형에서는 외부가 임시 파일을 바꿀 수 없다는 전제도 함께 둔다.

결과 생성 부분은 첫 줄을 제목으로, 나머지 줄의 개수를 본문 줄 수로 정의한다. 끝의 줄바꿈은 splitlines가 별도 빈 줄로 세지 않는다. 결과를 텍스트 콘텐츠에 담기 위해 작은 요약 객체를 다시 JSON 문자열로 만든다. 바깥 JSON은 호출 응답이고 안쪽 JSON은 이 도구가 정한 업무 결과다.

handle은 메시지 경계를 담당한다. JSON 해석, 요청 형식 검사, 메서드 분배, 응답 직렬화를 한곳에서 수행한다. type(candidate) is int를 쓴 이유는 Python에서 bool도 int의 하위 타입이기 때문이다. 이 모형은 요청 식별자를 정수로 제한하며 True를 식별자로 받지 않는다.

MiniClient.request는 식별자를 발급하고 문자열을 전송한다. transport에는 서버의 handle 메서드가 들어가지만, 클라이언트는 서버의 내부 파일 처리 방법을 알지 못한다. 응답을 받은 뒤 버전과 식별자를 확인하고 result와 error 중 하나만 있는지 검사한다. 외부 연결이라면 오류 객체와 결과 구조의 검증도 더 넓혀야 한다.

fake_model은 고정된 작업과 도구 목록을 보고 호출 인자를 만든다. 도구가 목록에 없으면 실행을 제안하지 않는다. 실제 모델을 사용해도 이 제안은 검증 대상이다. 모델이 이름을 잘 골랐는지와 서버가 호출을 허용하는지는 서로 다른 검사다.

main은 정상 호출과 거부 사례를 함께 실행한다. 제목과 줄 수를 정확한 딕셔너리와 비교하므로 출력이 있다는 이유만으로 완료 처리하지 않는다. 이어서 허용 범위 밖 문서, 미등록 도구, 누락된 인자를 거부하는지 확인한다. 마지막의 가짜 전송 함수는 다른 식별자의 응답을 보내 클라이언트의 검사를 시험한다.

require는 Python의 assert 문 대신 명시적으로 예외를 발생시킨다. 따라서 최적화 옵션으로 실행하더라도 완료 기준 검사가 빠지지 않는다. 도구를 더 붙일 때도 정상 결과뿐 아니라 거절돼야 하는 입력을 함께 확인한다.

실행 결과

macOS 또는 Linux의 Python 3.12 이상에서 다음 명령으로 실행한다. 파일 내용을 수정하지 않았다면 아래 출력이 나온다. 임시 폴더 이름과 실행 시각은 출력하지 않아 결과가 일정하다.

python3 main.py
도구 목록: summarize_document
문서 제목: 작업 보고서
본문 줄 수: 2
거부 검사: 3개 통과
응답 식별자 검사: 통과
완료 기준 검사: 통과

이 출력은 실행 시 확인할 예상 결과다. 실제 파일을 저장한 뒤 직접 실행하고, 예외가 없으며 결과가 일치하는지 확인한다. 컴파일 확인은 파일을 실행하는 검사와 다르다. 문법 검사를 통과해도 도구 선택이나 권한 검사가 틀릴 수 있다.

AI가 이 코드를 수정했다면 변경 전후의 diff를 읽는다. 특히 allowed가 사라졌는지, 경로를 만드는 위치가 검증보다 앞섰는지, 응답 식별자 검사가 삭제됐는지 확인한다. 그런 다음 같은 명령으로 정상·거부 검사를 다시 실행한다. 테스트 통과와 변경 내용 검토를 함께 사용해야 변경의 의미를 이해할 수 있다.

실무에서 자주 틀리는 것

설명에 적힌 규칙이 자동으로 적용된다고 생각한다

도구 설명은 사용자와 모델이 기능을 이해하도록 돕는다. 입력 규칙을 실제로 검사하는 코드는 별도로 필요하다. 다음 두 코드는 각각 독립적으로 실행할 수 있으며, 첫 번째는 허용하지 않은 필드까지 받아들인다.

def accept(arguments):
    return arguments["name"]


print(accept({"name": "report.txt", "extra": "무시되는 값"}))

고친 코드는 객체 여부와 필드 구성을 검사한다. 입력 스키마에서 다른 필드를 금지했다면 실행 코드도 같은 결정을 내려야 한다.

def accept(arguments):
    if not isinstance(arguments, dict):
        raise ValueError("객체가 필요하다.")
    if set(arguments) != {"name"}:
        raise ValueError("문서 이름만 허용한다.")
    if not isinstance(arguments["name"], str):
        raise ValueError("문서 이름은 문자열이어야 한다.")
    return arguments["name"]


print(accept({"name": "report.txt"}))

폴더를 지정했으니 접근 범위도 제한됐다고 생각한다

경로 조합만으로 접근 정책이 만들어지지는 않는다. 다음 예제는 파일을 읽지 않고 경로만 표시하지만, 상위 폴더를 가리키는 입력이 그대로 결합되는 문제를 보여 준다.

from pathlib import Path


def document_path(root, name):
    return root / name


print(document_path(Path("documents"), "../secret.txt"))

허용된 이름만 받는 작은 도구라면 허용 목록을 먼저 검사할 수 있다. 임의 경로를 지원해야 한다면 별도의 경로 정책과 파일 시스템 수준의 제한이 필요하다.

from pathlib import Path


def document_path(root, name):
    if not isinstance(name, str) or name not in {"report.txt"}:
        raise ValueError("허용된 문서가 아니다.")
    return root / name


print(document_path(Path("documents"), "report.txt"))

도착한 응답을 곧바로 현재 요청의 결과로 쓴다

동기 호출에서는 식별자 확인이 불필요해 보일 수 있다. 그러나 전송 구현이 바뀌거나 잘못된 응답이 섞이면 다른 작업의 결과를 사용할 수 있다. 첫 코드는 그 차이를 발견하지 못한다.

response = {"id": 8, "result": {"title": "다른 문서"}}
print(response["result"])

고친 코드는 요청 식별자를 비교하고, 결과와 오류가 동시에 있거나 모두 없는 응답도 거절한다. 아래 코드는 올바른 응답을 넣어 정상 종료한다.

request_id = 7
response = {"id": 7, "result": {"title": "작업 보고서"}}

if type(response.get("id")) is not int:
    raise ValueError("식별자 형식이 다르다.")
if response["id"] != request_id:
    raise ValueError("다른 요청의 응답이다.")
if ("result" in response) == ("error" in response):
    raise ValueError("결과 또는 오류가 하나 필요하다.")
if "error" in response:
    raise ValueError("서버가 오류를 반환했다.")
print(response["result"])

연결 성공을 작업 완료로 처리한다

오류 응답이 없다는 사실은 요청을 처리했다는 뜻에 가깝다. 요구한 문서를 올바르게 요약했다는 뜻까지 포함하지 않는다. 첫 코드는 빈 요약도 성공으로 취급한다.

summary = {}
print("완료")

고친 코드는 업무 완료 기준을 확인한 뒤 완료를 출력한다. 앞 장에서 만든 검증 루프는 연결 방식을 바꾼 뒤에도 필요하다.

summary = {"title": "작업 보고서", "body_lines": 2}
expected = {"title": "작업 보고서", "body_lines": 2}

if summary != expected:
    raise AssertionError("완료 기준 불일치")
print("완료")

한눈에 보기

공통 연결 규칙과 하네스가 각각 책임지는 검사
관점핵심 질문예제의 처리
기능 발견어떤 도구를 사용할 수 있는가tools/list로 설명을 받는다
호출 전달어떤 도구에 어떤 인자를 보내는가tools/call에 이름과 인자를 담는다
응답 연결현재 요청의 응답인가버전과 요청 식별자를 확인한다
입력 규칙인자 구조가 도구 규칙에 맞는가타입과 필드 구성을 검사한다
권한 범위이 문서를 읽어도 되는가허용 목록을 서버에서 확인한다
업무 완료결과가 요구 조건을 만족하는가제목과 본문 줄 수를 비교한다
구현 범위실제 규격과 호환되는가학습용 모형이며 초기화·전송 등은 생략한다

표준으로 연결하면 시스템별 차이를 서버 뒤로 옮기고, 클라이언트의 공통 처리를 재사용할 수 있다. 입력 검증, 접근 정책, 완료 기준은 계속 필요하다. 다음 장에서는 연결된 도구를 어떤 순서와 규칙으로 사용하도록 안내할지 재사용 절차와 팀 규칙의 관점에서 살펴본다.

연습 문제

  1. 도구 설명에 additionalProperties가 False로 들어 있는데도 서버가 필드를 직접 검사하는 이유를 설명하라.
  2. 거부 검사 목록에 문서 이름이 정수인 호출을 추가하라. 실행 결과의 거부 검사 개수도 실제 검사 수에 맞게 바꿔라.
  3. 잘못된 JSON 문자열을 MiniServer.handle에 직접 전달하는 검사를 추가하라. 오류 코드와 응답 식별자가 어떤 값이어야 하는지 확인하라.
  4. 문서 본문 제공, 문서 통계 계산, 반복 검토 질문 제공을 자원·도구·프롬프트에 각각 대응시켜라. 서버가 제공한 검토 질문이 호스트의 권한 정책을 바꿀 수 있는지도 설명하라.

정답과 해설

  1. 입력 스키마는 규칙을 기술하는 데이터다. 검증기를 실행하거나 직접 검사 코드를 작성해야 그 규칙이 적용된다. 완성 코드는 범용 검증기를 사용하지 않으므로 객체 타입, 필드 구성, 이름 타입을 직접 검사한다. 접근 허용 목록은 입력 구조 검사에 더해 적용하는 서버 정책이다.

  2. rejected 목록을 만든 직후 아래 코드를 넣는다. 숫자 이름은 문자열 검사에서 거절되며 오류 코드는 -32602다. 고정된 개수 출력 대신 목록 길이를 사용하면 이후 사례를 더 추가해도 출력이 실제 검사 수와 일치한다.

    rejected.append({
        "name": "summarize_document",
        "arguments": {"name": 123},
    })
    

    기존 거부 검사 출력문은 다음으로 바꾼다. 변경 후에는 거부 검사: 4개 통과가 출력된다.

    print(f"거부 검사: {len(rejected)}개 통과")
    
  3. 다음 코드를 main의 임시 폴더 블록 안에 추가한다. JSON을 해석하지 못했으므로 코드 -32700을 반환한다. 식별자를 읽을 수 없으므로 응답의 id는 JSON의 null에 대응하는 Python의 None이다. 이 검사는 출력하지 않으므로 기존 출력은 유지된다.

    bad_response = json.loads(MiniServer(root).handle("{"))
    require(bad_response["error"]["code"] == -32700, "해석 오류 불일치")
    require(bad_response["id"] is None, "식별자 처리 불일치")
    require("result" not in bad_response, "오류에 결과가 포함됐다.")
    
  4. 문서 본문은 참조 데이터이므로 자원에 대응한다. 문서 통계 계산은 인자를 받아 수행하는 작업이므로 도구에 대응한다. 반복 검토 질문은 재사용할 요청 형식이므로 프롬프트에 대응한다. 서버가 제공한 질문은 호스트가 검토해서 사용하는 입력이며, 호스트의 접근 정책이나 승인 조건을 바꾸는 권한을 얻지 않는다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.