검색
← 모듈 로드맵

Claude Code의 MCP 서버 instructions 처리 방식

발행 출처 DEV · Rulestack

클로드 코드사례전문가

나중에 보기좋아요
공유 · 인쇄
후기댓글

MCP 서버는 연결될 때 평문 문단 하나를 클라이언트에 전달할 수 있다. 바로 instructions 필드다. 이 필드는 서버 작성자가 모델에게 그 서버가 무엇을 위한 것인지, 언제 그것을 사용해야 하는지를 산문 형태로 알려줄 수 있는 유일한 자리다. Claude Code의 MCP 문서는 이 필드가 "tool search가 활성화되면 더 유용해진다"고 말한다. 세션 시작 시에는 도구 이름과 서버 지시사항만 로드되기 때문이다. 그리고 우리의 tool search 측정도 같은 조언으로 끝났다. 이번에는 필드 자체를 측정했다. Claude Code가 이 텍스트를 어디에 넣는지, 얼마나 남기는지, 언제 도착하는지, 그리고 서버 응답들 중 어느 응답에서 가져오는지를 살펴봤다.

실험실은 양 끝에 코드워드가 있고 가운데에 위치 표식이 들어 있는 작은 stdio 서버 하나, 그것을 상대로 한 20개의 claude -p 실행, 그리고 실행마다 세 가지 증거다: 모델의 답변, ~/.claude/projects/ 아래의 세션 기록, 그리고 서버가 받는 모든 JSON-RPC 메시지를 기록하는 로그. 아래 내용은 2026-09-30에 Claude Code 2.1.285(claude --version), --model opus(이는 claude-opus-5-5로 해석됨), 그리고 macOS의 Node v22.22.2로 실행한 결과다.

사양과 문서가 말하는 것

MCP 사양의 2025-11-25 개정판에서는 instructions가 initialize에 대한 응답의 선택적 필드이며, 생명주기 페이지는 순서에 엄격하다. "초기화 단계는 반드시 클라이언트와 서버 사이의 첫 상호작용이어야 한다." 예시 응답은 끝에 "instructions": "클라이언트용 선택적 지시사항"을 붙이며, 스키마는 이 필드를 다음과 같이 설명한다.

서버와 그 기능을 사용하는 방법을 설명하는 지시사항입니다. 이는 클라이언트가 사용 가능한 도구, 리소스 등에 대한 LLM의 이해를 개선하는 데 사용할 수 있습니다. 모델에 대한 일종의 "힌트"라고 볼 수 있습니다. 예를 들어, 이 정보는 시스템 프롬프트에 추가될 수 있습니다.

가장 최신 개정판인 2026-07-28에는 initialize 핸드셰이크가 없다. 버전, 식별 정보, 기능은 각 요청의 메타데이터로 전달된다. 같은 생명주기 경로의 페이지 제목은 그곳에서는 "Versioning and Compatibility"이며, 이전 방식을 Legacy라고 부른다. "세션을 initialize 핸드셰이크로 수립하는 프로토콜 버전(2025-11-25 및 그 이전)." 그 개정판에서 instructions는 새 메서드 server/discover의 결과 필드이며, "서버와 그 기능을 설명하는 자연어 안내. 이는 클라이언트가 사용 가능한 도구에 대한 LLM의 이해를 개선하는 데 사용할 수 있음(예: 시스템 프롬프트에 포함)."으로 설명된다. stdio의 경우, 두 시대를 모두 이해하는 클라이언트는 "다른 어떤 요청도 보내기 전에 server/discover로 반드시 probe해야 한다"고 하며, 서버가 "다른 오류를 반환하거나 합리적인 시간 내에 응답하지 않으면: 서버는 legacy다. initialize 핸드셰이크로 되돌아가라."고 한다.

같은 날 가져온 Claude Code의 MCP 페이지는 이 필드에 대해 세 가지를 말한다. tool search를 사용하면 "세션 시작 시에는 도구 이름과 서버 지시사항만 로드된다." 그리고 "Claude Code는 기본적으로 각 도구 설명과 각 서버의 지시사항을 2,048자로 자른다. 간결하게 유지하고, 중요한 세부사항은 앞부분에 두라." 또한 이 제한은 CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH로 바꿀 수 있는데, 그 참조 항목은 이 값이 "평범한 숫자로 된 양의 정수를 받아들인다. 그 외의 값은 모두 무시되고 기본값이 적용된다."고 설명한다.

