MCP 서버 직접 만들기 - 내 k3s 클러스터를 AI에게 읽기 전용으로 열어준 후기 (파이썬, 의존성 0개)
MCP(Model Context Protocol) 이야기는 여기저기서 많이 봤는데, 정작 "그래서 내 서버에 붙이면 뭐가 되는 건데?"는 감이 잘 안 왔다. 그래서 직접 운영 중인 k3s 클러스터에 MCP 서버를 하나 직접 붙여봤다. 결론부터 말하면 생각보다 코드는 짧고, 오히려 어려운 건 '어디까지 열어줄 것인가'였다.
이 글은 개념 설명보다 실제로 만든 것, 돌려본 결과, 그리고 하면서 걸렸던 부분 위주로 적었다. 예전에 쓴 MCP 입문 글을 읽고 오면 더 편하다.
MCP 서버가 실제로 하는 일
MCP 서버는 AI 앱(클라이언트)에게 "나는 이런 도구들을 갖고 있어"라고 목록을 보여주고, 요청이 오면 그 도구를 실행해서 결과를 돌려주는 프로그램이다. 통신 방식은 JSON-RPC 2.0이고, 로컬에서 쓸 때는 보통 stdio(표준 입출력)로 한 줄에 JSON 하나씩 주고받는다.
흐름은 딱 네 단계다.
initialize- 서로 프로토콜 버전과 기능을 확인한다notifications/initialized- 클라이언트가 준비됐다고 알린다tools/list- 서버가 쓸 수 있는 도구 목록을 알려준다tools/call- 도구를 실제로 실행한다
공식 SDK를 쓰면 데코레이터 몇 줄로 끝나지만, 나는 일부러 표준 라이브러리만으로 만들어봤다. 프로토콜이 어떻게 생겼는지 한 번은 눈으로 봐야 나중에 SDK가 이상하게 동작해도 원인을 찾을 수 있을 것 같아서다.
만든 것: k3s 읽기 전용 MCP 서버
도구는 딱 두 개만 넣었다.
list_pods- 파드 목록 조회pod_logs- 파드 로그 마지막 N줄 조회
삭제, 재시작, 배포 같은 쓰기 동작은 일부러 뺐다. 이유는 아래 '권한' 항목에서 다시 얘기한다. 전체 코드는 이렇다.
#!/usr/bin/env python3
"""읽기 전용 k3s MCP 서버 (stdio, 표준 라이브러리만 사용)."""
import json, subprocess, sys
ALLOWED_NS = {"portal"}
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return (r.stdout or r.stderr).strip()
def list_pods(namespace="portal"):
if namespace not in ALLOWED_NS:
return f"허용되지 않은 네임스페이스: {namespace}"
return run(["kubectl", "-n", namespace, "get", "pods", "--no-headers"])
def pod_logs(namespace="portal", pod="", lines=30):
if namespace not in ALLOWED_NS:
return f"허용되지 않은 네임스페이스: {namespace}"
lines = max(1, min(int(lines), 200))
return run(["kubectl", "-n", namespace, "logs", pod, f"--tail={lines}"])
TOOLS = {
"list_pods": (list_pods, "네임스페이스의 파드 목록을 읽기 전용으로 조회",
{"namespace": {"type": "string"}}),
"pod_logs": (pod_logs, "파드 로그 마지막 N줄 조회(최대 200줄)",
{"namespace": {"type": "string"}, "pod": {"type": "string"}, "lines": {"type": "integer"}}),
}
def reply(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
msg["error" if error else "result"] = error or result
sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n"); sys.stdout.flush()
for line in sys.stdin:
req = json.loads(line)
m, id_ = req.get("method"), req.get("id")
if m == "initialize":
reply(id_, {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
"serverInfo": {"name": "k3s-readonly", "version": "0.1.0"}})
elif m == "tools/list":
reply(id_, {"tools": [{"name": n, "description": d,
"inputSchema": {"type": "object", "properties": p}} for n, (_, d, p) in TOOLS.items()]})
elif m == "tools/call":
name, args = req["params"]["name"], req["params"].get("arguments", {})
if name not in TOOLS:
reply(id_, error={"code": -32602, "message": "unknown tool"}); continue
try:
text = TOOLS[name][0](**args)
except Exception as e:
text = f"error: {e}"
reply(id_, {"content": [{"type": "text", "text": text}]})
elif id_ is not None:
reply(id_, error={"code": -32601, "message": "method not found"})
직접 돌려본 결과
AI 클라이언트에 붙이기 전에, 먼저 터미널에서 JSON을 직접 흘려서 서버만 테스트했다. 이게 디버깅하기 훨씬 편하다.
$ printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_pods","arguments":{"namespace":"portal"}}}' \
| python3 k3s_mcp.py
실제로 돌려보니 initialize에는 서버 이름과 버전이, tools/list에는 도구 두 개가, tools/call에는 내 portal 네임스페이스의 파드 6개(mailserver, mini-portal, postgres, searxng 등)가 상태와 함께 돌아왔다. 별도 라이브러리 설치 없이 파이썬 파일 하나로 끝났다.
다른 네임스페이스를 요청하면 이렇게 거절한다.
{"text": "허용되지 않은 네임스페이스: kube-system"}
AI가 알아서 kube-system을 뒤져보려 해도 서버 단에서 막힌다는 뜻이다. 이게 이 글에서 제일 중요한 부분이다.
진짜 고민은 '권한'이었다
도구를 만드는 건 30분이면 되는데, 고민은 그 뒤에 시작됐다. AI가 호출하는 도구는 결국 내 계정 권한으로 실행된다. 프롬프트 인젝션으로 AI가 이상한 지시를 받으면, 열려 있는 도구만큼 사고가 날 수 있다. 그래서 세 가지를 지켰다.
- 읽기 전용만 연다. 조회 도구만 만들고,
delete,apply,exec는 아예 만들지 않았다. 필요해지면 그때 따로, 확인 단계를 넣어서 추가할 생각이다. - 허용 목록(allowlist)으로 범위를 좁힌다. 위 코드의
ALLOWED_NS처럼 접근 가능한 네임스페이스를 코드에 박아뒀다. "막을 것을 나열"하면 항상 빠지는 게 생긴다. "허용할 것만 나열"이 안전하다. - 입력값을 제한한다. 로그 줄 수를 1~200으로 자르고, 명령은 문자열 조합 대신 리스트로 넘긴다(
shell=True를 쓰지 않는다). 파드 이름에; rm -rf같은 걸 넣어도 셸이 해석하지 못한다.
더 확실하게 하려면 서버 코드 바깥에서도 막아야 한다. kubeconfig 대신 읽기 전용 ServiceAccount(get, list, watch만 허용하는 Role)를 만들어서 그 토큰으로 접속하게 하면, 서버 코드에 버그가 있어도 쿠버네티스가 쓰기를 거부해준다. 코드 검증과 권한 분리를 둘 다 하는 게 맞다.
자주 막히는 부분
- stdout에 로그를 찍으면 안 된다. stdio 방식에서는 표준 출력이 곧 프로토콜 채널이다. 디버깅용
print가 섞이면 클라이언트가 JSON 파싱에 실패한다. 로그는 반드시 stderr로 보내야 한다. - 알림(notification)에는 응답하지 않는다.
notifications/initialized처럼id가 없는 메시지에 응답을 보내면 안 된다. 위 코드가id_ is not None일 때만 오류 응답을 보내는 이유다. - 도구 설명(description)이 곧 프롬프트다. AI는 이 문장을 보고 도구를 쓸지 말지 판단한다. 모호하게 쓰면 엉뚱한 때 호출하거나 아예 안 쓴다.
- 프로토콜 버전.
protocolVersion은 클라이언트가 보낸 값과 협상한다. 위 예제는 2025-06-18 기준으로 테스트했고, 쓰는 클라이언트에 따라 다른 버전을 요구할 수 있다.
Claude Desktop, Cursor 같은 클라이언트에 연결하기
대부분의 MCP 클라이언트는 설정 파일에 서버 실행 명령만 적어주면 된다. 형식은 클라이언트마다 조금씩 다르지만 큰 틀은 이런 모양이다.
{
"mcpServers": {
"k3s-readonly": {
"command": "python3",
"args": ["/path/to/k3s_mcp.py"]
}
}
}
이 설정 예시는 대표적인 형식을 적은 것이고, 내가 위 서버를 모든 클라이언트에 붙여본 건 아니다. 경로와 키 이름은 쓰는 클라이언트 문서를 한 번 확인하자.
그래서 쓸모가 있나
솔직하게 말하면, 파드 목록 정도는 kubectl을 직접 치는 게 더 빠르다. 이 서버가 빛나는 순간은 "왜 portal 파드가 재시작됐지?"처럼 상태 조회 → 로그 확인 → 원인 추론이 이어지는 질문을 AI에게 던질 때다. 도구가 있으면 AI가 스스로 파드를 보고, 로그를 열어보고, 결론을 말해준다. 사람이 복사해서 붙여넣는 과정이 사라진다.
반대로 쓰기 도구를 열어주는 건 아직 조심스럽다. 나는 당분간 읽기 전용으로 쓰고, 배포나 재시작은 지금처럼 Git에 푸시해서 CI/CD로 하는 방식을 유지할 생각이다. 변경 이력이 남고 되돌릴 수 있으니까.
자주 묻는 질문
Q. MCP 서버를 만들려면 꼭 공식 SDK를 써야 하나요?
아니다. 이 글처럼 JSON-RPC만 맞추면 직접 만들 수 있다. 다만 실무에서는 스키마 검증, 전송 방식(SSE/HTTP) 지원, 인증 같은 걸 직접 구현하기 번거로우니 SDK를 쓰는 편이 낫다.
Q. MCP 서버는 어떤 언어로 만들 수 있나요?
JSON을 읽고 쓸 수 있으면 어떤 언어든 가능하다. 파이썬, TypeScript가 SDK와 예제가 가장 많다.
Q. MCP 서버가 보안상 위험하지 않나요?
열어준 만큼 위험하다. 읽기 전용, 허용 목록, 입력값 제한, 최소 권한 계정 이 네 가지는 기본으로 지키는 걸 추천한다.
Q. 쿠버네티스용으로 이미 나온 MCP 서버도 있나요?
커뮤니티에서 만든 쿠버네티스 MCP 서버들이 있다. 다만 기본 상태로 쓰기 권한까지 열려 있는 경우가 있으니 도입 전에 어떤 도구를 노출하는지 꼭 확인하자.
마무리
MCP는 거창한 기술이라기보다 "AI에게 줄 도구 목록을 표준 방식으로 노출하는 약속"에 가깝다. 코드는 100줄도 안 됐고, 시간이 오래 걸린 건 권한 설계였다. 처음 만든다면 조회 도구 하나로 시작해서, 범위를 좁게 잡고, 필요할 때만 조금씩 넓히는 걸 추천한다.
다음에는 이 서버에 Gitea 조회 도구를 붙이고, 읽기 전용 ServiceAccount로 교체하는 과정을 정리해볼 생각이다.
댓글 0
글에 대한 질문이나 오류 제보는 문의하기로 보내 주세요.