장르: 분석 + 설계 논의. 결재 문서를 읽어 주는 MCP 서버를 두고 "이 툴을 LLM에게는 안 보이게 하면서 REST로는 쓸 수 있나"를 물은 세션의 기록이다. 새로 만든 것은 없다. 코드에서 툴 노출이 어디서 결정되는지 확인하고, 두 표면을 함께 두는 소스 형태와 그것이 일반적인 구성인지, 그리고 서버가 딛고 선 계층(ASGI, Starlette, Pydantic)이 무엇인지를 순서대로 짚었다. 조직 식별 정보는 전부 일반화했다.

이 서버는 값은 맞는데 답이 틀린다: MCP 응답의 모양이 답을 바꾼다에서 다룬 그 서버다. 사내 그룹웨어의 결재 문서(휴가·지출) 스냅샷을 읽어 주고, 채팅 봇(Hermes Agent)이 MCP로 붙는다. 이번 질문은 그 서버를 봇이 아닌 것도 쓸 수 있게 하려는 데서 나왔다.

1. 질문: MCP 툴을 LLM 목록에 올리지 않고 REST로 쓸 수 있나

원문 그대로다. "MCP의 tool을 LLM이 활용할 tool 목록에 노출시키지 않고 일반적인 REST API로 동작하게 할 수 있어?"

질문 안에 전제가 하나 숨어 있다. MCP 툴이 곧 LLM 툴이라는 전제다. 그 전제가 맞다면 "노출시키지 않는 MCP 툴"은 형용모순이 되고, 답은 "MCP를 버리고 REST로 다시 짜라"가 된다. 그런데 코드를 보면 전제가 틀렸다.

2. 코드에서 확인한 것: 핸들러는 MCP를 모른다

서버의 파일 구성은 이렇다.

app/
  main.py      Starlette 앱. /health, /metrics, /mcp/approvals 마운트
  tools.py     툴 정의(types.Tool 목록)와 dispatch()
  dataset.py   스냅샷 로딩과 리로드
  auth.py      Bearer 토큰 미들웨어
  telemetry.py 호출 계측

핵심은 tools.pydispatch() 시그니처다.

def dispatch(name: str, args: dict, snap: Snapshot, *,
             path: str, stale_after_hours: float) -> dict[str, Any]:

dict를 받아 dict를 돌려준다. mcp.types를 임포트하기는 하지만 그것은 툴 정의(TOOLS 목록)를 만들 때뿐이고, 실행 경로에는 MCP 타입이 하나도 끼지 않는다. MCP 껍데기는 main.pycall_tool() 하나이고, 그것도 하는 일은 dispatch()를 부른 뒤 결과를 CallToolResult로 싸는 것뿐이다.

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> types.CallToolResult:
    snap = await store.current()
    try:
        payload = dispatch(name, arguments, snap,
                           path=str(store.path),
                           stale_after_hours=settings.stale_after_hours)
    except (ToolInputError, ValueError) as e:
        return _result(json.dumps({"error": str(e)}, ensure_ascii=False),
                       is_error=True)
    return _result(json.dumps(payload, ensure_ascii=False))

그리고 같은 파일에 이미 MCP가 아닌 HTTP 경로가 둘 있다.

base_app = Starlette(
    routes=[
        Route("/health", health, methods=["GET"]),
        Route("/metrics", metrics, methods=["GET"]),
        Mount("/mcp/approvals", app=_mcp_endpoint),
    ],
    lifespan=lifespan,
)

/health/metrics는 같은 프로세스에 살지만 봇은 그 존재를 모른다. 봇이 아는 것은 /mcp/approvals 마운트 하나이고, 그 안에서도 tools/list 요청에 서버가 돌려준 목록만 안다.

여기서 질문의 전제가 뒤집힌다. LLM이 보는 툴 = list_tools()가 반환하는 것. 그 외의 HTTP 경로는 MCP 클라이언트에게 존재 자체가 없다. "노출시키지 않고 REST로" 는 모순이 아니라 이미 /health가 하고 있는 일이다.