같은 페이지에는 두 MCP 클라이언트 런타임에 대한 섹션이 있습니다. v2 런타임은 2026-07-28 리비전을 추가하며, v2에서 Claude Code는 "HTTP 서버에 더 최신 리비전을 지원하는지 물어보고, 지원하는 서버에는 그 리비전을 사용합니다. 또한 feature flag를 가져오는 세션에서는 claude.ai connector 서버에도 묻습니다. stdio 서버에도 묻게 하거나, 매 세션마다 connector 서버에도 묻게 하려면 MCP_PROTOCOL_NEGOTIATION을 auto로 설정하세요. 그 외의 모든 서버에는 v1과 같이 연결합니다." 환경 변수 자체의 항목에도 기본값이 반복됩니다. "변수를 지정하지 않으면 Claude Code는 HTTP 서버를 probe하고, feature flag를 가져오는 세션에서는 claude.ai connector 서버도 probe합니다."

따라서 문서상으로는 사용자가 opt-in하지 않았다면 stdio 서버는 먼저 initialize를 봐야 합니다. 이 글의 나머지는 바로 그 문장에 달려 있습니다.

실험실

서버는 종속성이 없는 Node 스크립트이며, stdio를 통해 줄 단위 JSON-RPC를 주고받습니다. 도구는 세 개(lookup_part, list_bins, lab_ping)가 있어서 tool search가 의존성을 미룰 대상이 생기며, 수신한 모든 메시지를 rpc-log.jsonl에 덧붙입니다. MCP 설정의 환경 변수로 지시문을 선택합니다: 없음(필드는 응답에서 제외됨), 주어진 길이의 영어 텍스트, 또는 주어진 길이의 일본어 텍스트입니다. 두 개의 추가 스위치는 서버가 server/discover에 2026-07-28 서버처럼 응답하게 하거나, initialize 응답을 8초 동안 지연하게 만듭니다.

텍스트는 Claude가 실제로 받은 것만 보고하도록 구성되어 있습니다. 텍스트는 BEGIN codeword: - .로 시작하고 END codeword: - .로 끝나며, 그 사이에는 부품 재고에 관한 몇 문장이 반복되고, 대략 250자마다 [at N] 형식의 표시가 들어갑니다. 여기서 N은 표시 자체의 문자 오프셋입니다. 각 텍스트는 길이와 언어에서 파생된 고유한 코드워드 쌍을 가지며, 두 시대를 함께 쓰는 설정에서는 두 응답이 서로 다른 쌍을 담으므로, 답변을 보면 Claude에게 어떤 텍스트가 주어졌는지 알 수 있습니다.

각 서버 설정은 고유한 구성 파일을 가졌고, --strict-mcp-config와 함께 전달되어 다른 MCP 서버도, 계정의 claude.ai connector도 세션에 합류하지 못하게 했습니다. Claude Code 환경 변수를 바꾸는 설정들은 이 파일들 중 하나를 재사용하고 claude 프로세스에 변수를 설정합니다. 20,000자 텍스트용 파일:

코드
{
  "mcpServers": {
    "lab": {
      "type": "stdio",
      "command": "node",
      "args": ["/path/to/server.js"],
      "env": { "LAB_INSTR": "ascii:20000" }
    }
  }
}

18번의 실행은 이 명령과 이 프롬프트를 사용했으며, 빈 디렉터리에서 시작했습니다(뒤의 두 늦은 서버 실행은 아래 자기 섹션에서 설명하듯 프롬프트와 두 플래그를 변경합니다):

코드
claude -p "$PROMPT" --output-format stream-json --verbose --max-turns 1 --model opus \
  --settings '{"disableAllHooks": true}' --strict-mcp-config --mcp-config cfg/a20k.json \
  --debug-file runs/R05.debug.log
코드
Do not call any tools; answer only from what is already in your context. An MCP server named lab may have given you instructions. Reply with exactly one line in this format: BEGIN=<the codeword after "BEGIN codeword:"> END=<the codeword after "END codeword:"> LAST=<the largest number N in any marker written as [at N]>. Write NONE for any value you cannot find.

우리는 다른 Claude Code 세션 안에서 실행을 시작했기 때문에, 래퍼가 claude를 호출하기 전에 그 세션이 내보내는 환경 변수들(CLAUDECODE와 그 이웃들)을 제거했습니다. --settings 오버라이드는 hooks를 끄므로, 사용자 수준의 알림 hook은 조용히 유지되었습니다.

