mcp.json env 블록으로 API 키 전달하기

파일에는 참조를 넣고, 값은 셸에 넣은 다음, 서버가 실제로 무엇을 받았는지 확인한다.

표지: 파일에 들어가는 것은 키가 아니라 참조다. 이미지 제공: 작성자. AI의 도움을 받아 작성했다. 아래의 모든 명령과 출력은 2026년 10월 9일의 깨끗한 테스트 환경에서 실행했다. 모든 키는 가짜였고, 키는 길이와 짧은 sha256 접두사만 표시했으며, Claude Code는 모델 대신 로컬의 스크립트된 대역과 대화했다.
.mcp.json 파일에는 매번 "SHOP_API_KEY": "${SHOP_API_KEY}"라고 적혀 있었다. 서버 프로세스가 받은 것은 한 번은 26자짜리 키였고, 다른 한 번은 리터럴 텍스트 ${SHOP_API_KEY}였으며, 세 번째는 빈 문자열이었다.
터미널에서 잘 동작하던 MCP 도구가 Claude Code 안에서 누락된 키나 401을 보고한다면, 그 차이는 파일과 프로세스 사이의 그 간극에 있다. 이 글에서는 각 판독값을 해석하고 해결 방법을 알려준다.
2026년 10월 9일 기준 테스트 완료: Claude Code 2.1.295, mcp 2.3.0(Python 3.13.5), Debian 13.
판독값을 어떻게 얻었는가
테스트 서버 keycheck에는 프로세스가 SHOP_API_KEY(그리고 다른 두 개의 키 이름)를 받았는지 길이와 sha256 접두사만으로 보고하는 도구가 하나 있다. 서버는 시작 시에도 같은 보고를 기록한다. Claude Code 2.1.295는 프로젝트의 .mcp.json에서 이를 불러왔고, 각 claude -p 턴은 더미 키를 가진 모델 대신 로컬의 스크립트된 대역으로 보내졌기 때문에 Anthropic으로는 아무것도 전달되지 않았다.
대역의 모든 턴에 대한 응답은 스크립트된 “Done.”이었고, 이는 키에 대해 아무것도 말해주지 않는다. 아래의 증거는 서버 프로세스가 기록한 내용과 도구가 반환한 내용이다.
동작하는 패턴
{ "mcpServers": { "keycheck": { "command": ".venv/bin/python", "args": ["server.py"], "env": { "SHOP_API_KEY": "${SHOP_API_KEY}" } } }}Claude Code를 시작한 셸에서 SHOP_API_KEY를 export해 두면, 서버는 set, len=26, sha256:959ca53b라는 가짜 키를 받았다. 파일에는 참조만 들어 있으므로 커밋해도 안전하며, claude mcp get keycheck는 값이 아니라 SHOP_API_KEY=${SHOP_API_KEY}를 보여줬다.
Claude Code 문서에는 지원되는 형식으로 ${VAR}와 ${VAR:-default}가 command, args, env, url, headers에 대해 나열되어 있다 [1].

