블로그

2026년 8월 15일 · 11분 읽기

MCP 서버 역할별 권한: 프롬프트가 아니라 도구 등록에서 막기

고객 응대 에이전트 6인에게 역할을 나누고 쓰기 권한을 MCP 도구 등록 지점에서 끊었습니다. 읽기 전용이어야 하는 두 역할에 쓰기 도구가 있던 것을 찾습니다.

  • MCP
  • AI 에이전트
  • 권한
  • 운영 자동화

2026년 8월 23일 업데이트

라이브에서 도구 목록을 뽑아 보니 총괄과 지표 역할에도 쓰기 도구가 들어 있었습니다. 그 둘은 읽기만 하기로 한 역할입니다.

프롬프트에는 읽기 전용이라고 적혀 있었습니다. 모델이 그 문장을 지키느냐를 묻는 구조였습니다. 200회에서 절반을 틀리는 모델에게 물을 질문이 아닙니다.

그래서 경계를 옮겼습니다. 호출 직전에 막는 것이 아니라, 도구를 등록하는 자리에서 역할 밖 이름을 버립니다. 없는 도구는 부를 수 없습니다.

쓰기 권한이 갈리는 자리

한눈에 보기

문제 라이브에서 도구 목록을 뽑아 보니 총괄과 지표 역할에도 쓰기 도구가 들어 있었습니다.
결정 그래서 경계를 옮겼습니다.
결과 이 구조에서 모델에게 "하지 말라"고 물어보는 자리가 없습니다.
제약 역할이 없거나 알 수 없는 값이면 서버가 뜨지 않습니다.

여섯 에이전트와 열 개 운영 요소를 같은 숫자로 세지 않았습니다

JustSend Care의 에이전트 로스터는 6인입니다. 하람은 총괄, 리아는 리뷰, 다온은 커뮤니티, 우진은 메일, 세나는 지표, 태산은 운영을 담당합니다. 모두 claude-sonnet-5를 사용하고 출력 언어는 한국어 전용으로 고정했습니다.

문서에 함께 보이는 16은 다른 분류입니다. 6개 에이전트에 릴레이, MCP 서버, 등록 게이트, DB, 외부 도메인과 지표를 더한 10개 운영 구성요소를 합친 “16역” 표가 있습니다. 16개는 앱스토어 출시 언어 수이기도 합니다. 이 셋을 에이전트 인원으로 합치지 않았습니다.

구분 식별자 책임 쓰기 성격
에이전트 haram / 하람 총괄·에스컬레이션·KPI 브리핑 승인 조율
에이전트 ria / 리아 리뷰 16개 언어 응답·평점 승인된 리뷰 답변
에이전트 daon / 다온 Discourse·FAQ·신고 승인된 커뮤니티 답글
에이전트 woojin / 우진 메일 티켓·응답 SLA 승인된 메일 발송
에이전트 sena / 세나 분석·KPI·CSAT 읽기와 기록
에이전트 taesan / 태산 클러스터 상태·장애 1차 대응 승인된 워크로드 조치

운영 구성요소 10개는 care-buzz, care-mcp, scopedServer, care-db, App Store Connect, Discourse, Stalwart, Kubernetes, KPI, CSAT·SLA입니다. 숫자를 맞추려고 나눈 것이 아닙니다. 권한을 줄 대상을 하나씩 가리켜야 해서 나눴습니다.

릴레이는 Deployment 여섯 개를 띄우고, care-mcp는 역할별 무인수 래퍼로 stdio MCP 서버를 실행합니다. 라이브에서 리뷰 역할의 컨테이너를 보면 BUZZ_ACP_MCP_COMMAND=/usr/local/bin/care-mcp-reviews입니다. 이미지는 여섯 개가 같고 ghcr.io/<org>/justsend-care-agent:0.3.0 하나입니다. 달라지는 것은 프롬프트 파일 경로와 래퍼 이름과 CARE_ROLE 값입니다.

leadinsight는 읽기 전용입니다. 리뷰, 커뮤니티, 메일, 클러스터 조치는 각 도메인의 승인 경계를 지납니다.

역할 게이팅 부재가 쓰기 도구 누설로 먼저 드러났습니다