수치를 위해서는 트랜스크립트를 읽었습니다. 첫 번째 assistant record의 usage가 첫 번째 요청의 크기를 알려줍니다: input_tokens에 cache_read_input_tokens와 cache_creation_input_tokens를 더한 값입니다. 2.1.285에서는 트랜스크립트가 Claude Code가 첫 번째 user turn에 추가하는 각 context block을 렌더링된 텍스트와 함께 attachment record로 저장하며, system prompt와 tool 정의를 담은 prompt_snapshot record도 함께 저장합니다. 이런 기록 덕분에 모델이 말한 내용에 의존하지 않고도 지시문이 어디로 갔는지, 그리고 그 중 몇 글자가 갔는지를 말할 수 있습니다.

우리는 10개의 구성을 각각 두 번씩 실행해, 총 20번 실행했습니다. 각 쌍에서 첫 번째 요청은 토큰과 일치했습니다.

한 표로 보는 결과

구성첫 요청(토큰)지시 사항 없음 대비Claude에 전달된 내용Claude의 답변
지시 사항 필드 없음20,472아무것도 없음세 가지 모두 NONE
1,000자, 영어20,849+3771,000자 전부두 코드워드 모두, LAST=763
5,000자, 영어21,193+721처음 2,048자 + … [truncated]BEGIN만, LAST=2009
20,000자, 영어21,193+721처음 2,048자 + … [truncated]BEGIN만, LAST=2009
20,000자, 변수 limit를 25000으로 설정27,050+6,57820,000자 전부두 코드워드 모두, LAST=19808
20,000자, 변수 limit를 25,000으로 설정21,193+721처음 2,048자 + … [truncated]BEGIN만, LAST=2009
3,000자, 일본어(7,956바이트)22,199+1,727처음 2,048자 + … [truncated]BEGIN만, LAST=2026
1,000자, ENABLE_TOOL_SEARCH=false36,871(모든 도구를 미리 로드)1,000자 전부두 코드워드 모두, LAST=763
각 응답에 1,000자, 서버도 server/discover에 응답20,849+377server/discover 텍스트만discover 코드워드
1,000자, 8초 후 initialize 응답, CLAUDE_CODE_MCP_STARTUP_WAIT_MS=020,590, 이후 21,288 / 21,208요청 1에는 아무것도 없음, 요청 2에는 1,000자 전부두 코드워드 모두, LAST=763

제한 변수는 CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH입니다. 20번의 실행 모두에서 Claude의 답변은 기록에 적힌 대로 제공된 내용과 일치했습니다. 텍스트가 전달된 경우에는 올바른 코드워드를, 전달되지 않은 경우에는 NONE을, 잘려서 전달된 경우에는 잘리기 직전의 마지막 마커를 반환했습니다. 코드워드를 한 번도 지어내지 않았습니다. 20번의 실행 전체 비용은 total_cost_usd 기준 $1.02로 보고되었습니다.

이제 나머지 내용은 이 표를 한 열씩 분해해서 살펴봅니다.

핸드셰이크: server/discover가 20번 중 20번 먼저 왔다

서버 로그가 첫 번째 이야기를 들려줍니다. 모든 실행에서 Claude Code가 서버의 stdin으로 보낸 첫 메시지는 initialize가 아니라 항상 server/discover였고, id는 늘 server-discover-probe-1이었습니다. 두 번째 실행부터는 서버가 각 요청의 _meta도 기록했는데, 그 프로브는 그 19번의 실행 모두에서 다음과 같았습니다(짧게 줄였습니다. _meta에는 클라이언트의 기능도 함께 담깁니다):

코드
{"method": "server/discover", "id": "server-discover-probe-1",
 "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
   "io.modelcontextprotocol/clientInfo": {"name": "claude-code", "version": "2.1.285"}}}}

18번의 실행에서 서버는 이 메서드를 알지 못했고 JSON-RPC 오류 -32601로 응답했습니다. 그러면 Claude Code는 protocolVersion: "2025-11-25"를 포함한 initialize를 보냈고, 이어서 notifications/initialized와 tools/list를 보냈으며, 디버그 로그에는 "protocolEra":"legacy","negotiatedProtocolVersion":"2025-11-25"가 기록되었습니다. 이것이 2026-07-28 명세가 규정하는 폴백입니다. 서버 로그에서 initialize는 프로브로부터 5밀리초에서 1.1초 뒤에 도착했고, 그 18번의 실행에서는 initialize 응답에 포함된 지시 사항이 Claude가 받은 내용이었습니다.