3. 세 가지 방식

방식 하는 일 언제
A. 같은 프로세스에 REST 라우트 추가 routesRoute("/api/tools/{name}", ...)를 넣고 안에서 dispatch()를 부른다 LLM 툴은 그대로 두고 REST를 덧붙이고 싶을 때
B. 일부 툴만 LLM에서 빼고 REST 전용으로 TOOLS를 공개/비공개로 나눠 list_tools()는 공개만 돌려주고, call_tool()에서 비공개 이름을 거부한다 관리용·대량 덤프처럼 LLM에게 안 보여줄 기능이 있을 때
C. MCP 툴을 비우고 REST만 list_tools()[]를 돌려준다 사실상 MCP 서버를 띄울 이유가 없으니 마운트를 빼고 순수 REST 앱으로 간다

A가 기본이다. B에서 주의할 것은 숨김과 차단은 다르다는 점이다. tools/list에서 빼기만 하면 이름을 아는 클라이언트는 tools/call로 여전히 부를 수 있다. 진짜 막으려면 call_tool()에서 공개 목록에 없는 이름을 거부해야 한다.

작은 주의점 하나. 현재 call_tool()은 호출마다 telemetry.record()를 남기고 /metrics가 그 집계를 보여 준다. REST 경로에서도 같은 계측을 남길지, 남긴다면 MCP 호출과 구분할지는 결정이 필요하다. build_event()tool=만 받으니 source="rest" 정도의 필드 추가로 끝난다.

4. 두 표면의 소스를 나란히 놓으면

같은 로직을 두 표면으로 노출하는 최소 예시다. 이 서버의 구조를 그대로 축약했다.

공통 로직: 두 표면이 공유하는 순수 함수

# app/logic.py  (실제 서버에서는 tools.py 의 dispatch() 가 이 역할)
from dataclasses import dataclass

@dataclass
class Snapshot:
    docs: list[dict]

class ToolInputError(ValueError):
    pass

def search_approvals(args: dict, snap: Snapshot) -> dict:
    query = (args.get("query") or "").strip()
    if not query:
        raise ToolInputError("query 는 비울 수 없습니다")
    limit = min(int(args.get("limit", 25)), 100)
    hits = [d for d in snap.docs if query in d["title"]][:limit]
    return {"total": len(hits), "documents": hits}

def current_date(args: dict, snap: Snapshot) -> dict:
    from datetime import date
    return {"today": date.today().isoformat()}

HANDLERS = {
    "search_approvals": search_approvals,
    "current_date": current_date,
}

def dispatch(name: str, args: dict, snap: Snapshot) -> dict:
    if name not in HANDLERS:
        raise ToolInputError(f"unknown tool: {name}")
    return HANDLERS[name](args or {}, snap)

여기엔 mcp.typesstarlette도 없다. 이것이 전부의 출발점이다.

MCP 표면: LLM이 보는 것

# app/mcp_surface.py
import json
import mcp.types as types
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager

from .logic import dispatch, ToolInputError

server = Server("approvals")

# LLM 툴 목록. 여기 없으면 LLM 은 존재를 모른다.
PUBLIC_TOOLS = [
    types.Tool(
        name="search_approvals",
        description="결재 문서를 제목으로 검색한다.",
        inputSchema={
            "type": "object",
            "properties": {
                "query": {"type": "string"},
                "limit": {"type": "integer", "default": 25},
            },
            "required": ["query"],
        },
    ),
    # current_date 는 의도적으로 뺐다 -> REST 전용
]
PUBLIC_NAMES = frozenset(t.name for t in PUBLIC_TOOLS)


@server.list_tools()
async def list_tools() -> list[types.Tool]:
    return PUBLIC_TOOLS


