에이전트가 내 DB 를 지웠다 · 에이전트가 DB 를 지우기 전에 · 이론
에이전트가 DB 를 지우기 전에 — 허용 목록·읽기 전용·확인
한 줄 요약
파괴적인 도구를 안전하게 만드는 장치는 세 겹이다. 만능 도구를 없애고 좁은
도구를 허용 목록으로 켜고 끈다, 쓰기가 필요 없는 자리는 연결 자체를 읽기
전용으로 연다, 지우는 도구는 확인 없이는 돌지 않는다. 스펙의 "인간이 거부할
수 있어야 한다" 는 문장이 코드가 되면 이 셋이다.
왜 이게 필요했나
사고의 재료는 늘 같다. 편해서 만든 run_sql 하나. 모델에게 SQL 을 쓰게 하면
도구를 열 개 만들 필요가 없고 데모는 놀랍게 잘 된다. 그리고 어느 날 "지난달
테스트 주문을 정리해 줘" 라는 말이 DROP TABLE orders 가 된다. 모델은 가장
짧은 길을 골랐을 뿐이고, 서버는 그 길에 아무 문도 두지 않았다.
[스펙 개요](https://modelcontextprotocol.io/specification/2025-06-18)의 "Security
and Trust & Safety" 절은 이 상황을 정면으로 다룬다. 도구는 임의 코드 실행이며
그에 맞는 주의로 다뤄야 하고, 도구 설명이나 주석(annotations)은 신뢰할 수 있는
서버가 아니면 믿지 말아야 하며, 호스트는 도구를 부르기 전에 사용자의 명시적
동의를 얻어야 한다. [도구 스펙](https://modelcontextprotocol.io/specification/2025-06-18/server/tools)
은 더 구체적이다 — 사람이 도구 호출을 거부할 수 있는 자리가 항상 있어야
하고(SHOULD), 서버는 모든 입력을 검증하고 접근 제어를 구현하고 호출 빈도를
제한해야 한다(MUST). 스펙이 프로토콜 층에서 이것을 강제할 수는 없다고 스스로
적어 두었기 때문에, 구현하는 사람이 넣지 않으면 아무 데도 없다.
어떻게 동작하나
첫째, 도구를 좁힌다. run_sql 대신 count_orders, list_customers,delete_order 처럼 하나의 의도에 하나의 도구를 둔다. 좁은 도구는 인자 스키마가
좁고, 스키마가 좁으면 모델이 만들 수 있는 요청의 공간이 좁다. delete_order
의 인자는 id 하나뿐이라 "테이블을 지우는 방법" 이 아예 존재하지 않는다.
이것이 접근 제어의 첫 층이고, 다른 어떤 검사보다 확실하다 — 검사할 것이 없다.
둘째, 허용 목록으로 켠다. 서버는 정의된 도구 전부(ALL_TOOLS)와 지금 켤
도구(allowlist.json)를 나눈다. tools/list 는 목록에 있는 것만 내고,tools/call 도 목록 밖 이름은 모르는 도구로 답한다(-32602). 목록 파일이
없거나 깨졌으면 아무 도구도 켜지 않는다 — 닫힌 기본값이다. 운영 환경에서는
읽기 도구만 켜고, 삭제 도구는 필요한 작업 시간에만 켜는 식으로 쓴다.
"있는 도구 중 끌 것을 고른다" 가 아니라 "목록에 없으면 없는 도구다" 여야
새 도구가 추가될 때 실수로 열리지 않는다.
셋째, 읽기 전용은 연결에서 건다. SQL 문자열이 SELECT 로 시작하는지 검사하는
방식은 검사할 것이 끝없이 늘어난다 — 여러 문장을 이어 붙인 입력, 주석과
대소문자 변형, WITH 로 시작하는 읽기 문장(SQLite 는 WITH ... DELETE 도
허용한다)까지 하나씩 따라가야 하고, 놓친 하나가 사고다. 대신 파이썬 표준
라이브러리의 [sqlite3](https://docs.python.org/3/library/sqlite3.html) 가 지원하는
URI 로 mode=ro 를 붙여 여는 것이 답이다.
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)con.executescript("DROP TABLE orders;") # sqlite3.OperationalError: attempt to write a readonly database쓰기가 데이터베이스 엔진에서 거부되므로 문자열을 어떻게 꾸며도 통하지 않고,
서버 코드에는 검사할 목록이 아예 없다. 서버는 그 예외를 잡아 isError: true
로 돌려주면 된다. 환경변수 하나
(MCP_READ_ONLY=1)로 켜서, 조회만 필요한 배포에서는 쓰기 경로가 아예 없게 한다.
넷째, 파괴적 도구는 확인을 받는다. delete_order 는 confirm 인자가
불리언 true 일 때만 지운다. 아닐 때는 지우지 않고, 지웠다면 무엇이 사라졌을지
(주문 번호·상태·금액)를 isError: true 텍스트로 돌려준다. 그 텍스트를 호스트가
사람에게 보여 주고, 사람이 승인하면 모델이 confirm: true 로 다시 부른다.
스펙이 말하는 "human in the loop" 가 이 왕복이다. 문자열 "true" 를 받아 주면
안 된다 — 스키마가 boolean 이면 is True 로 비교한다. 도구 정의의annotations 에 readOnlyHint·destructiveHint 같은 힌트를 달 수 있지만,
스펙은 클라이언트가 이 주석을 신뢰할 수 있는 서버가 아니면 믿지 말라(MUST)고
한다. 힌트는 화면 표시용이지 안전장치가 아니다.
현장에서 만나는 모습
사고 뒤의 복구는 백업에서 한다. 그런데 더 중요한 것은 사고 기록이다 — 원인은
무엇이었나(도구 설계), 어떤 장치가 없었나(허용 목록·읽기 전용·확인), 무엇을
바꾸나. 이 세 줄이 없으면 다음 서버도 run_sql 로 시작한다. 이 모듈의 실습이
사고 재현 → 복구 → 기록 → 세 겹의 장치 순서인 이유다.
또 하나. 확인 단계를 넣은 뒤 "에이전트가 매번 물어봐서 느리다" 는 불만이
나온다. 답은 확인을 빼는 것이 아니라 도구를 더 좁히는 것이다. delete_order
가 아니라 cancel_test_order(테스트 주문만, 상태 변경만) 같은 도구라면
확인이 필요 없을 만큼 안전하다. 안전은 확인 창의 개수가 아니라 도구가 할 수
있는 일의 크기에서 온다.
다음 실습에서 할 것
run_sql 서버로 DROP TABLE orders 를 실제로 보내 orders 가 사라지는 것을
보고, 복구하고, 사고 기록을 쓴다. 그 뒤 읽기 전용 모드, 허용 목록, 확인 인자를
차례로 넣어 같은 공격이 세 곳에서 막히는 것을 증명한다.