두 개의 dual-era 실행에서는 서버가 프로브에 instructions에 한 쌍의 코드워드를 담은 DiscoverResult로 응답했고, initialize에는 다른 한 쌍으로 응답할 준비가 되어 있었습니다. 하지만 Claude Code는 묻지 않았습니다. 다음 메시지는 _meta에 프로토콜 버전 2026-07-28을 담은 tools/list였고, 디버그 로그에는 "protocolEra":"modern","negotiatedProtocolVersion":"2026-07-28"이 기록되었습니다. Claude는 두 번 모두 server/discover 코드워드로 답했고, 두 transcript 어디에도 initialize 코드워드는 나타나지 않았습니다.

두 실행의 서버 로그: R01에서는 server/discover가 -32601 오류를 받았고 그 뒤 initialize와 tools/list가 이어졌으며, R13에서는 server/discover가 DiscoverResult를 받아 initialize가 전혀 전송되지 않았고 tools/list만 이어졌다
두 실행의 서버 로그: R01에서는 server/discover가 -32601 오류를 받았고 그 뒤 initialize와 tools/list가 이어졌으며, R13에서는 server/discover가 DiscoverResult를 받아 initialize가 전혀 전송되지 않았고 tools/list만 이어졌다

두 동작 모두 2026-07-28 사양을 따릅니다. 맞지 않는 부분은 Claude Code가 언제 탐색을 시도하는지에 대한 자체 설명입니다. MCP_PROTOCOL_NEGOTIATION은 설정되어 있지 않았습니다. 셸 환경에도 없었고 ~/.claude/settings.json의 env 블록에도 없었으며, 해당 머신에는 관리형 설정 파일도 없습니다. 문서대로라면 stdio 서버는 그러면 initialize가 먼저 오는 방식으로, 즉 "v1처럼" 연결되어야 합니다. 그러나 모든 20개의 디버그 로그에는 mcp runtime arm: v2 (source: growthbook)라는 줄이 들어 있으며, 우리는 이를 가져온 기능 플래그가 세션을 v2 런타임으로 올려놓은 것으로 읽었습니다. 문서도 그 부분을 설명합니다. 기능 플래그를 가져오는 세션에서는 Claude Code가 "Claude Code v2.1.232 이상에서는 v2 런타임을 사용한다"고 되어 있습니다. 로그 어디에도 stdio 서버를 왜 탐색했는지는 나오지 않습니다. 플래그일 수도 있고, 문서가 코드보다 뒤처진 것일 수도 있으며, 외부에서는 어느 쪽인지 알 수 없습니다. 관측 가능한 사실은 stdio 서버에 대해 문서상 기본 동작이 20번 중 20번에서 일어나지 않았다는 점입니다.

우리처럼 알 수 없는 메서드에 오류로 응답하는 레거시 서버의 경우, 이 탐색은 Claude가 본 것에 아무 영향도 주지 않았습니다. 주의해야 할 경우는 두 시대를 모두 구현한 서버입니다. server/discover가 한 번 성공하면 initialize 응답은 더 이상 요청되지 않으며, 그 안에만 있는 지시는 모두 사라집니다. 예를 들어 서버가 레거시 응답과 새 응답을 서로 다른 필드에서 만들 때처럼, 이런 일은 조용히 일어날 수 있습니다.

텍스트가 도착하는 위치: 첫 사용자 턴의 리마인더

지시가 있었던 모든 실행에서 텍스트는 정확히 한 곳에 나타났습니다. mcp_instructions_delta 유형의 첨부 파일이었습니다. 렌더링된 텍스트는 다음처럼 시작하는 블록입니다.

코드
<system-reminder>
# MCP Server Instructions

The following MCP servers have provided instructions for how to use their tools and resources:

## lab
BEGIN codeword: WAXWING-4465. The lab server tracks spare parts for a small hardware workshop. ...