첫 설계에서 모든 역할에 쓰기 도구를 등록했습니다. 프롬프트에 “이 역할은 답변을 보내지 마세요”라고 적는 것만으로는 등록된 도구를 숨기지 못합니다. 라이브에서 lead와 insight에 쓰기 도구가 누출된 결함 (5)을 잡으며 경계의 위치를 바꿨습니다.

도구 등록 지점은 서버가 도구 이름을 실제 MCP 목록에 올리는 순간입니다. 이 지점에서 허용 목록 밖의 이름을 버리면 에이전트 프롬프트가 우회할 호출 대상을 받지 못합니다.

// care-mcp/src/envelope.ts:28-42 원문
export function scopedServer(target: ToolServer, allowed: readonly string[]): ToolServer {
  const allow = new Set(allowed);
  const gate = <T extends unknown[]>(name: unknown, apply: (...args: T) => unknown, args: T): unknown => {
    if (typeof name === 'string' && !allow.has(name)) return undefined;
    return apply(...args);
  };
  return {
    registerTool: target.registerTool
      ? (name, config, handler) => gate(name, (...a: [string, Record<string, unknown>, (input: unknown) => Promise<unknown>]) => target.registerTool?.(...a), [name, config, handler])
      : undefined,

allow에 없는 문자열을 만나면 registerToolundefined를 돌려줍니다. 이 함수는 호출 직전에 권한을 검사하지 않습니다. 처음부터 도구를 등록하지 않습니다.

tool 경로도 같은 게이트를 통과합니다.

// care-mcp/src/envelope.ts:36-42 원문
    tool: target.tool
      ? (...args: unknown[]) => gate(args[0], (...a: unknown[]) => target.tool?.(...a), args)
      : undefined,
  };
}

ROLE_TOOLSETSregisterRoleTools는 역할별 허용 목록을 만들고 모든 도메인 등록을 const target = scopedServer(server as unknown as ToolServer, allowed);로 감쌉니다. asc_review_replyASC_ALL에만 들어가며 lead와 insight에는 노출되지 않습니다.

역할별 허용 목록을 세어 봤습니다.

역할 도구 수 외부에 흔적을 남기는 도구
lead 25 없음
reviews 15 asc_review_reply
insight 12 없음
community 11 discourse_post_reply
mail 10 mail_send
ops 9 cluster_restart_workload

여섯을 합치면 서로 다른 도구가 31개입니다.

표의 순서가 처음에 예상한 것과 반대였습니다. 도구를 가장 많이 가진 역할이 총괄이고, 그 25개에 외부 쓰기가 하나도 없습니다. 읽기 전용이 권한이 적다는 뜻이 아닙니다. 볼 수 있는 것이 가장 많고 남길 수 있는 것이 없습니다.

쓰는 역할은 반대입니다. ops가 아홉 개로 가장 적고 그중 하나가 워크로드 재시작입니다. 역할마다 외부 쓰기 도구가 정확히 하나씩입니다. 무엇을 열었는지 세는 일이 한 줄로 끝납니다.

approval_requestapproval_status는 쓰기 역할 넷에만 등록했습니다. 이 역할은 승인을 요청하고 상태를 바꿀 수 있습니다. leadinsight에는 approval_list만 등록했습니다.

등록 코드 위에 그 의도가 주석으로 적혀 있습니다.

// care-mcp/src/index.ts:47-49
// 도메인 모듈은 자기 도구 전부를 등록한다. 역할 화이트리스트를 등록 지점에서 강제하지 않으면
// lead/insight 같은 읽기 역할에 asc_review_reply·cluster_restart_workload 가 새어 나간다.
const target = scopedServer(server as unknown as ToolServer, allowed);

도메인 모듈은 역할을 모릅니다. registerAsc는 ASC 도구 일곱 개를 다 등록하려고 합니다. 역할을 아는 것은 그 모듈을 감싸는 한 줄입니다. 모듈을 늘릴 때 각 모듈에 역할 검사를 넣지 않아도 됩니다.

등록할 모듈을 고르는 방식도 이름을 봅니다. 허용 목록에 discourse_로 시작하는 이름이 하나도 없으면 Discourse 모듈을 아예 부르지 않습니다. reviews 역할의 프로세스에는 Discourse 클라이언트가 만들어지지 않습니다.

