콘텐츠로 이동
Study Notekagent · kmcp

MCP 서버 로컬 개발 흐름

결론부터
  • MCP 서버는 클러스터 없이 개발한다. 서버는 일반 Python 프로세스이고, MCP Inspector나 작은 client로 직접 호출해 볼 수 있다.
  • kmcp run은 stdio로 띄운다. 우리 서버처럼 header를 읽어야 하면 --transport http로 직접 실행해서 시험한다.
  • 임직원 토큰 처리는 로컬에서 재현된다. client가 Authorization header를 실어 보내면 서버 코드가 받는 것은 클러스터와 같다.
  • 로컬에서 확인할 수 없는 것은 kagent가 실제로 header를 넘겨 주는지와 클러스터의 네트워크·Secret이다.
  • image로 실행할 때는 --host 0.0.0.0이 필요하다. 생성된 서버의 기본값은 stdio와 localhost다.
이 장에서 처음 나오는 말4개
scaffold
도구가 만들어 주는 project의 뼈대다. 디렉터리 구조와 예제 코드, Dockerfile이 포함된다.
MCP Inspector
MCP 서버에 접속해 tool 목록을 보고 인자를 넣어 실행해 보는 공식 시험 도구다. 브라우저 화면으로 뜬다.
uv
Python package와 가상 환경을 관리하는 도구다. kmcp의 Python project가 의존성 설치와 실행에 쓴다.
bind
서버가 어느 네트워크 주소에서 연결을 받을지 정하는 것이다. localhost에 bind하면 같은 기기 안에서만 접속된다.

kmcp 페이지에서 MCPServer 리소스와 두 전송 방식을 봤다. 이 페이지는 그 앞 단계, 즉 서버를 소스로 만들 때 개발자의 PC에서 무엇을 하는가를 다룬다. 예로 삼는 서버는 우리 backend API를 tool로 감싸는 portal-api-mcp이고, 임직원 토큰을 header로 받아야 한다.

  1. kmcp init python으로 project를 만든다.
  2. src/tools/에 tool을 추가하고 backend 호출 코드를 쓴다.
  3. 서버를 로컬에서 http로 띄우고 Inspector나 client로 호출한다. header를 실어 보내 본다.
  4. test를 돌린다.
  5. image를 만들고 container로 한 번 더 확인한다.
  6. MCPServer manifest를 만들어 GitOps PR로 넘긴다.

1~5는 클러스터가 필요 없다. 준비물은 Python, uv, Docker, 그리고 MCP Inspector를 실행할 Node.js다.

터미널 창
kmcp init python portal-api-mcp
  • 디렉터리portal-api-mcp/
    • 디렉터리src/
      • main.py 진입점. --transport·--host·--port를 받는다
      • 디렉터리core/
        • server.py src/tools/의 파일을 자동으로 찾아 등록한다
        • utils.py
      • 디렉터리tools/
        • echo.py 예제 tool. tool 하나가 파일 하나다
    • 디렉터리tests/ 생성된 기본 test
      • …
    • kmcp.yaml project 이름·framework·tool 설정·secret 설정
    • Dockerfile
    • pyproject.toml FastMCP 등 의존성
    • .env.example

알아 둘 동작이 둘 있다(template 소스에서 확인).

  • 서버는 시작할 때 src/tools/의 모든 .py 파일을 import한다. tool을 하나도 등록하지 않는 파일이 있으면 서버가 시작하지 않고 종료한다. helper 코드는 src/tools/가 아닌 곳에 둔다.
  • main.py의 기본 전송은 stdio이고 기본 host는 localhost, 기본 port는 3000이다. 환경 변수 MCP_TRANSPORT_MODE·HOST·PORT로도 바꿀 수 있다.
터미널 창
kmcp add-tool get_my_leave_requests --project-dir portal-api-mcp

src/tools/get_my_leave_requests.py가 예제 내용으로 생긴다. 이 파일을 우리 코드로 바꾼다.