우리의 1,000자 지시에 대해서는 렌더링된 블록이 1,167자였으므로, 제목과 래퍼가 167자를 더한 셈입니다. 첨부 파일은 addedNames 아래에 서버 이름을, addedBlocks 아래에 텍스트를 저장하고 있으며, 비어 있는 removedNames가 옆에 있습니다. 아래의 늦은 서버 실행들은 이것이 델타처럼 동작함을 보여줍니다. 서버의 지시가 처음 사용 가능해질 때 추가됩니다. 트랜스크립트에서는 프롬프트 뒤에 이어지는 첨부 파일 사슬 속에 있으며, 지연된 도구 목록과 에이전트 목록 다음, 그리고 스킬 목록 앞에 놓입니다. 서버가 instructions 필드를 보내지 않으면 이런 첨부 파일은 아예 없었고, 빈 제목조차 없었습니다.

BEGIN 코드워드가 갈 수 있었던 다른 위치도 확인했습니다. prompt_snapshot 레코드의 시스템 프롬프트는 20번의 실행 모두에서 총 6,662자였고, 코드워드나 "MCP Server Instructions" 제목을 한 번도 포함하지 않았습니다. 지연된 도구 목록에는 실험실 서버의 세 도구 이름만 들어 있었고 그 지시 내용은 없었으며, 도구 정의에도 역시 없었습니다. 따라서 MCP 사양이 예시로 제시하는 위치, 즉 시스템 프롬프트는 Claude Code가 이 텍스트를 넣는 곳이 아닙니다. 사양은 단지 "MAY"라고만 하므로 위반은 아니지만, 지시를 시스템 수준 텍스트로 생각한다면 중요한 차이입니다. Claude Code에서는 이것이 사용자 턴에 붙는 리마인더입니다.

도구 검색을 끄면 위치가 바뀌지 않았습니다. ENABLE_TOOL_SEARCH=false일 때는 그렇지 않으면 지연되는 17개 도구(내장 14개, 실험실 서버 3개)가 전체 정의로 나갔고, 검색 도구 자체는 빠졌으며, 첫 요청은 36,871 토큰으로 늘었습니다. 지시는 여전히 같은 mcp_instructions_delta 첨부 파일에 있었고, 길이도 같은 1,167자였습니다. 도구 검색이 켜진 세션 시작 시 지시가 로드된다는 문서의 문장은 맞습니다. 하지만 도구 검색이 없을 때도 세션 시작 시 로드됩니다.

얼마나: 2,048자, 그다음 13자 마커

영어 5,000자와 20,000자에서는 ## lab 아래의 텍스트 길이가 2,061자였습니다. 처음 2,048자는 서버의 텍스트와 정확히 일치했고, Claude Code는 그 뒤에 … [truncated]를 덧붙였습니다. 줄임표, 공백, 대괄호 안의 단어까지 합쳐 총 13자입니다. 텍스트가 잘린 8번의 실행에서 Claude는 END=NONE을 보고했고, 잘리기 직전의 마지막 마커는 영어 텍스트에서는 2009, 일본어 텍스트에서는 2026이었습니다.

Claude가 20,000자 서버 텍스트에서 받은 지시 블록의 끝: 2009의 마커, 그다음
Claude가 20,000자 서버 텍스트에서 받은 지시 블록의 끝: 2009의 마커, 그다음

디버그 로그를 제외하면 잘린 사실은 조용했습니다. 모든 실행에서 stderr는 비어 있었고, stream-json 출력에도 그것은 한 번도 언급되지 않았습니다. 디버그 파일에는 실행마다 한 줄씩 기록이 남았는데, 예를 들면 MCP server "lab": Server instructions truncated from 20000 to 2048 chars였습니다.

단위는 문서에 적힌 대로 바이트가 아니라 문자입니다. 일본어 지침은 3,000자였고 UTF-8에서는 7,956바이트였으며, 블록은 처음 2,048자를 유지했는데 이는 5,452바이트였습니다. 도구 검색 글에서는 이 제한을 2KB라고 풀어썼습니다. 측정해 보면 2,048자이며, 일본어에서는 5KB가 넘었습니다.

상한은 문자를 기준으로 세기 때문에, 토큰 비용은 언어에 따라 달라집니다. 잘린 영어 블록은 첫 요청에 721토큰을 추가했고, 잘린 일본어 블록은 1,727토큰을 추가해 같은 2,048자에 대해 2.4배나 많았습니다. 1,000자 및 20,000자 영어 실행에서는 제목과 래퍼가 약 51토큰이었고, 영어 채움 텍스트는 토큰당 약 3.06자였는데, 이를 바탕으로 계산하면 잘린 영어 블록은 3토큰 이내로 예측됩니다. 일본어 블록은 토큰당 약 1.23자였습니다. 상한은 서버가 얼마나 말할 수 있는지를 제한할 뿐, 그 비용을 제한하지는 않습니다.