경계 방식 에이전트가 보는 상태 우회 가능성
프롬프트 지시 도구가 목록에 보임 모델이 호출을 시도할 수 있음
호출 직전 검사 도구가 목록에 보임 등록·설명·추론 단계에서 누설
등록 지점 게이트 허용된 도구만 목록에 보임 역할 밖 이름을 등록하지 않음

이렇게 바꾼 뒤 라이브에서 역할 게이팅이 6/6 일치했습니다. 인자를 받는 도구는 전부 스키마도 함께 노출했습니다. 목록과 입력 계약을 같이 봐야 "도구가 없다"와 "도구가 있는데 호출이 실패한다"가 갈립니다.

외부 쓰기와 내부 원장 기록을 같은 스위치로 묶지 않았습니다

외부에 흔적을 남기는 동작은 리뷰 답변, 커뮤니티 답글, 메일 발송, 워크로드 재시작입니다. CARE_WRITE_ENABLED != "1"이면 이 도구들은 실제 외부 호출 대신 {ok:true,data:{dry_run:true,...}}를 반환합니다.

KPI와 CSAT 기록은 이 게이트 밖에 있습니다. 내부 DB 원장에 샘플이나 만족도를 적는 일과 고객에게 답변을 전송하는 일을 같은 스위치로 처리하지 않았습니다. 이 구분이 있어야 dry-run에서도 지표 흐름을 관찰하면서 외부 쓰기만 멈출 수 있습니다.

동작 CARE_WRITE_ENABLED != "1" 기록 위치
asc_review_reply dry-run 봉투 반환 외부 ASC 호출 없음
discourse_post_reply dry-run 봉투 반환 외부 Discourse 호출 없음
mail_send dry-run 봉투 반환 외부 SMTP 발송 없음
cluster_restart_workload dry-run 봉투 반환 워크로드 재시작 없음
KPI·CSAT 기록 게이트 대상 아님 care 스키마 원장

라이브 확인에서 클러스터 재시작은 WRITE_DISABLED로 끝났습니다. 메일 발송도 dry-run 게이트를 지켰고, IMAP 연결·RFC2047 제목·preview_decoded: true를 확인했습니다. 커뮤니티는 토픽 5, 검색 11, 카테고리 7을 확인하고 locale 게이트를 dry-run으로 통과시켰습니다.

KPI는 목표 4.2와 4.5를 비교해 93.33% under_target로 보고되었습니다. CSAT 요약과 SLA 상태도 확인했으며, sla_status는 0건이었습니다.

이 경계는 "쓰기 권한을 열었다"와 "쓰기 호출을 실제로 허용했다"를 나눕니다. 오늘 라이브 컨테이너를 다시 읽어 보니 CARE_WRITE_ENABLED=0, CARE_CLUSTER_WRITE=0입니다. 운영을 시작하면서 승인 절차와 함께 여는 일이 남아 있습니다.

승인은 사람 한 명의 리액션이 아니라 원장 한 행입니다

게이트를 열어도 그것만으로 외부에 나가지 않습니다. 승인 없이는 못 나갑니다.

승인이 필요한 작업이 정확히 넷입니다. asc_review_reply, discourse_post_reply, mail_send, cluster_restart_workload입니다. 역할별 외부 쓰기 도구 넷과 같은 목록입니다.

승인 요청은 채널에 카드를 올리고 DB에 행을 하나 만듭니다. 그 행의 상태가 다섯 가지입니다.

상태 이 상태에서 호출하면
pending 사람이 아직 안 봤다 APPROVAL_REQUIRED
approved 승인됐고 아직 안 썼다 통과
rejected 반려됐다 APPROVAL_REJECTED
expired 기한이 지났다 APPROVAL_EXPIRED
consumed 이미 한 번 썼다 APPROVAL_CONSUMED

consumed가 있는 이유는 재사용을 막는 것입니다. 한 번 승인받은 카드로 두 번 보내면 고객은 같은 답변을 두 번 받습니다. 실제로 보낸 뒤 상태를 consumed로 바꾸고, 조건에 status='approved' AND consumed_at IS NULL을 넣어 두 프로세스가 동시에 써도 하나만 통과하게 했습니다.

본문이 바뀌는 것도 막습니다. 승인 요청을 저장할 때 payload_hash를 함께 넣습니다.

// care-mcp/src/approval.ts — verifyApproval()
if (row.action !== input.action ||
    row.payload_hash !== approvalPayloadHash({ action, target, body, locale }))
  return err('APPROVAL_MISMATCH', '승인 요청의 작업 또는 본문이 일치하지 않습니다.');

