등록하지 않은 MCP 도구 이름이 통과할 때: Object.hasOwn과 상속 속성 진단

MCP 서버에 등록하지 않은 toString이라는 이름을 넣었는데 “도구가 있다”는 검사부터 통과하는가? 일반 JavaScript 객체를 도구 목록으로 쓰면서 상속된 속성까지 조회하고 있는지 확인한다. name in registry나 if (registry[name])는 직접 등록한 도구만 판별하는 검사가 아니다. 호출 전 직접 소유한 키인지, 그 값이 실행 가능한 핸들러인지 차례로 확인해야 한다.

이 글은 JavaScript/Node.js로 직접 만든 도구 조회 코드에 적용한다. 특정 MCP SDK에서 이 결함이 발생한다고 주장하는 글은 아니다. 도구 이름이 겹치는 여러 서버의 연결 문제와도 다르다. 여기서는 서버 하나의 조회 표에 적지 않은 이름이 왜 존재하는 것처럼 보이는지 살핀다.

먼저 확인할 것: 값이 있다는 것과 등록했다는 것

const registry = { search: () => 'synthetic result' };
console.log('toString' in registry);              // true
console.log(typeof registry.toString);           // 'function'
console.log(Object.hasOwn(registry, 'toString')); // false

일반 객체는 프로토타입을 통해 속성을 물려받는다. 위 합성 객체의 toString은 등록한 핸들러가 아니라 상속된 메서드다. 따라서 typeof registry[name] === "function"만 추가해도 이 사례는 걸러지지 않는다. 직접 등록한 키인지 먼저 판정하고, 그다음 함수인지 확인한다.

Object.hasOwn은 직접 가진 속성의 존재를 검사한다. 값이 undefined여도 그 속성을 직접 갖고 있다면 true다. 존재·값·호출 가능 여부는 각각 다른 질문이다. 이 구분은 Object.hasOwn 공식 알고리즘과 HasProperty·HasOwnProperty 정의에서 확인할 수 있다.

증상별로 좁히는 진단 순서

1. 요청 이름과 등록 목록을 비교한다. 운영 요청을 다시 실행하지 말고, 승인된 합성 입력에서 Object.keys(registry)와 요청 이름을 비교한다. 단, Object.keys는 열거 가능한 직접 속성만 나열하므로 목록에 없다는 이유만으로 직접 속성도 없다고 단정하지 않는다. 최종 판정은 Object.hasOwn으로 확인한다. 고객 인자·인증값·전체 응답을 로그에 남길 필요는 없다.

2. 조회 조건을 찾는다. name in registry, registry[name] !== undefined, truthy 검사, registry.hasOwnProperty(name)를 각각 살핀다. 앞의 조건들은 등록 여부와 값의 의미를 섞기 쉽고, 마지막 방식은 객체의 같은 이름 속성이 메서드를 덮어썼거나 프로토타입이 null인 객체에서 실패할 수 있다.

3. 조회 표의 출처를 확인한다. 아래 수정 예제는 개발자가 직접 정의한 일반 데이터 속성으로 이루어진 표를 전제로 한다. 외부 객체를 그대로 등록표로 받거나 Proxy·getter를 허용하면 조회 자체가 코드를 실행할 수 있다. 이 경우 hasOwn 하나를 추가하는 것으로 검토를 끝내지 말고, 등록 단계부터 허용된 이름과 핸들러만 구성해야 한다.

4. 실제 실행과 권한을 분리한다. 등록된 함수가 있다는 사실은 그 호출자에게 실행 권한이 있다는 뜻이 아니다. 조회 검사 다음에도 인자 스키마·사용자 권한·대상 범위 검증을 유지한다. 알 수 없는 이름을 첫 번째 도구로 보내는 fallback은 두지 않는다.

복사 예제: 등록·호출 가능·입력형을 따로 검사하기

다음은 네트워크와 MCP SDK 없이 실행하는 합성 단위 예제다. registry-check.mjs로 저장한 뒤 node registry-check.mjs로 실행할 수 있다. search는 고정 문자열만 반환하고 disabled는 의도적으로 실행 불가능하게 둔다. 예외 문구는 이 예제의 내부 구분용이며 MCP 오류 응답 형식을 구현한 것이 아니다.

import assert from 'node:assert/strict';
const registry = Object.freeze({
  search: () => 'synthetic result',
  disabled: undefined
});
assert.equal('toString' in registry, true);
assert.equal(typeof registry.toString, 'function');
assert.equal(Object.hasOwn(registry, 'toString'), false);
assert.equal(Object.hasOwn(registry, 'disabled'), true);
assert.equal(registry.disabled, undefined);

