MCP API 주소에서 경로가 사라질 때: new URL과 끝 슬래시 진단

MCP 도구의 외부 API 주소를 https://api.example.invalid/v1/로 설정했는데, 실제 요청은 /v1/search가 아니라 /search로 간다. 서버는 살아 있고 인증도 맞는데 404가 난다. 이때는 서버 라우트부터 바꾸기보다 요청 직전에 만들어진 URL을 확인한다.

new URL(input, base)는 문자열 두 개를 이어 붙이지 않는다. /search처럼 슬래시로 시작하는 입력은 호스트의 루트 경로에서 해석된다. 또 base가 /v1로 끝나면 상대 경로 search는 마지막 경로 구간을 바꾼다. /v1/search를 만들려면 이 예에서는 base의 끝 슬래시와 입력의 시작 형태를 함께 맞춰야 한다.

이 글은 JavaScript로 작성한 MCP 도구 내부에서 외부 HTTP API 주소를 조립하는 경우를 다룬다. MCP 자체의 전송 주소나 SDK 등록 API를 바꾸는 안내가 아니다. 확인일은 2026년 10월 24일, 실행 환경은 Node.js v24.19.0이다. 아래 도메인은 예시용이며 네트워크 요청은 실행하지 않았다. 실제 장애 재현이나 운영 복구 기록과 구분한다.

첫 응답이 오기 전부터 주소가 달라질 수 있다

Node.js 24.x의 URL 생성자 문서는 상대 입력을 base에 대해 해석하고, 절대 URL 입력에는 base를 사용하지 않는다고 설명한다. WHATWG URL 파싱 규칙에서는 상대 경로와 슬래시로 시작하는 경로를 서로 다른 상태에서 처리한다. 2026년 10월 24일 확인한 Living Standard의 표시 갱신일은 10월 19일이다. 특정 MCP revision에서 생긴 새 규칙으로 해석하지 않는다.

입력과 base만들어지는 경로·목적지
search + 끝이 /v1//v1/search
/search + 끝이 /v1//search
search + 끝이 /v1/search
../search + 끝이 /v1//search
//other.example.invalid/searchbase의 HTTPS scheme을 쓰지만 호스트는 바뀜

끝 슬래시는 장식이 아니다. base가 어떤 경로를 뜻하는지에 참여한다. 모든 설정에 슬래시를 무조건 추가하는 방식도 적절하지 않다. 설정값이 이미 완성된 단일 엔드포인트인지, 하위 경로를 붙일 디렉터리형 base인지 먼저 정한다. /v1와 /v1/가 실제 서버에서 같은 자원을 뜻하는지는 별도 문제다.

네트워크 없이 여섯 가지 주소를 비교한다

아래 코드를 resolve.mjs로 저장해 node resolve.mjs로 실행한다. 모두 URL 문자열 해석 검사다. 실행하면 여섯 개의 예상 주소를 대조하고 성공 메시지를 출력한다. 정상 API 주소처럼 보이는 문자열도 실제 DNS나 서버로 보내지 않는다.

import assert from 'node:assert/strict';

const cases = [
  ['search', 'https://api.example.invalid/v1/',
    'https://api.example.invalid/v1/search'],
  ['/search', 'https://api.example.invalid/v1/',
    'https://api.example.invalid/search'],
  ['search', 'https://api.example.invalid/v1',
    'https://api.example.invalid/search'],
  ['../search', 'https://api.example.invalid/v1/',
    'https://api.example.invalid/search'],
  ['//other.example.invalid/search', 'https://api.example.invalid/v1/',
    'https://other.example.invalid/search'],
  ['https://other.example.invalid/search', 'https://api.example.invalid/v1/',
    'https://other.example.invalid/search'],
];
for (const [input, base, expected] of cases) {
  assert.equal(new URL(input, base).href, expected);
}
console.log('6 URL resolution assertions passed');

위 여섯 assertion은 Node.js v24.19.0에서 통과했다. 첫째·둘째·셋째 사례만으로도 base의 끝 슬래시와 상대 입력의 시작 슬래시가 서로 독립적인 조건임을 확인할 수 있다. 마지막 두 사례는 base를 제공했다는 이유만으로 목적지 호스트가 고정되지 않음을 보여준다. 문법적으로 파싱에 성공한 URL이 허용된 목적지라는 뜻은 아니다.

요청 직전·프록시·서버 경로를 나눠 본다

1. 설정의 의미를 확인한다. base가 API 루트인지 완성된 엔드포인트인지 문서와 대조한다. 설정이 들어오는 경계에서 타입도 확인한다. URL 생성자는 문자열이 아닌 값도 문자열로 바꿀 수 있으므로, 설정 오류를 그 변환에 맡기지 않는다.

2. 전송 직전에 해석된 값을 확인한다. origin과 pathname을 예상값과 비교한다. 실제 서비스의 쿼리, URL 사용자정보, 인증 헤더, 고객 식별자가 들어간 경로를 공개 로그에 복사하지 않는다. 필요한 경우 경로 자체 대신 예상 경로와의 일치 여부를 기록한다. 이 단계에서 이미 경로가 틀렸으면 프록시보다 URL 생성 코드를 먼저 조사한다.

3. 전송값이 맞을 때 다음 경계로 간다. 승인된 읽기 요청과 제한된 접근 기록으로 프록시의 경로 재작성, 서버가 받은 경로, 리디렉션 여부를 확인한다. 요청 전 주소 조립 오류와 응답 후 주소 이동은 다른 원인이다. 리디렉션이 관측되면 HTTP 리디렉션과 POST 변경 진단을 함께 본다. 404 하나만으로 어느 구간이 틀렸는지는 알 수 없다.