# 설명용 예제 — src/tools/get_my_leave_requests.py
import os
import httpx
from fastmcp.server.dependencies import get_http_request
from core.server import mcp
BACKEND_URL = os.environ["BACKEND_URL"]
@mcp.tool()
async def get_my_leave_requests() -> list[dict]:
"""로그인한 임직원 본인의 휴가 신청 내역을 조회한다."""
auth = get_http_request().headers.get("authorization")
if not auth:
raise RuntimeError("임직원 토큰이 전달되지 않았다")
async with httpx.AsyncClient(base_url=BACKEND_URL, timeout=10) as client:
resp = await client.get("/api/leave-requests/me", headers={"Authorization": auth})
resp.raise_for_status()
return resp.json()
  • from core.server import mcp로 가져온 객체에 @mcp.tool()을 붙이면 등록된다.
  • 함수의 docstring과 인자 타입이 tool 설명과 입력 schema가 된다. 모델이 이 설명을 보고 tool을 고르므로 “누구의 무엇을 조회하는가”를 분명히 쓴다.
  • httpx처럼 추가한 의존성은 uv add httpx로 pyproject.toml에 넣는다.
  • tool을 이렇게 짜는 이유(신원은 header에서만, 토큰이 없으면 실패)는 임직원 토큰 전파에 있다.
kmcp runhttp로 직접 실행
명령kmcp run --project-dir portal-api-mcpuv run python src/main.py --transport http --port 8080
전송stdioStreamable HTTP
Inspector자동으로 열린다따로 실행해서 URL로 접속한다
HTTP header없다있다
맞는 경우header가 필요 없는 tool의 빠른 확인우리 서버처럼 header를 읽는 tool

kmcp run은 Python project에서 항상 uv run python src/main.py를 stdio로 실행한다. v0.4.0 소스에서는 --transport http 옵션이 Python project에 적용되지 않는다. 그래서 header를 읽는 tool은 kmcp run으로 시험할 수 없다 — stdio에는 HTTP 요청이 없어서 get_http_request()가 실패한다.

우리 서버는 직접 실행한다.

터미널 창
cd portal-api-mcp
uv sync
BACKEND_URL=http://localhost:9000 uv run python src/main.py --transport http --port 8080

서버는 http://127.0.0.1:8080/mcp에서 응답한다. BACKEND_URL은 로컬에서 띄운 backend나 개발 환경의 backend 주소다. 서버가 시작할 때 .env 파일을 읽으므로 거기에 적어도 된다. .env는 commit하지 않는다.

터미널 창
npx @modelcontextprotocol/inspector

화면에서 Transport Type을 Streamable HTTP, URL을 http://127.0.0.1:8080/mcp로 두고 접속한다. 인증(또는 custom header) 설정에 header 이름 Authorization과 Bearer 토큰을 넣으면 Inspector가 요청마다 그 header를 붙인다. 항목 이름은 Inspector 버전에 따라 다르다. Tools 탭에서 tool을 골라 실행한다.

토큰은 개발 환경 Keycloak에서 발급받은 시험 계정의 것을 쓴다. 운영 토큰을 개발 도구에 붙여 넣지 않는다.

같은 확인을 반복하려면 script가 편하다.

# 설명용 예제 — scripts/call_tool.py
import asyncio
import os
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
async def main() -> None:
transport = StreamableHttpTransport(
"http://127.0.0.1:8080/mcp",
headers={"Authorization": f"Bearer {os.environ['EMPLOYEE_TOKEN']}"},
)
async with Client(transport) as client:
tools = await client.list_tools()
print([t.name for t in tools])
result = await client.call_tool("get_my_leave_requests", {})
print(result)
asyncio.run(main())
보내는 것기대 결과확인되는 것
유효한 토큰그 계정의 데이터header를 읽어 backend로 넘기는 코드
header 없음tool이 오류로 실패고정 자격으로 대신 호출하지 않는다
다른 계정의 토큰다른 데이터신원이 header에서만 온다
만료된 토큰backend 401이 tool 오류로 전달오류가 “다시 로그인”으로 읽히는가

이 네 가지가 로컬에서 통과하면, 클러스터에서 남는 질문은 “kagent가 이 header를 실제로 넘겨 주는가” 하나로 줄어든다.

project에는 서버가 뜨고 tool이 등록되는지를 보는 기본 test가 들어 있다.