@server.call_tool()
async def call_tool(name: str, arguments: dict) -> types.CallToolResult:
    # tools/call 은 이름만 알면 부를 수 있으므로, 목록에서 뺀 것은 여기서도 막는다.
    if name not in PUBLIC_NAMES:
        return _result(json.dumps({"error": f"unknown tool: {name}"}), is_error=True)

    snap = await store.current()
    try:
        payload = dispatch(name, arguments, snap)
    except ToolInputError as e:
        return _result(json.dumps({"error": str(e)}, ensure_ascii=False), is_error=True)
    return _result(json.dumps(payload, ensure_ascii=False))


def _result(text: str, is_error: bool = False) -> types.CallToolResult:
    return types.CallToolResult(
        content=[types.TextContent(type="text", text=text)],
        isError=is_error,
    )


sessions = StreamableHTTPSessionManager(app=server, event_store=None, stateless=True)

async def mcp_endpoint(scope, receive, send):
    await sessions.handle_request(scope, receive, send)

_result()가 굳이 CallToolResult를 직접 만드는 데는 이유가 있다. 핸들러가 dict를 돌려주면 SDK가 기본 경로를 타면서 indent=2로 예쁘게 찍고 같은 페이로드를 structuredContent에 한 벌 더 복사한다. 출력이 약 2배가 되고 그 2배는 전부 모델의 컨텍스트 비용이다. CallToolResult를 돌려주면 SDK가 단락해서 그대로 내보낸다. 실제 서버는 이 의존을 테스트로 고정해 두었다.

REST 표면: 사람·스크립트·다른 서비스가 보는 것

# app/rest_surface.py
from starlette.requests import Request
from starlette.responses import JSONResponse

from .logic import dispatch, ToolInputError, HANDLERS


async def rest_tool(request: Request) -> JSONResponse:
    """POST /api/tools/{name}  body: {"query": "휴가", "limit": 10}
       GET  /api/tools/{name}?query=휴가&limit=10
    """
    name = request.path_params["name"]
    if name not in HANDLERS:
        return JSONResponse({"error": f"unknown tool: {name}"}, status_code=404)

    if request.method == "POST":
        args = await request.json()
    else:
        args = dict(request.query_params)

    snap = await store.current()
    try:
        payload = dispatch(name, args, snap)
    except ToolInputError as e:
        return JSONResponse({"error": str(e)}, status_code=400)

    # MCP 와 달리 텍스트로 감싸지 않고 JSON 그대로 돌려준다.
    return JSONResponse(payload)


# 자원 중심 경로도 같은 dispatch 로 만들 수 있다.
async def list_approvals(request: Request) -> JSONResponse:
    snap = await store.current()
    payload = dispatch("search_approvals", dict(request.query_params), snap)
    return JSONResponse(payload)

한 프로세스에 함께 마운트

# app/main.py
from starlette.applications import Starlette
from starlette.routing import Mount, Route

from .mcp_surface import mcp_endpoint, sessions
from .rest_surface import rest_tool, list_approvals

app = Starlette(
    routes=[
        Route("/health", health, methods=["GET"]),
        # REST: LLM 은 이 경로들의 존재를 모른다.
        Route("/api/tools/{name}", rest_tool, methods=["GET", "POST"]),
        Route("/api/approvals", list_approvals, methods=["GET"]),
        # MCP: 봇이 붙는 유일한 경로.
        Mount("/mcp/approvals", app=mcp_endpoint),
    ],
    lifespan=lifespan,   # sessions.run() 을 감싸는 기존 lifespan
)

실제 서버는 이 앱을 BearerTokenMiddleware로 한 번 더 감싼다. 미들웨어는 /health를 뺀 모든 경로를 검사하므로 새 REST 경로도 손대지 않고 같은 토큰 뒤에 들어간다.

5. 호출 비교: 같은 함수, 다른 봉투