입력은 경로 대신 허용한 작업 이름으로 받는다

외부 사용자가 임의 URL을 지정할 필요가 없는 도구라면, 경로 전체를 받기보다 search 같은 좁은 작업 이름을 받고 서버가 정한 경로로 대응시키는 편이 검토하기 쉽다. 다음은 두 개의 읽기 경로만 가진 가상 API용 예제다. 실제 경로·권한·쿼리 제한은 해당 서비스의 계약에 맞춰 별도로 정해야 한다.

import assert from 'node:assert/strict';

const base = new URL('https://api.example.invalid/v1/');
const routes = new Map([
  ['search', 'search'],
  ['summary', 'documents/summary'],
]);

function buildApiUrl(operation, query) {
  if (typeof operation !== 'string' || !routes.has(operation)) {
    throw new TypeError('Unsupported operation');
  }
  if (typeof query !== 'string' || query.length > 200) {
    throw new TypeError('Invalid query');
  }
  const url = new URL(routes.get(operation), base);
  if (url.origin !== base.origin ||
      !url.pathname.startsWith(base.pathname) ||
      url.username || url.password || url.hash) {
    throw new TypeError('Unexpected destination');
  }
  url.searchParams.set('q', query);
  return url;
}

const result = buildApiUrl('search', 'a+b');
assert.equal(result.pathname, '/v1/search');
assert.equal(result.searchParams.get('q'), 'a+b');
assert.equal(buildApiUrl('summary', 'demo').pathname,
  '/v1/documents/summary');
assert.throws(() => buildApiUrl('/search', 'x'), TypeError);
assert.throws(() => buildApiUrl('https://other.example.invalid/', 'x'),
  TypeError);
assert.throws(() => buildApiUrl('search', null), TypeError);
assert.throws(() => buildApiUrl('search', 'x'.repeat(201)), TypeError);
console.log('7 destination-builder assertions passed');

이 코드를 build-url.mjs로 저장해 node build-url.mjs로 실행하면 일곱 개의 assertion을 확인한다. 위 코드도 실제 실행해 통과했다. 두 코드의 합성 검사는 총 13개다. query.length > 200은 이 가상 예제에서 정한 UTF-16 코드 단위 길이 제한이며, API의 공통 규격이나 바이트 크기 제한이 아니다. 쿼리는 searchParams.set에 원문으로 넣어 경로 조립과 분리했다.

이 예제의 경로 접두 확인은 /v1/라는 끝 슬래시가 있는 고정 base와 개발자가 작성한 routes에 한정된다. 이를 임의 URL을 허용하는 범용 보안 검사로 가져다 쓰지 않는다. 작업 이름을 URL이나 경로로 그대로 치환하는 코드로 바꾸면 예제의 전제가 사라진다. 도구 실행 권한과 외부 API의 사용자별 접근 권한도 이 함수 밖에서 검증해야 한다.

origin 검사만으로 끝내지 않는다

Node.js 문서는 base와 함께 절대 입력이 들어올 수 있다면 결과의 origin을 확인하라고 권한다. 다만 같은 origin 안에서도 경로가 달라질 수 있다. 원래 목적이 /v1/ 아래의 제한된 작업이라면 허용 경로와 작업의 의미까지 확인한다. 문자열 앞부분이 비슷한지만 보고 호스트를 승인하지 말고 URL로 해석한 구성요소를 비교한다.

목적지 검사는 리디렉션, DNS 해석, 내부망 접근 통제, 응답 크기 제한을 대신하지 않는다. 특히 외부 입력으로 임의 주소를 받는 서비스의 SSRF 방어는 이 작은 함수의 범위를 넘는다. HTTPS 오류를 무시하거나 인증정보를 다른 목적지에 복사해 문제를 해결하려 하지 않는다. 권한이 있는 기존 읽기 흐름에서 관측하고, 쓰기 요청이 이미 실행됐을 가능성이 있다면 재전송보다 대상 상태 확인을 먼저 한다.

무엇을 확인하면 이 진단을 끝낼 수 있을까

합성 검사 통과만으로 운영 문제를 닫지 않는다. 실제 설정으로 만든 URL의 origin·경로가 예상과 일치하고, 승인된 읽기 요청이 같은 경로로 서버에 도착하며, 반환 데이터가 기대한 자원을 가리키는지 확인해야 한다. 첫 요청은 맞지만 후속 요청만 달라지면 리디렉션이나 미들웨어를 따로 본다. 다른 환경의 base 설정도 같은 계약인지 대조한다.

관련 경계로는 fetch 응답의 HTTP 오류 판정과 URLSearchParams의 +와 이중 인코딩이 있다. 이 글의 핵심은 슬래시 한 글자를 바꾸는 요령보다, 설정·URL 해석·전송·서버 수신을 구분해 주소가 처음 달라지는 지점을 찾는 데 있다.

검증 범위: 공식 자료는 확인일의 Node.js 24.x URL 문서와 WHATWG URL Living Standard를 직접 대조했다. 로컬 실행은 Node.js v24.19.0의 URL 생성·쿼리 조립 합성 검사 13개이며, MCP SDK 설치·호출, 실제 API 통신, 프록시 설정 변경, 운영 장애 수정은 수행하지 않았다. 따라서 특정 SDK 버전의 동작 보장이나 실제 서비스 복구 성공으로 읽어서는 안 된다.


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

RUDA DIRECTOR에서 더 알아보기

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

계속 읽기