사람이 본 문장과 실제로 나가는 문장이 같아야 합니다. 승인을 받고 나서 본문을 고치면 해시가 달라지고 APPROVAL_MISMATCH로 끝납니다. 승인 대상이 "이 작업"이 아니라 "이 문장"입니다.

기한은 기본 1,440분입니다. 하루입니다. 요청할 때 늘릴 수 있고 상한이 10,080분, 즉 7일입니다. 승인 카드가 채널에 무한히 남아 있다가 한 달 뒤에 쓰이는 것을 막습니다.

승인 주체는 CARE_APPROVERS의 공개키입니다. 그 목록이 비어 있으면 승인을 요청하는 것 자체가 APPROVAL_REQUIRED로 끝납니다. 승인자가 없는 상태에서 자기 승인으로 넘어가지 않습니다. 상태를 판정할 때도 자기 공개키가 아닌 것 중 목록에 있는 키의 반응만 셉니다.

오류 코드가 봉투에 열다섯 개 정의돼 있고 그중 다섯 개가 승인 관련입니다. 어디서 막혔는지가 코드 하나로 구분됩니다. WRITE_DISABLEDAPPROVAL_REQUIRED는 다른 사실입니다. 전자는 스위치가 닫힌 것이고 후자는 사람이 아직 안 본 것입니다.

stdio MCP는 애플리케이션 로그와 바이너리 자원을 더 엄격하게 봐야 했습니다

MCP stdio transport는 표준 출력으로 프레이밍된 메시지를 주고받습니다. 서버가 진단 로그를 stdout에 쓰면 클라이언트는 로그를 MCP 메시지로 오해합니다. 실제 결함 16건 중 구조적으로 반복될 범주를 다음처럼 추렸습니다.

결함 관찰 수정
(1) ASC ES256 DER→JOSE 오버플로 전 호출 401 32바이트 정렬, 3/3 200
(2) postgres.js NOTICE가 stdout MCP 프레이밍 파손 stderr로 전환
(3) 컴파일 바이너리에 schema.sql 미포함 /$bunfs ENOENT, DB 영구 불가 텍스트 import 임베드
(4) 동적 import 실패 커뮤니티·메일·클러스터 도구 전부 누락 정적 import
(5) 역할 게이팅 부재 lead·insight에 쓰기 도구 누출 scopedServer
(6) MCP_COMMAND--role 인수 MCP 도구 0개 역할별 무인수 래퍼

첫 번째 결함은 외부 API 인증이고, 두 번째부터 여섯 번째는 실행 경계와 패키징의 문제입니다. stdio를 사용하는 사람에게는 stdout 오염, 단일 바이너리 자원 누락, 동적 로딩 실패가 서로 다른 증상으로 나타나도 같은 배포 형태에서 반복될 수 있습니다.

MCP_COMMAND--role을 직접 넘겼을 때 도구가 0개가 된 뒤, 역할별 무인수 래퍼로 전환했습니다. 역할은 CARE_ROLE 환경 변수도 허용하지만 CLI가 우선하며, 역할이 없거나 알 수 없는 값이면 stderr에 이유를 쓰고 exit 2가 됩니다.

이 수정들은 모델을 바꾸는 작업이 아니었습니다. MCP 서버가 어떤 프레이밍으로 통신하고 어떤 파일을 바이너리에 포함하며 어떤 이름으로 도구를 등록하는지를 고치는 작업이었습니다. 라이브 최종 결과는 테스트 31 pass/0 fail과 linux-x64 컴파일 성공이었습니다.

수치가 맞아도 접근 범위와 운영 상태를 함께 기록했습니다

Care의 운영 표에는 외부 시스템과 내부 원장이 함께 있습니다. 에이전트에게 무엇을 맡겼는지는 이름보다 실제 도구와 상태로 확인했습니다.

영역 관찰한 라이브 값 의미
KPI 4.2 / 4.5, 93.33% under_target 목표 대비 리포트
workload 11 ready 준비된 워크로드 수
클러스터 집계 5 ns, postal 문제 1건 namespace와 문제 검출
커뮤니티 토픽 5, 검색 11, 카테고리 7 읽기 도구 결과
검증 31 pass / 0 fail 자동 테스트 결과