MCP (LLM 경유) REST (직접)
경로 POST /mcp/approvals/ (JSON-RPC tools/call) POST /api/tools/search_approvals
요청 본문 {"jsonrpc":"2.0","method":"tools/call","params":{"name":"search_approvals","arguments":{"query":"휴가"}}} {"query":"휴가"}
응답 content[0].text 안에 JSON 문자열 JSON 객체 그대로
오류 isError: true + HTTP 200 HTTP 400 / 404
인증 Authorization: Bearer 동일 (같은 미들웨어)
# REST
curl -s -H "Authorization: Bearer $TOKEN" \
  -X POST localhost:18089/api/tools/search_approvals \
  -d '{"query":"휴가","limit":5}'

# current_date 는 REST 로는 되지만 MCP tools/list 에는 안 나온다
curl -s -H "Authorization: Bearer $TOKEN" localhost:18089/api/tools/current_date

응답 행이 두 표면의 성격 차이를 가장 잘 보여 준다. MCP는 JSON을 문자열로 텍스트 블록에 넣는다. LLM에게는 그게 자연스럽지만 프로그램에는 이중 파싱이다. 그래서 REST에서는 JSONResponse(payload)로 직접 돌려주고, 직렬화 지점이 표면마다 하나씩 생긴다.

LLM 전용 가공이 어디에 남는지도 보인다. 실제 서버의 list_tools()with_date_context()를 거쳐 툴 설명에 스냅샷 범위와 오늘 날짜를 끼워 넣는다. 모델이 연도를 추측하면 0건이 나오고 그 재시도 한 번이 실측 13~17초이기 때문이다. REST 호출자는 자기가 날짜를 넣으니 이 가공이 필요 없다. 모델을 위한 프롬프트 가공은 MCP 표면에만 남고, 그것이 두 표면을 나누는 자연스러운 경계가 된다.

6. 두 표면을 함께 두는 것이 일반적인가

그렇다. 다만 어느 쪽이 원본이냐에 따라 두 방향이 있다.

(a) REST가 원본, MCP는 어댑터. 가장 흔하다. 이미 REST API가 있는 서비스(GitHub, Slack, Notion 등)가 그 위에 MCP 서버를 얹고, 툴 핸들러 안에서 자기 REST를 부른다. 이 시리즈의 봇은 하나, 계정은 사용자마다: 사용자별 인증 게이트웨이 MCP에 나오는 캘린더 프록시가 이 형태다. Google Calendar REST를 MCP로 감싼다. 이 경우 "REST로도 쓸 수 있나"는 질문 자체가 나오지 않는다. REST가 먼저 있었다.

LLM → MCP 서버 → (HTTP) → 기존 REST API → DB

(b) 로직이 원본, MCP와 REST가 둘 다 얇은 껍데기. 처음부터 MCP용으로 만든 서버에 나중에 REST가 필요해지는 경우이고, 이 결재 서버가 여기 해당한다.

LLM      → MCP 표면  ─┐
                       ├→ dispatch() → 스냅샷
스크립트 → REST 표면  ─┘

(b)를 만드는 실질적 이유는 셋이다.

  • 디버깅. MCP는 JSON-RPC + 세션 + SSE라 curl 한 줄로 확인하기 번거롭다. REST가 있으면 "이 툴이 이 인자에 뭘 돌려주나"를 바로 본다.
  • LLM 없는 소비자. 대시보드, cron 스크립트, 다른 백엔드. 이들에게 MCP 클라이언트 라이브러리를 물리는 것은 과하다.
  • LLM에게 안 보여줄 기능. 관리용, 대량 덤프, 비용이 큰 연산. tools/list에서 빼면 LLM은 시도조차 못 한다. 도구 79개를 2개로 압축한 대가: 스키마를 감추면 모델은 매번 한 번 틀린다가 보여 주듯 툴 목록은 곧 프롬프트 비용이라, 모델이 쓸 일 없는 툴은 애초에 목록에 없는 편이 낫다.

