Function calling은 모델이 도구를 요청하는 방법을 제공합니다. MCP는 애플리케이션이 서버의 도구를 표준 방식으로 발견하고 호출하는 방법을 제공합니다. 애플리케이션이 자체 보유한 소수의 도구라면 직접 함수 통합을 사용하세요. 기존 호환 서비스를 소비하거나 도구를 공유 인터페이스로 노출하려면 MCP를 추가하세요. 아래에서는 하나의 전체 에이전트 루프를 사용해 동일한 주문 조회를 CometAPI를 통해 두 방식으로 연결합니다.
핵심 포인트
한 애플리케이션에서 몇 개의 도구만 쓰는 경우에는 직접 디스패치로 시작하세요. 호환 도구 서비스나 공유 도구 인터페이스가 유지 관리를 쉽게 만드는 경우 MCP를 사용하세요.
두 경로 모두 완전한 루프가 필요합니다: 도구를 설명하고, 호출을 수신하고, 실행하고, 결과를 반환한 뒤 모델이 답하도록 합니다.
실행을 직접 핸들러에서 MCP 클라이언트로 바꾸더라도 모델 입장에서의 함수 정의는 그대로 유지할 수 있습니다.
지원 에이전트는 배송 지연을 설명할 수 있지만, 고객에게 패키지가 어디 있는지 알려면 주문 시스템에 접근해야 합니다. Function calling은 해당 조회를 요청하는 방법을 모델에 제공합니다. 애플리케이션은 조회를 실행하고 결과를 반환합니다. 핸들러는 로컬 라이브러리, 데이터베이스 또는 원격 API를 사용할 수 있습니다.
그 요청을 MCP로 이행하려면 애플리케이션은 클라이언트를 사용해 tools/list로 서버의 도구를 발견하고 tools/call로 호출합니다. 애플리케이션은 발견된 도구 중 모델에 노출할 도구를 여전히 선택합니다.
MCP 서버가 필요하지만, 클라이언트가 로컬 stdio 서브프로세스로 실행할 수 있음; 별도로 배포된 원격 서비스는 선택 사항
일반적인 사용
애플리케이션이 소유한 소수 도구에 대한 직접 디스패치
기존 호환 서비스 또는 여러 애플리케이션에서 재사용되는 도구 인터페이스
함께 사용할 수 있나요?
애플리케이션이 모델의 함수 요청을 MCP를 통해 라우팅할 수 있음
MCP 클라이언트가 tools/call로 그 요청을 이행하고 결과를 애플리케이션에 반환
마지막 행이 둘을 함께 구축하는 핵심입니다. 모델은 get_order_status를 요청하고, 애플리케이션은 자체 핸들러를 호출할지 MCP 서버를 호출할지 선택할 수 있습니다. 모델 측 함수 정의는 그대로 유지됩니다.
애플리케이션에 맞는 통합을 선택하세요
한 애플리케이션이 사용하는 소수의 도구를 보유하고 있다면 직접 통합으로 시작하는 것을 권장합니다. 배포가 적고, 모델 요청에서 비즈니스 코드로 이어지는 경로가 명확합니다.
호환 서버가 이미 기능을 제공하거나 여러 AI 애플리케이션이 공통 도구 인터페이스를 필요로 할 때 MCP가 유용해집니다. 동일한 주문 도구는 지원 어시스턴트와 내부 운영 애플리케이션 모두에 제공될 수 있으며, 각 호스트는 사용자가 접근하는 방식을 제어합니다.
디스패처는 내부 함수와 MCP 서비스를 모두 지원할 수 있으며, 각 작업을 해당 구현으로 라우팅합니다. 라이브러리와 REST API도 공유될 수 있습니다. 도구를 소비하는 애플리케이션에 MCP의 표준 발견 및 호출 인터페이스가 유용할 때 MCP를 선택하고, 운영이 더 간단한 곳에는 직접 핸들러를 유지하세요.
CometAPI로 완전한 도구 루프 구축
읽기 전용 작업 하나인 get_order_status와 허구의 주문 하나인 ORD-104를 사용합니다. 예시는 먼저 작업을 직접 호출한 뒤, 로컬 MCP 서버를 통해 실행합니다. 두 모드 모두 모델 추론에 CometAPI Responses API를 사용합니다.
그림 2. 하나의 에이전트 루프가 직접 실행과 MCP 실행을 모두 지원할 수 있습니다. MCP 아키텍처와 CometAPI 함수 호출 흐름을 기반으로 한 원본 일러스트.
1단계 프로젝트 설정
Python 3.11 이상과 CometAPI API 키를 사용하세요. 이 튜토리얼은 Python 3.12.14와 mcp==2.3.0으로 테스트했습니다. CometAPI 빠른 시작을 따라 키를 생성하세요. macOS 또는 Linux 셸에서 프로젝트를 만들고 가상 환경을 활성화합니다:
플레이스홀더를 자신의 키로 바꾸고 공개 소스 코드에 포함하지 마세요. 이 튜토리얼은 예시에서 사용하는 MCP SDK 버전을 고정합니다. 공식 Python 서버 가이드는 2.x API를 대상으로 합니다. 아래의 두 에이전트 명령은 CometAPI로 요청을 전송하며 API 크레딧을 사용합니다.
2단계 주문 조회 정의
다음을 orders.py로 저장하세요. 고정 데이터를 사용해 튜토리얼을 자급자족하게 유지합니다. 이후에는 조회를 여러분의 서비스로 대체할 수 있습니다.
"""Read-only, invented order data; never connects to a real order system."""
import re
def get_order_status(order_id: str) -> dict[str, str]:
"""Look up an invented order; ORD-104 is the only available record."""
if not isinstance(order_id, str) or not re.fullmatch(r"ORD-[0-9]{3}", order_id):
raise ValueError("order_id must have the form ORD-104")
if order_id != "ORD-104":
raise ValueError("Order not found in this demo")
return {"order_id": order_id, "status": "shipped", "source": "fictional_demo"}
다음 완전한 스크립트를 agent.py로 저장하세요. 함수를 등록하고, 질문을 CometAPI로 보내며, 요청된 호출을 실행하고 원래 call_id를 사용하여 각 결과를 반환합니다.
모델 측 정의는 CometAPI Responses API의 parameters 필드를 사용합니다. MCP는 inputSchema를 제공합니다. 이 예시는 발견한 계약이 단일 문자열 order_id 스키마와 일치하는지 확인한 뒤 검토된 함수를 노출합니다.
"""A deliberately narrow direct/MCP bridge, not a general MCP adapter."""
import argparse
import asyncio
import json
import os
from pathlib import Path
import sys
from urllib.request import Request, urlopen
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
from orders import get_order_status
NAME = "get_order_status"
SCHEMA = {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"], "additionalProperties": False}
TOOL = {"type": "function", "name": NAME, "description": "Look up fictional order ORD-104.",
"parameters": SCHEMA, "strict": True}
def validate_arguments(arguments):
# Exact validation for SCHEMA above; deliberately not a JSON Schema engine.
if (not isinstance(arguments, dict) or set(arguments) != {"order_id"}
or not isinstance(arguments["order_id"], str)):
raise ValueError("Expected exactly one string argument: order_id")
def validate_catalog(tools):
if len(tools) != 1 or tools[0].name != NAME:
raise RuntimeError("Unexpected MCP tool catalog")
schema = tools[0].input_schema
if (schema.get("type") != "object" or schema.get("required") != ["order_id"]
or set(schema.get("properties", {})) != {"order_id"}
or schema["properties"]["order_id"].get("type") != "string"):
raise RuntimeError("MCP schema does not match this reviewed demo tool")
async def dispatch(name, arguments, client=None):
if name != NAME:
raise ValueError("Tool is not allowlisted")
try:
validate_arguments(arguments)
if client is None:
return {"is_error": False, "data": get_order_status(**arguments)}
except ValueError as error:
return {"is_error": True, "error": str(error)}
result = await client.call_tool(name, arguments)
if getattr(result, "result_type", "complete") != "complete":
raise RuntimeError("Input-required MCP results are outside this demo")
if any(block.type != "text" for block in result.content):
raise RuntimeError("Non-text MCP content is outside this demo")
return {"is_error": bool(result.is_error),
"content": [block.text for block in result.content],
"structured_content": result.structured_content}
async def run_agent(send, execute, question, max_rounds=4):
history = [{"role": "user", "content": question}]
seen_call_ids = set()
for _ in range(max_rounds):
response = send({"model": "gpt-6-astra", "input": list(history),
"tools": [TOOL], "reasoning": {"effort": "low"}})
if response.get("status") != "completed" or response.get("error"):
raise RuntimeError("Model response failed or did not complete")
output = response["output"]
# CometAPI's documented manual replay: keep ALL items, omit top-level ids.
history.extend({key: value for key, value in item.items() if key != "id"}
for item in output)
blocks = [block for item in output if item["type"] == "message"
for block in item.get("content", [])]
if any(block["type"] == "refusal" for block in blocks):
raise RuntimeError("Model refused the request")
calls = [item for item in output if item["type"] == "function_call"]
if not calls:
text = "\n".join(block["text"] for block in blocks
if block["type"] == "output_text")
if not text:
raise RuntimeError("No final text returned")
return text
for call in calls:
call_id = call["call_id"]
if not isinstance(call_id, str) or not call_id or call_id in seen_call_ids:
raise ValueError("Missing or duplicate function call_id")
seen_call_ids.add(call_id)
try:
arguments = json.loads(call["arguments"])
except (ValueError, TypeError):
result = {"is_error": True, "error": "Arguments must be JSON text"}
else:
result = await execute(call["name"], arguments)
print(json.dumps({"tool": call["name"], "arguments": call["arguments"],
"result": result}), file=sys.stderr)
history.append({"type": "function_call_output", "call_id": call_id,
"output": json.dumps(result)})
raise RuntimeError("Tool loop reached its round limit")
def send_live(payload):
key = os.environ["COMETAPI_KEY"] # Only live mode reads the caller's key.
request = Request("https://api.cometapi.com/v1/responses",
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {key}",
"Content-Type": "application/json"})
with urlopen(request, timeout=45) as response:
return json.load(response)
def server_parameters():
return StdioServerParameters(command=sys.executable,
args=[str(Path(__file__).with_name("server.py"))])
async def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mode", choices=["direct", "mcp"], required=True)
parser.add_argument("--question", default="Use the tool to check order ORD-104.")
args = parser.parse_args()
if args.mode == "direct":
answer = await run_agent(send_live, dispatch, args.question)
else:
async with Client(stdio_client(server_parameters())) as client:
discovered = await client.list_tools()
validate_catalog(discovered.tools)
async def execute(name, arguments):
return await dispatch(name, arguments, client)
answer = await run_agent(send_live, execute, args.question)
print(answer)
if __name__ == "__main__":
asyncio.run(main())
루프는 원래 질문과 반환된 모든 응답 항목을 유지합니다. CometAPI Responses 대화 형식을 따라, 재생 시 출력 항목의 최상위 id 필드는 제거하고 각 호출에 대해 function_call_output을 추가합니다. 반환된 나머지 항목을 보존하면 다음 턴에 필요한 추론 항목도 함께 보존됩니다.
4단계 직접 경로 실행
다음을 실행하세요:
python agent.py --mode direct
애플리케이션은 모델에 get_order_status를 설명합니다. 모델이 이를 요청하면 dispatch()가 이름과 인자를 확인한 뒤 orders.py의 함수를 호출합니다. 결과는 대화에 다시 들어가며, 모델이 이를 답으로 변환할 수 있습니다.
터미널에 출력되는 도구 트레이스를 사용해 조회가 ORD-104를 사용했고 status: shipped를 반환했는지 확인하세요. 최종 답변은 그 결과를 반영해야 하며, 문구는 달라질 수 있습니다. 이제 사용자 질문부터 애플리케이션 실행 작업, 후속 모델 응답까지 완전한 함수 호출 루프를 갖게 되었습니다.
다음 단계는 모델과 대화 루프를 그대로 유지합니다. 실행을 MCP 서버 뒤로 이동시키고, 애플리케이션이 클라이언트를 통해 도구를 발견하고 호출하도록 합니다.
5단계 조회를 MCP 도구로 노출
다음을 다른 두 파일과 함께 server.py로 저장하세요:
"""One read-only MCP tool, served over stdio. No stdout logging."""
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from orders import get_order_status as lookup_order
server = MCPServer("fictional-orders")
@server.tool(structured_output=True)
def get_order_status(order_id: str) -> dict[str, str]:
"""Look up fictional order ORD-104; this demo has no real customer data."""
try:
return lookup_order(order_id)
except ValueError as error:
raise ToolError(str(error)) from error
if __name__ == "__main__":
server.run(transport="stdio")
SDK는 함수 시그니처와 독스트링에서 도구 정의를 생성합니다. 서버는 클라이언트가 표준 스트림을 통해 서브프로세스로 실행하고 프로토콜 메시지를 교환하는 stdio를 사용합니다. 서버의 표준 출력에는 애플리케이션 로그를 남기지 마세요.
6단계 MCP 경로 실행
다음을 실행하세요:
python agent.py --mode mcp
main()의 mcp 분기에서 server.py를 시작하고, list_tools()를 호출해 주문 도구 계약을 확인합니다. 모델이 조회를 요청하면 동일한 디스패처가 이제 client.call_tool()을 사용합니다. 서버가 함수를 실행하고, 애플리케이션이 그 결과를 모델에 반환합니다.
두 실행을 동일한 기준으로 비교하세요: 올바른 도구, 인자로 ORD-104, shipped를 포함한 성공적인 결과, 그리고 그 결과와 일치하는 최종 답변. 실행 경로는 달라졌지만, 작업과 모델 측 함수는 그대로입니다.
위에 세 소스 파일이 모두 포함되어 있습니다. 예제 패키지에도 이 파일들과 오프라인 검사가 포함되어 있어 편리하게 재사용할 수 있습니다. 그 검사는 스크립트된 모델 응답으로 실제 로컬 MCP 서버를 테스트하며, 라이브 모델 출력은 로컬 검증과 별개입니다.
예제를 여러분의 도구에 맞게 조정
고정 주문 데이터를 애플리케이션이 실제로 필요로 하는 최소 작업으로 대체하세요. 배송 관련 질문이라면 주문 ID와 유용한 상태 필드를 반환하세요. 간결한 결과는 모델이 관련 없는 자료를 해석할 여지를 줄이며, 답변을 확인하기 쉽게 만듭니다.
도구의 설명과 인자 계약을 구체적으로 유지하세요. 이 예시는 정확히 하나의 문자열을 허용하며, 호스트는 디스패치 전에 요청된 도구 이름을 확인합니다. MCP 보안 지침은 서버가 입력을 검증하고 접근 제어를 구현할 것을 권고합니다. 실제 주문 서비스에서는 신뢰할 수 있는 애플리케이션 컨텍스트에서 고객의 신원을 파생하고, 서비스 내부에서 해당 고객의 주문에 대한 접근을 강제하세요. 모델이 제공한 주문 ID만으로는 소유권을 확립할 수 없습니다.
도구가 작동하면 완료된 작업, 종단 간 시간, 실제 비용을 평가하세요. MCP가 도구를 필요로 하는 애플리케이션에 서비스 인터페이스를 제공하는 데 도움이 되는 경우 MCP를 추가하고, 운영이 더 간단한 곳에서는 직접 핸들러를 유지하세요.
FAQ
MCP가 함수 호출을 대체하나요?
둘은 다른 인터페이스를 다룹니다. MCP는 애플리케이션의 클라이언트를 서버에 연결합니다. 이 예시에서 모델은 함수를 요청하고, 애플리케이션은 MCP 클라이언트를 통해 그 요청을 이행합니다. 둘 다 동일한 에이전트 루프에 참여합니다.
MCP 도구가 기존 REST API를 호출할 수 있나요?
가능합니다. MCP 도구 내부의 조회를 여러분의 API 호출로 바꾸세요. MCP 서버는 도구 계약을 게시하고 도구 호출을 처리하며, 기존 서비스는 비즈니스 동작을 계속 제공합니다.
원격 MCP 서버는 언제 사용해야 하나요?
클라이언트가 로컬 프로세스를 실행하는 대신 별도로 배포된 서버에 접근해야 하는 경우 원격 서비스를 사용하세요. MCP는 해당 연결을 위해 Streamable HTTP와 클라이언트가 실행하는 서브프로세스용 stdio를 정의합니다. 의도된 호출자에 맞게 원격 서버의 인가를 구성하세요.
결론
단일 애플리케이션의 경우, orders.py의 허구 기록을 서비스의 읽기 전용 조회 하나로 대체하고 직접 모드로 시작하세요.
기존 MCP 서비스가 필요하거나 공유 도구 인터페이스가 필요하다면 MCP 경로를 사용하고 동일한 확인을 유지하세요: 올바른 작업, 올바른 레코드, 반환된 데이터로 뒷받침되는 답변.