거절하는 도구 — 스키마는 약속이지 보장이 아니다

5 분
목표MCP 서버 강좌 3편. 입력 스키마를 붙이고, 그 스키마를 믿지 않는 핸들러를 쓴다. 0명을 들여보내라는 요청과 99명을 들여보내라는 요청이 각각 다른 이유로 거절되는 것까지.

2편의 도구는 인자를 안 받았다. 이번엔 받는다. 그리고 받은 걸 안 믿는다.

스키마

inputSchema: const {
  'type': 'object',
  'properties': {
    'count': {'type': 'integer', 'description': 'How many to admit, 1 or more'},
  },
  'required': ['count'],
},

이걸 붙이면 부르는 쪽이 무엇을 보내야 하는지 안다. 모델이라면 여기 맞춰 인자를 만든다.

그런데 스키마는 보장이 아니다

'type': 'integer' 라고 적었다고 정수가 오는 게 아니다. 스키마는 부르는 쪽에 대한 안내이고, 그 쪽이 안 지킬 수도 있고 못 지킬 수도 있다.

그래서 핸들러가 다시 본다.

handler: (args) async {
  final n = args['count'];
  // The schema says integer; the caller may still send anything.
  if (n is! int || n < 1) {
    return CallToolResult(
      content: [TextContent(text: 'desk.admit: count must be 1 or more')],
      isError: true,
    );
  }
  // And the desk cannot admit people who are not there.
  if (n > waiting) {
    return CallToolResult(
      content: [TextContent(text: 'only $waiting waiting')],
      isError: true,
    );
  }
  waiting -= n;
  return CallToolResult(
    content: [TextContent(text: jsonEncode({'waiting': waiting}))],
  );
},

두 거절은 성질이 다르다

검사가 둘인데 이유가 다르다.

n < 1 — 값 자체가 말이 안 된다. 0명을 들여보내라는 건 언제나 틀렸다. 입력의 문제다.

n > waiting — 값은 멀쩡한데 지금 상태에서 불가능하다. 3명 대기 중에 99명을 들여보낼 수 없다. 하지만 100명이 대기 중이면 99는 정상이다. 상태의 문제다.

이걸 구분해야 하는 이유는 메시지 때문이다.

count must be 1 or more     ← 다시 보내도 똑같이 틀림
only 3 waiting              ← 3 이하로 보내면 됨

isError: true 로 뭉뚱그리면 부르는 쪽이 재시도할지 포기할지 모른다.

조용히 받으면 안 되는 이유

거절 대신 이렇게 쓸 수도 있다.

if (n > waiting) n = waiting;   // 조용히 맞춰 준다

돌아가긴 한다. 그런데 부른 쪽은 99명을 들여보냈다고 믿는다. 화면에 99가 뜨고, 장부에 3이 남고, 마감에 안 맞는다. 그리고 어디서 어긋났는지 찾을 방법이 없다.

계측 편의 12V 클램프와 같은 자리다. 잘랐으면 잘랐다고 말해야 한다.

검증

세 요청을 연달아 보낸다.

ask step3 \
  '...{"name":"desk.admit","arguments":{"count":0}}}' \
  '...{"name":"desk.admit","arguments":{"count":99}}}' \
  '...{"name":"desk.admit","arguments":{"count":1}}}'

grep -q 'count must be 1 or more' captures/s3.txt || die "zero was not refused"
grep -q 'only 3 waiting'          captures/s3.txt || die "over-count was not refused"
grep -q 'waiting.*2'              captures/s3.txt || die "a valid admit did not go through"
step3  refused 0 and 99, admitted 1 -> waiting 2

세 번째 검사가 중요하다. 앞 둘만 있으면 "전부 거절하는 도구" 도 통과한다. 거절이 되는지와 통과가 되는지를 같이 봐야 한다.

직접 돌려보기

dart run bin/step3.dart
bash verify.sh

가져갈 것

  1. 스키마 + 핸들러 검사 — 스키마는 안내, 검사는 방어
  2. 거절을 두 종류로 — 입력의 문제와 상태의 문제는 메시지가 달라야 한다
  3. 거절 검사 + 통과 검사 — 둘 다 있어야 검증이 성립한다

다음 편

화면을 붙인다. 코드가 아니라 서버가 내주는 문서로.

샘플 실행하기

makemind-academy/course_server/
git clone https://github.com/makemind-academy/course_server
cd course_server
dart pub get
dart run bin/step3.dart
GitHub에서 열기 →