상한을 올리면 문서대로 동작했습니다. CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH=25000을 사용하자 20,000자가 모두 도착했고, 디버그 로그에는 잘림 행이 없었으며, Claude는 END 코드워드와 마지막 마커인 19808을 보고했고, 첫 요청은 6,578토큰 늘었습니다. 같은 값을 25,000으로 쓰면 아무 일도 일어나지 않았습니다. 쉼표 때문에 잘못된 값이 되었고, 기본값이 적용되어 텍스트는 2,048자에서 잘렸으며, stderr나 디버그 로그 어디에도 값에 대한 경고는 없었습니다. 이것도 문서에 적힌 그대로입니다. 이것은 Claude Code 환경 변수이며 사용자 측에서 설정하는 것이므로, 서버 작성자는 이를 가정할 수 없다는 점을 기억해야 합니다.

언제: 요청을 만들 때 서버가 연결되어 있을 때만

--mcp-config를 사용한 -p 모드에서 Claude Code는 첫 턴 전에 아직 연결 중인 서버를 기다립니다. CLI 레퍼런스에 따르면 이는 "MCP_TIMEOUT 시작 시간 초과인 기본 30초까지"이며, 캐시된 도구 목록이 있는 서버는 대기를 건너뛴다고 덧붙입니다. 우리 서버는 0.2초에서 3.1초 사이에 연결되었고, 항상 첫 요청이 나가기 전에 완료되었기 때문에 18번의 실행에서는 이 문제가 전혀 발생하지 않았습니다. 대화형 세션은 다릅니다. 환경 변수 레퍼런스에는 "MCP 시작은 기본적으로 비차단입니다: 서버는 백그라운드에서 연결되며, 도구는 준비되는 대로 사용 가능해집니다."라고 적혀 있습니다.

늦게 연결되는 서버가 내부적으로 어떤 모습인지 보려고, 서버가 initialize 응답을 8초 동안 지연하도록 하고 CLAUDE_CODE_MCP_STARTUP_WAIT_MS=0을 설정했습니다. 문서에는 이것이 첫 턴 대기를 건너뛰는 방법이라고 나와 있습니다. 프롬프트는 Claude에게 먼저 Bash로 sleep 10을 실행하고 그 뒤에 답하도록 요청했으며, 세션이 두 번째 요청을 만들도록 --max-turns 1 대신 --max-turns 3과 --allowedTools "Bash(sleep:*)"를 적용했습니다.

두 실행 모두에서 init 이벤트는 서버를 pending으로 표시했고, 첫 요청은 Claude Code가 서버에 연결을 시작한 지 1초 이내에 나갔습니다. 그 요청은 20,590토큰이었고, 지침 첨부는 없었습니다. 대신 연기된 도구 목록에 이런 메모가 붙었습니다. "다음 MCP 서버들이 아직 연결 중입니다 — 이들의 도구(보통 mcp__*로 이름 붙음)는 아직 사용할 수 없지만 곧 나타납니다:" 그 뒤에 lab가 이어졌습니다. 서버는 Claude Code가 연결을 시작한 지 8.3초와 8.6초 후에 연결을 마쳤습니다. Bash 결과가 돌아오자, Claude Code는 여기에 두 개의 델타를 첨부했는데, 세 개의 lab 도구 이름과 mcp_instructions_delta 블록이었고, 두 번째 요청에는 둘 다 포함되었습니다. 첫 응답이 thinking 블록으로 시작한 실행에서는 21,288토큰이었고, 다른 실행에서는 21,208토큰이었습니다. 이후 Claude는 두 코드워드와 LAST=763으로 답했습니다.

따라서 "세션 시작 시"란 "서버가 연결된 뒤에 만들어진 첫 요청"을 의미합니다. initialize에 느리게 응답하는 서버는 기다리지 않는 세션의 첫 요청을 놓치며, 그 지침은 다음 요청이 올 때 Claude에 전달됩니다. 같은 논리로, 대기를 끈 상태의 단일 요청 -p 실행에서는 늦게 연결되는 서버의 지침을 Claude가 전혀 보지 못합니다. 우리는 그 경우는 실행하지 않았습니다.

서버 작성자로서 우리가 할 일

이것들은 문서가 아니라, 스무 번의 실행을 바탕으로 한 우리의 해석입니다.