프레임워크 쪽 증거도 있다. FastMCP 2.x에는 @mcp.custom_route("/api/...")가 있고, 반대로 FastMCP.from_fastapi(app)로 기존 FastAPI 엔드포인트를 MCP 툴로 자동 변환하는 기능도 있다. "두 표면을 함께"가 프레임워크 차원에서 지원된다는 것 자체가 흔한 요구라는 뜻이다. 이 서버는 FastMCP가 아니라 저수준 Server를 쓰므로(세션 매니저를 직접 제어하려고) Starlette 라우트로 직접 붙이는 방식이 된다.

같은 프로세스에 둘지 분리할지도 물었는데, 같은 프로세스가 맞다. 배포·인증·스냅샷 공유가 간단하다. 분리는 스케일이나 장애 격리가 필요할 때인데, 이 서버는 인메모리 조회라 그럴 이유가 없다.

7. 용어 정리: 이 서버가 딛고 선 세 층

세션 후반은 용어 질문이었다. Starlette이 뭔지, ASGI가 뭔지, Pydantic이 뭔지. 서버 코드를 읽는 데 필요한 만큼만 적는다.

위치 관계

uvicorn        ASGI 서버. 소켓을 열고 HTTP 를 파싱해 ASGI 이벤트로 넘긴다
  └ Starlette    ASGI 프레임워크. 라우팅, 미들웨어, Request/Response 객체
      └ FastAPI      Starlette 위에 Pydantic 검증 + OpenAPI 문서를 얹은 것
      └ MCP SDK      StreamableHTTPSessionManager 를 Starlette Mount 로 붙이도록 설계됨

ASGI: 서버와 앱 사이의 약속

Asynchronous Server Gateway Interface. 라이브러리가 아니라 규약이다. "앱은 이런 모양의 함수여야 하고, 서버는 이렇게 호출한다"는 문서이고, 규약의 전체 내용은 시그니처 하나다.

async def app(scope, receive, send):
    ...
인자 의미
scope 이 연결의 정보. {"type": "http", "method": "POST", "path": "/api/x", "headers": [...]}
receive 호출하면 요청 본문 조각이 하나씩 온다 (await receive())
send 응답을 내보낸다. http.response.start(상태·헤더) 뒤에 http.response.body(본문)

이 서버의 auth.py가 이 규약을 날것으로 쓴다. 거부 응답을 만들 때 Starlette의 Response가 아니라 send를 두 번 직접 부른다. Starlette 없이도 ASGI 앱은 만들 수 있다.

왜 필요한가. 서버(uvicorn)와 프레임워크(Starlette)가 서로를 몰라도 되게 하려고다. uvicorn은 "ASGI 앱이면 뭐든 돌린다", Starlette은 "ASGI 서버면 뭐든 위에서 돈다". 그래서 한쪽을 바꿔도 반대편은 손대지 않는다.

이전 규약인 WSGI(Flask, 전통 Django)는 동기식이라 요청 하나가 끝날 때까지 스레드가 묶인다. ASGI는 async라 한 스레드가 여러 연결을 번갈아 처리하고, WebSocket과 SSE처럼 오래 열린 연결을 다룰 수 있다. MCP의 Streamable HTTP는 SSE로 응답을 흘려보내므로 ASGI가 아니면 구현이 안 된다. 이 서버가 Flask가 아닌 첫 번째 이유다.

그리고 ASGI 앱은 "앱을 받아 앱을 돌려주는" 식으로 겹쳐 쌓인다.

uvicorn
 └ BearerTokenMiddleware(scope, receive, send)   ASGI 앱. 토큰 검사 후
    └ Starlette(scope, receive, send)            ASGI 앱. 경로를 보고
       └ _mcp_endpoint(scope, receive, send)     ASGI 앱. MCP 처리