참조만 담긴 env 블록과 서버가 26자짜리 가짜 키를 받는 모습이 지문으로 표시되어 있다. 이 경우 OPENAI_API_KEY는 export되지 않았다(Claude Code 2.1.295, Debian 13). 이미지 제공: 작성자.
서버가 받은 것을 해독하기
판독 1: UNEXPANDED 리터럴 '${SHOP_API_KEY}'
원인: 변수가 Claude Code를 시작한 셸에 설정되어 있지 않았다. 설정 파일은 여전히 로드되었고, 서버는 키로 ${SHOP_API_KEY}라는 15글자를 그대로 받았다. 이는 문서와 일치한다: 설정은 "여전히 로드되고", Claude Code는 "펼쳐지지 않은 ${VAR} 텍스트를 있는 그대로 사용한다" [1].
유일한 징후: claude mcp list가 서버 아래에 경고를 출력했다:
└ [Warning] [keycheck] mcpServers.keycheck: Missing environment variables: SHOP_API_KEY왜 문제가 되는가: 키가 설정되었는지만 확인하는 서버는 비어 있지 않은 문자열을 보고 나중에, 서드파티 API에서 실패한다. 여기서는 실제 API를 호출하지 않았으므로 제공자 측의 정확한 오류는 테스트하지 않았다.
해결: Claude Code를 시작하기 전에 변수를 export하라. 서버 측에서는 ${를 포함한 키 값은 거부하는 것을 고려하라.

설정되지 않은 변수가 claude mcp list에서 경고만 만들고, 서버는 리터럴 텍스트 ${SHOP_API_KEY}를 받는 모습(Claude Code 2.1.295, Debian 13). 이미지 제공: 작성자.
판독 2: 빈 문자열
원인: 참조에 빈 기본값 "${SHOP_API_KEY:-}"이 있었고, 변수는 설정되어 있지 않았다. 이번에는 claude mcp list에 경고가 없었고, claude mcp get도 여전히 SHOP_API_KEY=${SHOP_API_KEY}를 보여줬으며 :-는 표시되지 않았다.
해결: 빈 값이 진짜로 괜찮을 때만 :-를 사용하고, 서버가 그런 값을 받았을 때는 "키 없음"이라고 분명하게 말하게 하라.
판독 3: MISSING
원인: 클라이언트가 변수를 아예 전달하지 않았다. 이는 테스트 클라이언트로 사용한 mcp 2.3.0 Python stdio 클라이언트에서 나온 결과로, 셸에는 두 키가 모두 export되어 있었다. env=None일 때 세 키 모두 MISSING이었다. 이 클라이언트는 기본적으로 HOME, LOGNAME, PATH, SHELL, TERM, USER만 전달한다(DEFAULT_INHERITED_ENV_VARS) [2].
p = StdioServerParameters(command=".venv/bin/python", args=["server.py"], env={"SHOP_API_KEY": os.environ["SHOP_API_KEY"]})해결: env=를 명시적으로 전달하라. 그렇게 하면 SHOP_API_KEY만 도착하며, 보통은 그것이면 충분하다.
그래서 같은 서버라도 Claude Code 아래에서는 키가 보이고 Python 테스트에서는 보이지 않을 수 있습니다. 테스트 쪽은 “Test MCP Servers with pytest, Including Launching from Another Folder”에서 다룹니다.

Python SDK의 stdio 클라이언트는 env=가 지정되기 전까지 기본 변수만 전달합니다(mcp 2.3.0, Debian 13). 이미지: 저자 제공.
읽기 4: env 블록이 없어도 설정됨
원인: Claude Code는 stdio 서버에 자신의 환경 전체를 전달합니다. env 블록이 전혀 없고 키들이 export된 상태여도, 서버는 여전히 SHOP_API_KEY와 OPENAI_API_KEY를 받았습니다. 모든 stdio 사례에서, 요청하지 않았는데도 Claude Code 자신의 ANTHROPIC_API_KEY(여기서는 더미 값)도 받았습니다.
따라서 env 블록의 역할은 보기보다 좁습니다:
- 키 자체를 커밋된 파일 밖으로 빼두기
- 서버를 위해 변수를 이름 바꾸거나 기본값을 지정하기
- Python 클라이언트처럼 상속하지 않는 클라이언트에 대해 명시적으로 지정하기
그리고 stdio 서버는 전체 환경을 볼 수 있으므로, 신뢰하는 서버만 실행하세요. 테스트 서버에서 확인한 것은 세 가지 키 이름뿐이었고, Claude Code가 다른 변수를 제거하는지는 테스트하지 않았습니다.

env 블록이 없어도 stdio 서버가 내보낸 모든 키와 Claude Code 자신의 더미 ANTHROPIC_API_KEY까지 계속 받는 모습이 지문으로 표시되어 있습니다(Claude Code 2.1.295, Debian 13). 이미지: 저자 제공.
읽기 5: 파일에 붙여 넣은 키로 설정됨
원인: 키 자체가 .mcp.json 안에 있었습니다. 작동했고, claude mcp get은 키를 평문으로 출력했습니다. 저장소를 읽을 수 있는 사람은 누구나 그것을 읽을 수 있습니다. 그 파일이 이미 커밋되었다면, "A Secret Committed to Git: Rotate First, Then filter-repo and a PreToolUse Hook"에서 정리 방법을 다룹니다.
claude mcp add -e 인용부호 실수
같은 파일은 claude mcp add로도 작성할 수 있으며, 셸의 인용 규칙이 그 안에 무엇이 들어갈지를 결정합니다. SHOP_API_KEY가 export된 상태에서:
claude mcp add keycheck --scope project -e SHOP_API_KEY="$SHOP_API_KEY" -- python server.py:.mcp.json에 기록됨(git status:?? .mcp.json); 기록된 값은 키 자체, len=26claude mcp add keycheck --scope project -e 'SHOP_API_KEY=${SHOP_API_KEY}' -- python server.py:.mcp.json에 기록됨; 기록된 값은${SHOP_API_KEY}라는 참조claude mcp add keycheck --scope local -e SHOP_API_KEY="$SHOP_API_KEY" -- python server.py: 저장소 밖의~/.claude.json에 기록됨; 기록된 값은 키 자체
큰따옴표를 쓰면 Claude Code가 보기 전에 셸이 변수를 확장하므로, 키가 공유 파일에 들어갑니다. 작은따옴표는 참조를 그대로 유지합니다.
처음 실패한 이유: 처음 버전들은 -e를 서버 이름 바로 앞에 두었습니다(... -e SHOP_API_KEY=... keycheck -- ...). 세 경우 모두 Invalid environment variable format: keycheck로 거부되었는데, CLI가 이름을 또 다른 KEY=value 쌍으로 읽었기 때문입니다. 문서에도 이에 대한 경고가 있습니다 [1]. 이름을 먼저 두자 정상 동작했습니다.

큰따옴표는 키를 .mcp.json 또는 ~/.claude.json에 쓰고, 작은따옴표는 참조를 씁니다. 키는 지문으로만 표시되어 있습니다(Claude Code 2.1.295, Debian 13). 이미지: 저자 제공.
원격 서버: 헤더
HTTP 서버의 경우 참조는 headers에 들어갑니다 [1]:
"headers": { "Authorization": "Bearer ${SHOP_API_KEY}" }로컬 테스트 서버 앞의 작은 프록시가 도착한 내용을 기록했습니다:
- 변수를 export한 경우: 모든 요청(
tools/list,tools/call및 그 밖의 요청)마다Bearer뒤에 26자 키가 붙어 전송됨. - 변수를 설정하지 않은 경우: stdio와 마찬가지로
Bearer ${SHOP_API_KEY}라는 문자 그대로의 텍스트가 전송됨. Bearer ${ANTHROPIC_API_KEY}, 더미 키를 설정한 경우:Bearer뒤에는 아무것도 오지 않음.${ANTHROPIC_API_KEY:-fallback-not-used}헤더는 빈 문자열로 도착했으므로, fallback도 무시되었습니다.
마지막 동작은 의도된 것입니다. 원격 서버의 url과 headers에서 Claude Code는 자신의 자격 증명 변수와 다른 자격 증명 변수를, 변수를 설정했는지 여부와 상관없이 빈 값으로 읽으며, :-default fallback도 무시합니다 [1]. 이를 통해 프로젝트 파일이 Claude Code 자격 증명을 이름만 있는 서버로 보내는 것을 막습니다. 반면 stdio env 블록에서는 같은 ${ANTHROPIC_API_KEY} 참조가 실제로 확장되었습니다.
“stdio vs Streamable HTTP MCP Servers: Which One and Why”에서 HTTP 전송 자체를 다룹니다.

Bearer 뒤에 아무것도 보내지 않고, 대체값도 무시한 채 원격 서버에 빈 X-Shop-Key를 보내는 Claude Code (Claude Code 2.1.295, Debian 13). 작성자 제공 이미지.
제한 사항
- 대체 모델과 가짜 키는 실제 제공자가 문자 그대로의
${VAR}를 어떻게 처리하는지가 아니라, 서버 프로세스가 무엇을 받았는지를 보여 줍니다. - 테스트한 호스트는 Linux의 Claude Code 2.1.295 CLI 하나뿐입니다. Claude Desktop, VS Code, Cursor, Windows, macOS는 테스트하지 않았고, 키체인 기반 대안도 테스트하지 않았습니다.
claude -p는 문서 [1]에 설명된 대로 승인 프롬프트 없이 프로젝트 서버를 불러왔지만,claude mcp list에는⏸ 승인 대기 중으로 표시되었습니다. 대화형 승인 대화상자와 작업공간 신뢰는 테스트하지 않았습니다.headersHelper는 테스트하지 않았습니다.
핵심 정리
.mcp.json에는${VAR}를 넣고, 값은 셸에 두세요. 파일은 안전하게 커밋할 수 있습니다.- 설정되지 않은 변수는 실패하지 않습니다. 서버는 문자 그대로의
${VAR}를 받고, 유일한 징후는claude mcp list의 경고뿐입니다. claude mcp add -e에서 큰따옴표를 쓰면 키가 파일에 기록되고, 작은따옴표를 쓰면 참조가 기록됩니다.- Claude Code는 자체 API 키를 포함해 전체 환경을 stdio 서버에 전달하지만, Python SDK 클라이언트는
env=를 설정하지 않으면 거의 아무것도 전달하지 않습니다. - 원격 헤더에서는 Claude Code의 자체 자격 증명 이름이 의도적으로 비어 있는 상태로 전달됩니다.
여러분 팀은 .mcp.json이 참조하는 값들을 어디에 보관하나요?
재검증 상자. 이것을 그대로 믿기 전에 다음을 실행하세요:
artifacts/tools/node_modules/.bin/claude --version # compare with 2.1.295; if newer, re-run ./run_cases.sh and ./run_http.shpip index versions mcp # compare with 2.3.0; if newer, re-run ./run_py_client.sh세 가지를 확인하세요: 설정되지 않은 변수가 여전히 문자 그대로의 텍스트로 도착하는지, stdio 서버가 여전히 ANTHROPIC_API_KEY를 상속하는지, 원격 헤더가 여전히 이를 비워 두는지입니다. 그런 다음 [1]의 "환경 변수 확장"과 "비어 있는 것으로 읽히는 자격 증명 변수" 섹션을 다시 읽어 보세요.
마지막 확인: 2026년 10월 9일, Claude Code 2.1.295, mcp 2.3.0. 새 버전에서 이것이 바뀌면 이야기를 업데이트하겠습니다.
참고 자료
- Claude Code 문서, “MCP를 통해 Claude Code를 도구에 연결하기”(환경 변수 확장, 설정되지 않은 변수, 비어 있는 것으로 읽히는 자격 증명 변수,
--env위치, 프로젝트 범위 승인): https://code.claude.com/docs/en/mcp - MCP Python SDK,
mcp/client/stdio(DEFAULT_INHERITED_ENV_VARS), mcp 2.3.0: https://github.com/modelcontextprotocol/python-sdk
이 계정의 관련 글: “MCPServer로 Python에서 첫 MCP 서버 만들기(mcp 2.3.0)”.
출처
- 원문: https://nandanpriyadarshi.medium.com/pass-api-keys-to-an-mcp-server-with-the-env-block-in-mcp-json-183637cb2cd7?source=rss------claude_code-5 (2026-10-20 · Nandan Priyadarshi)