두 응답에 모두 지침을 넣으세요, 서버가 두 시대로 모두 말한다면. Claude Code 2.1.285는 매 실행마다 우리의 stdio 서버에 server/discover를 보냈고, 그 탐색이 성공하면 initialize는 보내지지 않았습니다. 두 응답은 하나의 문자열에서 만들고, 서버 자체 로그를 보고 실제로 어떤 메서드가 도착했는지 확인하세요.

첫 2,048자만 존재한다고 가정하세요. 그 뒤의 내용은 모든 실행에서 모델에게 보이지 않았고, 디버그 로그 바깥에서는 그런 사실을 말해 주지 않았습니다. 텍스트가 더 길다면, Claude가 언제 도구를 검색해야 하는지 알려 주는 부분은 문서의 조언대로 맨 위에 있어야 합니다.

지침이 영어가 아니라면 토큰 기준으로 예산을 잡으세요. 같은 상한선이 일본어에서는 2.4배나 많은 토큰 비용이 들었습니다.

시스템 프롬프트로 취급될 것이라고 기대하지 마세요. 그 텍스트는 사용자의 첫 메시지 옆에 있는 알림으로 도착합니다. 어떤 문장이 고정된 시스템 규칙으로서만 의미가 통한다면, 그 위치에서 Claude가 실제로 따르는지 시험하세요.

빠르게 시작하세요. 첫 번째 요청이 만들어진 뒤에 연결되는 서버는 그 요청에 포함되지 않습니다.

우리가 측정하지 않은 것

모든 것은 한 대의 머신, 한 계정, 한 모델(Opus 5.5), 한 버전(2.1.285)에서 모두 헤드리스로 실행했습니다. 대화형 세션은 열지 않았으므로, 거기서의 늦게 연결되는 서버 동작은 문서와 대기 끔 상태의 -p 실행으로부터 추정한 것입니다.

MCP_PROTOCOL_NEGOTIATION=legacy나 MCP_SDK_GENERATION=v1은 실행하지 않았습니다. 문서에 따르면 둘 중 하나라도 stdio 서버를 initialize에 머무르게 해야 합니다. 우리는 20회 실행에서 멈췄고, 우리가 본 탐색이 이 계정에만 특화된 기능 플래그에서 온 것인지는 말할 수 없습니다.

이중 시대 서버는 136줄짜리 실험용 스크립트였습니다. 어떤 MCP SDK도 테스트하지 않았으므로, 어떤 SDK가 server/discover에 응답하는지 또는 그 instructions에 무엇을 넣는지는 알 수 없습니다. 또한 알 수 없는 첫 메서드에 대해 오류를 반환하는 대신 충돌하거나 멈추는 레거시 서버도 테스트하지 않았습니다.

실행당 연결된 서버는 하나뿐이었습니다. 여러 서버의 지침이 어떤 순서로 정렬되는지, 또는 각 서버가 각각 2,048자를 받는지도 테스트하지 않았습니다. 도구 설명도 측정하지 않았는데, 그 역시 같은 제한의 적용을 받습니다.

+377, +721, +1,727, +6,578 수치는 지침이 없는 실행과의 차이이며, 제목과 래퍼를 포함합니다. ENABLE_TOOL_SEARCH=false 실행에는 대응하는 기준선이 없어서 총합만 보고합니다. attachment와 prompt_snapshot 기록은 문서화된 출력이 아니며 어떤 릴리스에서든 바뀔 수 있습니다.

우리는 Claude가 코드워드를 다시 읽어낼 수 있는지만 확인했습니다. 이 위치로 전달된 지침을 실제로 따르는지는 다른 문제이며, 우리는 그것을 테스트하지 않았습니다.

재현하기

이것은 서버의 더 짧은 버전입니다: 하나의 도구, 고정된 코드워드, 그리고 일본어 또는 느린 모드 없음. 우리는 JSON-RPC 줄을 이 서버에 파이프로 넣어 응답을 확인했으며, 위의 수치는 더 긴 스크립트에서 나온 것입니다.

코드
// lab.js: a stdio MCP server that logs every message and returns long instructions.
// LAB_CHARS sets the length. LAB_DUAL=1 also answers server/discover, with other codewords.
const fs = require('fs');
const log = (m) => fs.appendFileSync(__dirname + '/rpc-log.jsonl', JSON.stringify(m) + '\n');