function resolveHandler(name) {
  if (typeof name !== 'string') {
    throw new TypeError('Tool name must be a string');
  }
  if (!Object.hasOwn(registry, name)) {
    throw new Error('Unknown tool');
  }
  const handler = registry[name];
  if (typeof handler !== 'function') {
    throw new Error('Tool is not callable');
  }
  return handler;
}
assert.equal(resolveHandler('search')(), 'synthetic result');
for (const name of ['toString', 'constructor', '__proto__', 'missing']) {
  assert.throws(() => resolveHandler(name), /Unknown tool/);
}
assert.throws(() => resolveHandler('disabled'), /not callable/);
assert.throws(() => resolveHandler(null), TypeError);
assert.throws(() => resolveHandler({ toString: () => 'search' }), TypeError);

const shadowed = { hasOwnProperty: false, search: 1 };
assert.equal(Object.hasOwn(shadowed, 'search'), true);
const bare = Object.create(null);
bare.search = 1;
assert.equal(Object.hasOwn(bare, 'search'), true);
assert.equal('toString' in bare, false);
const handlers = new Map([['search', () => 'synthetic result']]);
assert.equal(handlers.has('toString'), false);
assert.equal(handlers.has('search'), true);
console.log('18 assertions passed; synthetic lookups only');

Node.js v24.19.0에서 실행한 결과는 18 assertions passed; synthetic lookups only였다. 상속 이름 세 개와 미등록 이름 거부, undefined 값의 등록 상태와 호출 불가 상태, 문자열이 아닌 입력 거부, 같은 이름 메서드가 가려진 객체, null 프로토타입 객체, Map의 키 조회를 확인했다. 실제 외부 도구나 고객 데이터를 호출하지 않았다.

객체를 유지할지 Map으로 옮길지

선택조회 방식과 조건
일반 객체 유지Object.hasOwn으로 직접 키 확인 후 값의 형태 검사. 기존의 정적인 표를 좁게 수정할 때 적합하다.
Object.create(null)기본 프로토타입 상속을 없앤 표. 객체 자체의 hasOwnProperty 메서드를 기대하지 말고, 등록 입력 검증은 계속한다.
Maphas와 get으로 저장한 엔트리 조회. 대괄호 접근은 Map 엔트리 조회가 아니므로 전체 호출 지점을 함께 바꾼다.

Map 인스턴스에도 메서드와 프로토타입은 있다. 장점은 프로토타입이 없다는 데 있지 않고, map.has(name)와 map.get(name)이 저장된 엔트리를 조회한다는 데 있다. map[name]으로 바꾸면 같은 구분을 다시 놓친다. Map.prototype.has 공식 알고리즘을 기준으로 조회 방식을 선택한다.

어느 선택도 외부 입력을 안전한 등록 자료로 바꾸어 주지는 않는다. 등록명 허용 규칙, 중복 등록 차단, callable 검사, 권한 검사는 별도다. Object.freeze도 표의 직접 변경을 막는 보조 수단일 뿐 상속 키를 없애거나 모든 보안 문제를 해결하지 않는다. 예제의 freeze와 own-key 검사는 역할이 다르다.

MCP 연결 경계에서 남겨 둘 검사

기준 문서는 MCP revision 2026-07-28의 Tools이며 확인일은 2026년 10월 24일 KST다. 공식 명세는 서버의 도구 입력 검증과 접근 통제를 요구한다. 이 글의 lookup 함수는 그중 등록표 조회 문제만 다룬다. 실제 핸들러에서 예외를 응답으로 바꾸는 방식은 사용하는 SDK의 정확한 버전과 프로토콜 오류·도구 실행 오류 구분에 맞춰야 한다. MCP Tools 보안 고려사항

실서비스 적용 전에는 합성 요청으로 미등록 이름이 실행 경로에 도달하지 않는지 확인하고, 정상 이름의 인자 검증과 접근 통제가 그대로 유지되는지도 시험한다. 이름을 거부한 단위 테스트가 통과해도 실제 SDK·클라이언트의 오류 표시, 다른 연결의 라우팅, 배포된 코드 반영은 추가 확인 대상이다. 내부 스택이나 요청 원문을 오류 응답에 그대로 넣지 않는다.

확인 범위와 관련 글

ECMAScript의 in 연산자·HasProperty·HasOwnProperty·Object.hasOwn·Map.has 설명을 공식 원문과 대조했다. 연결된 TC39 문서는 열람 당시 표제가 ECMAScript 2027인 살아 있는 초안이다. 이를 모든 런타임의 지원 보장이나 확정된 최신판 주장으로 쓰지 않는다. 실행 검증은 위 Node.js 버전의 합성 코드에 한정되며 MCP SDK는 설치·실행하지 않았다. in 연산자 공식 평가 규칙

두 서버의 정상 등록 이름이 충돌한다면 도구 이름 충돌과 호출 경로 진단을 본다. 필드가 없어진 문제라면 JSON 직렬화 전후 진단이 가깝다. 이번 진단의 종료 조건은 단순히 오류 문구가 사라지는 것이 아니라, 직접 등록한 실행 가능한 이름만 조회를 통과하고 호출 권한 검사는 그 뒤에 그대로 남는 것이다.


다른 글 보기 · 주제 탐색 · 작성자와 편집 기준 · 문의·정정 요청

RUDA DIRECTOR에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기