이 수치는 에이전트가 모든 판단을 자동으로 내렸다는 증거가 아닙니다. 무엇을 읽었고, 어떤 외부 쓰기를 dry-run으로 멈췄고, 어떤 DB 기록을 남겼는지를 보여주는 검증 결과입니다.

특히 lead와 insight를 읽기 전용으로 만든 뒤에도 KPI와 CSAT 원장은 게이트 밖에서 기록됩니다. 권한 경계를 좁히면 지표를 잃는 것이 아니라 외부 부작용과 내부 관측을 분리하게 됩니다.

기능 목록보다 이 상태표를 먼저 봅니다. 도구가 등록됐는가, 실제 호출이 막혔는가, 원장에 기록이 남았는가가 각각 다른 질문입니다.

승인할 수 있는 경계를 배포 결과로 남겼습니다

라이브 구성은 care-buzz 릴레이, Deployment 여섯 개, care-mcp stdio 서버, care-db 원장으로 이어집니다. 외부 도메인은 ASC, Discourse, Stalwart, Kubernetes 넷입니다.

운영 역할이 볼 수 있는 범위도 환경 변수에 적혀 있습니다. CARE_KUBE_NAMESPACESjustsend-care, justsend-platform, mail, postal, monitoring 다섯입니다. 앞의 표에서 클러스터 집계가 5 namespace로 나온 것이 이 목록입니다.

오늘 클러스터의 네임스페이스를 세어 보니 열여덟입니다. 그중 다섯을 열었습니다. 빠진 것에 argocd, auth, cert-manager, kube-system이 있습니다. 배포를 바꾸는 곳, 인증을 판정하는 곳, 인증서를 발급하는 곳, 그리고 클러스터 자체입니다. 장애 초동 대응에 필요한 것은 제품 파드의 상태이고, 배포와 인증을 고치는 일은 사람이 합니다.

nodes 읽기는 열지 못했습니다. 노드는 네임스페이스에 속하지 않아서 RoleBinding으로 풀리지 않고 ClusterRoleBinding이 필요합니다. cluster_overview가 nodes를 null로 두고 사유를 붙여 부분 성공으로 돌려주는 것이 그 결과입니다. 실패로 처리하지 않고 무엇이 비었는지 적었습니다.

답할 대상도 좁혀 뒀습니다. 오늘 라이브를 다시 읽어 보니 BUZZ_ACP_SUBSCRIBEmentions에서 config로 바뀌어 있었습니다. 구독 범위를 환경 변수 한 줄이 아니라 역할별 TOML 규칙 파일이 정합니다. BUZZ_ACP_RESPOND_TO=allowlist는 그대로입니다.

담당 채널의 일반 메시지는 멘션 없이 받고, 승인 요청과 리마인더는 이름을 불렀을 때만 받습니다. 규칙 파일의 내용은 24편에 적었습니다.

CARE_APPROVERS에도 공개키가 하나 있습니다. 승인할 수 있는 주체를 프롬프트가 아니라 환경 변수가 갖고 있습니다.

경계가 어느 층에 있는지 한 줄씩 세면 넷입니다.

무엇을 막나 어디에 적혀 있나
도구 등록 역할 밖 도구가 목록에 없다 ROLE_TOOLSETSscopedServer
외부 호출 실제 호출 대신 dry-run 봉투 CARE_WRITE_ENABLED
승인 주체 승인 상태를 바꿀 사람 CARE_APPROVERS
구독 범위 무엇에 답할지 역할별 rules.toml

넷 다 모델 밖에 있습니다. 프롬프트에 적힌 문장이 아니라 코드와 환경 변수와 파일입니다.

역할이 없거나 알 수 없는 값이면 서버가 뜨지 않습니다. parseConfig가 사유를 stderr에 쓰고 exit 2로 끝납니다. 도구 0개로 조용히 도는 상태를 만들지 않으려는 것입니다. --role 인수를 잘못 넘겨 도구가 0개가 됐던 결함이 그 반대 경우였습니다.

이 구조에서 모델에게 "하지 말라"고 물어보는 자리가 없습니다. 역할 밖 도구는 목록에 없고, 외부 쓰기는 환경 게이트에서 dry-run으로 멈추고, KPI와 CSAT는 별도 원장에 남습니다.