터미널 창
uv run pytest

우리 tool의 test는 backend를 가짜로 바꿔서 쓴다. tool 함수가 backend에 어떤 header로 어떤 경로를 호출하는지가 검증 대상이다. header가 없을 때 실패하는 경우도 test로 고정해 둔다 — 나중에 누군가 “토큰이 없으면 서비스 계정으로 호출”하는 fallback을 넣는 것을 막는다.

image로 만들어 한 번 더 확인한다

섹션 제목: “image로 만들어 한 번 더 확인한다”
터미널 창
kmcp build --project-dir portal-api-mcp -t portal-api-mcp:dev
docker run --rm -p 8080:8080 -e BACKEND_URL=http://host.docker.internal:9000 \
portal-api-mcp:dev python src/main.py --transport http --host 0.0.0.0 --port 8080

host.docker.internal은 Docker Desktop에서 host의 backend를 가리키는 이름이다. Linux의 Docker에서는 --add-host=host.docker.internal:host-gateway를 함께 준다.

container가 뜬 상태에서 앞의 client script를 다시 실행해 같은 결과가 나오는지 본다. 여기까지가 클러스터 없이 확인할 수 있는 범위다.

터미널 창
kmcp deploy --file portal-api-mcp/kmcp.yaml \
--image registry.example.com/mcp/portal-api-mcp@sha256:... \
--transport http --port 8080 --namespace agents \
--dry-run --output mcpserver.yaml

--transport http를 주면 kmcp가 args와 httpTransport.targetPort를 채운다(소스에서 확인). 결과는 이런 모양이다.

# 생성 결과의 형태 — 발췌
apiVersion: kagent.dev/v1alpha1
kind: MCPServer
metadata:
name: portal-api-mcp
namespace: agents
spec:
transportType: http
httpTransport:
targetPort: 8080
deployment:
image: registry.example.com/mcp/portal-api-mcp@sha256:...
cmd: python
args: ["src/main.py", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
port: 8080

BACKEND_URL 같은 설정은 deployment.env에, 비밀 값은 deployment.secretRefs로 Secret 이름만 적는다. 이 파일을 직접 apply하지 않고 GitOps PR로 넘긴다.

로컬에서 확인되는 것과 안 되는 것

섹션 제목: “로컬에서 확인되는 것과 안 되는 것”
확인 대상로컬클러스터
tool의 로직과 backend 호출된다—
header를 읽어 넘기는 코드된다—
http 전송, /mcp 경로, bind 주소container 실행으로 된다—
kagent가 Authorization을 실제로 넘기는가안 된다allowedHeaders와 설치 버전에 달렸다
Agent가 tool을 올바로 고르고 인자를 채우는가안 된다Agent와 모델이 있어야 한다
Service 이름·NetworkPolicy·Secret 주입안 된다클러스터 설정이다

Declarative Agent는 로컬에서 실행할 방법이 없다. Agent와 함께 시험하려면 개발용 클러스터가 필요하고, kind로 띄우는 방법은 kagent 실습 덱에 있다. 클러스터에서의 확인 순서는 임직원 토큰 전파의 확인 순서를 따른다. BYO Agent는 MCP 서버와 함께 로컬에서 띄울 수 있다 — 다음 페이지에서 다룬다.

  • kmcp run으로 띄운 서버에서 get_my_leave_requests를 실행하니 “No active HTTP request” 오류가 난다. 왜인가? → kmcp run은 stdio로 실행한다. HTTP 요청이 없으므로 header를 읽을 수 없다. --transport http로 직접 실행한다.
  • 로컬에서는 되던 서버가 클러스터에서 Ready인데 Agent의 tool 호출이 연결 실패한다. 무엇부터 보는가? → MCPServer의 args. --transport http와 --host 0.0.0.0이 빠지면 서버가 stdio로 뜨거나 Pod 안에서만 듣는다.
  • src/tools/에 공용 함수만 모은 common.py를 추가했더니 서버가 시작하지 않는다. 왜인가? → 서버는 그 디렉터리의 모든 파일이 tool을 등록한다고 가정한다. 등록이 없는 파일은 오류로 처리한다. 다른 디렉터리로 옮긴다.