세 층이 전부 같은 시그니처라 Mount나 미들웨어로 자유롭게 끼워진다. build_app()BearerTokenMiddleware(base_app, ...)를 돌려주는 것이 바로 이 겹치기다.

Starlette: 경량 ASGI 프레임워크

읽기는 "스탈렛"이다. Python의 경량 ASGI 웹 프레임워크로, Flask와 비슷한 위치인데 비동기 전용이다. FastAPI를 써 봤다면 이미 Starlette을 쓴 것이다. FastAPI의 Request, Response, Mount, Middleware는 전부 Starlette에서 그대로 재수출된 것이다.

이 서버에서 Starlette이 하는 일은 정확히 셋이다. 경로를 함수로 잇는 Route, 하위 ASGI 앱을 붙이는 Mount, 기동·종료 훅인 lifespan. 미들웨어는 Starlette 클래스를 쓰지도 않고 순수 ASGI 인터페이스로 직접 짰다.

FastAPI가 아니라 Starlette을 고른 이유는 의존성 목록에 드러난다. "나가는 HTTP 요청이 없고, 저장할 토큰이 없다"는 최소주의. REST 엔드포인트가 /health, /metrics 둘뿐인 서버에서 FastAPI의 Pydantic 검증과 OpenAPI 문서는 얻는 게 없다. REST를 키워 요청 검증이 귀찮아지면 그때 갈아타면 된다. Starlette(routes=...)FastAPI()로 바꾸면 기존 Mount, Route, 미들웨어가 그대로 동작한다.

Pydantic: 타입 힌트로 검증하는 라이브러리

읽기는 "파이단틱"이다. 클래스 정의만 하면 파싱·타입 변환·오류 메시지·JSON Schema 생성을 전부 얻는다.

from pydantic import BaseModel

class SearchArgs(BaseModel):
    query: str
    limit: int = 25

SearchArgs(query="휴가", limit="10")   # limit 이 문자열 "10" → int 10 으로 변환
SearchArgs(query="휴가", limit="abc")  # ValidationError: limit 은 정수여야 함
SearchArgs(limit=5)                    # ValidationError: query 가 없음

이 서버의 직접 의존성에는 없지만 MCP SDK가 Pydantic 위에 있다. types.CallToolResult, types.TextContent, types.Tool이 모두 Pydantic 모델이다. 툴의 inputSchema로 넣는 JSON Schema 딕셔너리는 Pydantic이 만들어 주는 것과 같은 형식이고, 이 서버는 그것을 손으로 쓴다.

손으로 쓰는 데도 이유가 있다. 툴이 6개뿐이고, 대신 필드마다 LLM에게 보여줄 설명 문구를 수백 자 단위로 세밀하게 조정한다. 상대 기간을 서버에서 풀라고 설득하는 설명, 주 경계를 하루 틀리면 무엇이 빠지는지 적은 설명. Pydantic의 Field(description=...)로도 되지만 이 정도 길이의 프롬프트성 설명은 딕셔너리 그대로가 읽기 편하다.

REST 표면을 본격적으로 키운다면 반대 방향이 열린다. SearchArgs 같은 모델을 한 번 정의하고 .model_json_schema()로 MCP의 inputSchema도 뽑는 방식이다. 두 표면의 스키마가 한 소스에서 나오니 어긋날 일이 없다. "로직이 원본, 표면은 껍데기" 원칙이 스키마까지 확장되는 것이다.

8. 남은 결정

  • 방식 A(REST 라우트 추가)로 갈지, B(일부 툴 REST 전용)로 갈지. B라면 어느 툴을 뺄지.
  • REST 호출도 telemetry.record()에 남길지, 남긴다면 /metrics에서 MCP와 구분할지.
  • REST가 두어 개를 넘기면 Pydantic 모델을 들여 두 표면의 스키마를 한 소스로 묶을지.

전부 구조를 바꾸는 결정이 아니다. dispatch()가 순수 함수인 한, 표면은 얼마든지 덧붙인다.

관련 글