function instructions(begin, end, n) {
  const tail = ` END codeword: ${end}.`;
  let s = `BEGIN codeword: ${begin}. `;
  let next = 250;
  while (s.length < n - tail.length) {
    if (s.length >= next) { s += `[at ${s.length}] `; next += 250; }
    s += 'Use lookup_part when the user names a part number or asks about stock. ';
  }
  return s.slice(0, n - tail.length) + tail;
}

const n = Number(process.env.LAB_CHARS || 1000);
const fromInit = instructions('HERON-4821', 'OSPREY-3094', n);
const fromDiscover = instructions('TANAGER-6881', 'WAXWING-2170', n);
const dual = process.env.LAB_DUAL === '1';
const tools = [{ name: 'lookup_part', description: 'Look up one part number.',
  inputSchema: { type: 'object', properties: { part: { type: 'string' } } } }];

function handle({ id, method, params }) {
  log({ t: new Date().toISOString(), method, id });
  if (id === undefined) return null;
  const ok = (result) => ({ jsonrpc: '2.0', id, result: dual ? { resultType: 'complete', ...result } : result });
  if (method === 'server/discover' && dual) return ok({
    supportedVersions: ['2026-07-28'], capabilities: { tools: {} },
    _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'lab', version: '0.0.1' } },
    instructions: fromDiscover, ttlMs: 0, cacheScope: 'private' });
  if (method === 'initialize') return ok({
    protocolVersion: params.protocolVersion, capabilities: { tools: {} },
    serverInfo: { name: 'lab', version: '0.0.1' }, instructions: fromInit });
  if (method === 'tools/list') return ok(dual ? { tools, ttlMs: 0, cacheScope: 'private' } : { tools });
  return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
}

let buf = '';
process.stdin.setEncoding('utf8').on('data', (chunk) => {
  buf += chunk;
  let i;
  while ((i = buf.indexOf('\n')) >= 0) {
    const line = buf.slice(0, i).trim();
    buf = buf.slice(i + 1);
    if (!line) continue;
    const reply = handle(JSON.parse(line));
    if (reply) process.stdout.write(JSON.stringify(reply) + '\n');
  }
});
코드
LAB=$(mktemp -d) && cd "$LAB"   # save lab.js here
cfg() { printf '{"mcpServers":{"lab":{"type":"stdio","command":"node","args":["%s/lab.js"],"env":{"LAB_CHARS":"%s","LAB_DUAL":"%s"}}}}' "$LAB" "$1" "$2"; }
cfg 20000 0 > long.json; cfg 1000 1 > dual.json
P='Do not call any tools. Reply with the codeword after "BEGIN codeword:" and the one after "END codeword:" in your context, or NONE for any you cannot see.'
mkdir empty && cd empty
for c in long dual; do
  claude -p "$P" --max-turns 1 --output-format json --strict-mcp-config \
    --mcp-config "../$c.json" --debug-file "../$c.log" | jq -r '.result, .session_id'
done
jq -r .method ../rpc-log.jsonl          # which handshake your version sends
grep -h "instructions truncated" ../long.log

Claude에 도달한 내용을 보려면 ~/.claude/projects/ 아래의 세션 id에 대한 대화 기록을 열고 jq -r 'select(.attachment.type == "mcp_instructions_delta") | .attachment.addedBlocks[]'로 블록을 출력하세요. 방법 로그가 initialize로 시작한다면, 여러분의 세션은 문서에 설명된 방식대로 stdio 서버에 연결되고 있는 것이며, 이중 실행은 대신 initialize 코드워드를 보여 줄 것입니다.

여기 실험실은 하나의 Node 파일과 몇 개의 claude -p 플래그로 이루어져 있어서, 각 Claude Code 업그레이드 뒤에 핸드셰이크 로그를 다시 확인하기가 쉽습니다.

버전이 initialize를 먼저 보낸다면, 아래 댓글에 서버의 메서드 로그를 붙여 주세요.

출처

  • 원문: https://dev.to/rulestack/20-of-20-runs-sent-serverdiscover-to-our-stdio-mcp-server-off-by-default-per-the-docs-and-its-12m2 (2026-10-07 · Rulestack)

자료 변경 기록을 불러오는 중입니다.

다음 자료를 불러오는 중입니다.

후기

아직 후기가 없습니다

직접 적용한 결과와 주의할 점을 공유하는 공간입니다.

로그인하고 후기 쓰기

참고 자료 · 도구와 릴리스 노트

댓글