Vibe용 커스텀 MCP 서버(틱택토 게임) 만들기

Vibe용 커스텀 MCP 서버(틱택토 게임) 만들기 (Build a custom MCP server tic-tac-toe game for Vibe)

Mistral로 구동되는 틱택토 MCP 서버를 만들고, Hugging Face Spaces에 배포한 뒤 Vibe에 연결해 채팅 인터페이스에서 바로 게임을 플레이해요. 이 쿡북은 다섯 단계로 진행돼요: 1) 게임 로직과 Flask API 작성, 2) MCP 서버 레이어 추가, 3) Docker로 컨테이너화, 4) Hugging Face Spaces에 배포, 5) Vibe에 연결.

출처: 문서

본문

동작 방식 (How it works)

아키텍처는 Vibe에서 MCP 서버를 거쳐 게임 로직까지 이어지는 체인으로 구성돼요.

Vibe  <-->  SSE Transport  <-->  Your MCP Server  <-->  Game Logic + Mistral
  • Vibe에 "create a new tic-tac-toe game"이라고 입력하면
  • Vibe가 MCP 서버의 create_room() 도구를 호출하고
  • 서버가 게임 세션을 만들어 보드 상태를 반환하며
  • 수를 두면(make_move(position)) 서버가 수를 처리하고 Mistral에 대응 수를 요청해
  • 업데이트된 보드와 AI의 농담(trash talk)이 돌아와요.
  • 승리하거나 무승부가 될 때까지 게임은 계속돼요.

필수 조건 (Prerequisites)

  • Python 3.11+
  • Mistral 계정과 API 키
  • Pro 구독이 있는 Hugging Face 계정 (Docker 컨테이너 배포용)

환경 설정 (Environment setup)

Hugging Face Space 만들기: 프로젝트 파일을 클론된 저장소 안에서 바로 만들 수 있도록 Space를 먼저 생성해요. huggingface.co/new-space로 가서 Space 이름을 정하고(예: tictactoe-mcp-server), SDK를 Docker로 선택하며, 가시성을 Public으로 설정해요 (Vibe 커넥터에 필요).

필요한 환경 변수: Mistral API 키가 필요해요. Studio의 API keys 섹션에서 Connectors access scope 드롭다운에서 Private and shared connectors를 선택하고 새 키를 만들어요. 키를 Space secret으로 추가하면 저장소에 저장하지 않고도 런타임에 사용할 수 있어요. Space의 Settings 탭 → Variables and secrets → New secret에서 이름을 MISTRAL_API_KEY로 설정하고 키 값을 붙여넣어요. Space secret은 write-only라서 저장 후에는 설정 페이지에서 값을 읽을 수 없어요. 런타임에 환경 변수로 주입되므로 코드의 os.getenv('MISTRAL_API_KEY')가 로컬 .env 파일과 동일하게 동작해요. 저장소에 .env 파일을 커밋하지 마세요.

Space 클론: Hugging Face에 클론·푸시하려면 쓰기 권한이 있는 User Access Token이 필요해요. huggingface.co/settings/tokens에서 생성해요. 클론 시 Git이 자격 증명을 물어보면 Hugging Face 사용자 이름과 access token을 비밀번호로 사용하세요. <your-username>은 사용자 이름, <space-name>은 고른 이름으로 바꿔요.

git clone https://huggingface.co/spaces/<your-username>/<space-name>
cd <space-name>

설치: requirements.txt를 만들고 의존성을 한 번에 설치해요.

echo "fastmcp\nflask\nflask-cors\nmistralai\npython-dotenv" > requirements.txt && pip install -r requirements.txt

다음 단계의 모든 파일은 이 디렉터리 안에 작성돼요.

Step 1 — 게임 로직과 Flask API

app.py를 만드세요. 이 파일이 모든 게임 상태를 관리하고, MCP 레이어가 내부적으로 호출하는 네 개의 REST 엔드포인트를 노출해요.

from flask import Flask, request, jsonify
import os
from flask_cors import CORS
import logging
import json
import uuid
import time
from mistralai.client import Mistral

app = Flask(__name__)
CORS(app)

# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Initialize Mistral client
MISTRAL_API_KEY = os.getenv('MISTRAL_API_KEY')
if not MISTRAL_API_KEY:
    logger.error("MISTRAL_API_KEY not configured")
    exit(1)

client = Mistral(api_key=MISTRAL_API_KEY)

class Room:
    def __init__(self, room_id=None):
        self.id = room_id or str(uuid.uuid4())[:8]
        self.board = [''] * 9
        self.current_player = 'X'  # X = human, O = AI
        self.game_status = 'active'  # 'active', 'won', 'draw'
        self.winner = None
        self.chat_history = []
        self.created = time.time()
        self.last_activity = time.time()
        self.moves_count = 0

        # Add welcome message
        self.chat_history.append({
            'sender': 'ai',
            'message': "Hey there! Ready for a game of Tic-Tac-Toe? I'm pretty good at this... 😏 You're X, I'm O. Good luck!",
            'timestamp': time.time()
        })

    def make_move(self, position, player):
        if self.game_status != 'active' or self.board[position] != '':
            return False

        self.board[position] = player
        self.moves_count += 1
        self.last_activity = time.time()

        # Check for winner
        if self.check_winner():
            self.game_status = 'won'
            self.winner = player
        elif self.moves_count == 9:
            self.game_status = 'draw'
        else:
            self.current_player = 'O' if player == 'X' else 'X'

        return True

    def check_winner(self):
        win_patterns = [
            [0, 1, 2], [3, 4, 5], [6, 7, 8],  # rows
            [0, 3, 6], [1, 4, 7], [2, 5, 8],  # columns
            [0, 4, 8], [2, 4, 6]              # diagonals
        ]

        for pattern in win_patterns:
            a, b, c = pattern
            if self.board[a] and self.board[a] == self.board[b] == self.board[c]:
                return True
        return False

    def add_chat_message(self, message, sender):
        self.chat_history.append({
            'sender': sender,
            'message': message,
            'timestamp': time.time()
        })
        self.last_activity = time.time()

    def to_markdown(self):
        # Game header
        markdown = f"# Game Room: {self.id}\n"
        markdown += f"## Status: "

        if self.game_status == 'won':
            winner_name = "You" if self.winner == 'X' else "Mistral AI"
            markdown += f"Game Over - {winner_name} wins! 🎉\n"
        elif self.game_status == 'draw':
            markdown += "Game Over - It's a draw! 🤝\n"
        else:
            turn_name = "Your turn" if self.current_player == 'X' else "Mistral's turn"
            markdown += f"{turn_name} ({self.current_player} to play)\n"

        markdown += f"Moves: {self.moves_count}/9\n\n"

        # Board representation
        markdown += "```\n"
        for i in range(0, 9, 3):
            row = [self.board[i] or '·', self.board[i+1] or '·', self.board[i+2] or '·']
            markdown += f"{row[0]} | {row[1]} | {row[2]}\n"
            if i < 6:
                markdown += "-----------\n"
        markdown += "```\n\n"

        # Chat history (last 5 messages)
        if self.chat_history:
            markdown += "## Recent Chat\n"
            recent_messages = self.chat_history[-5:]
            for msg in recent_messages:
                sender_name = "**You:**" if msg['sender'] == 'user' else "**Mistral AI:**"
                markdown += f"{sender_name} {msg['message']}\n"

        return markdown

    def to_dict(self):
        return {
            'id': self.id,
            'board': self.board,
            'current_player': self.current_player,
            'game_status': self.game_status,
            'winner': self.winner,
            'chat_history': self.chat_history,
            'moves_count': self.moves_count,
            'created': self.created,
            'last_activity': self.last_activity
        }

# In-memory room storage
rooms = {}

# Room management endpoints
@app.route('/rooms', methods=['POST'])
def create_room():
    room = Room()
    rooms[room.id] = room
    logger.info(f"Created room: {room.id}")
    return jsonify({
        'room_id': room.id,
        'status': 'created',
        'room_data': room.to_dict()
    })

@app.route('/rooms/<room_id>', methods=['GET'])
def get_room(room_id):
    if room_id not in rooms:
        return jsonify({'error': 'Room not found'}), 404

    room = rooms[room_id]
    return jsonify({
        'room_id': room_id,
        'room_data': room.to_dict(),
        'markdown': room.to_markdown()
    })

@app.route('/rooms/<room_id>/move', methods=['POST'])
def make_room_move(room_id):
    if room_id not in rooms:
        return jsonify({'error': 'Room not found'}), 404

    room = rooms[room_id]
    data = request.json
    position = data.get('position')

    if position is None or position < 0 or position > 8:
        return jsonify({'error': 'Invalid position'}), 400

    # Make human move
    if not room.make_move(position, 'X'):
        return jsonify({'error': 'Invalid move'}), 400

    # Check if game ended
    if room.game_status != 'active':
        return jsonify({
            'room_data': room.to_dict(),
            'markdown': room.to_markdown(),
            'ai_move': None
        })

    # Get AI move
    try:
        ai_response = get_ai_move_for_room(room)
        if ai_response and 'move' in ai_response:
            # Validate AI move
            ai_move = ai_response['move']
            if 0 <= ai_move <= 8 and room.board[ai_move] == '':
                room.make_move(ai_move, 'O')
                if 'message' in ai_response:
                    room.add_chat_message(ai_response['message'], 'ai')
            else:
                logger.error(f"AI chose invalid move: {ai_move}, board: {room.board}")
                # Fallback to random valid move
                empty_positions = [i for i in range(9) if room.board[i] == '']
                if empty_positions:
                    fallback_move = empty_positions[0]  # Take first available
                    room.make_move(fallback_move, 'O')
                    room.add_chat_message("Oops, had a brain freeze! But I'm still playing! 🤖", 'ai')

        return jsonify({
            'room_data': room.to_dict(),
            'markdown': room.to_markdown(),
            'ai_move': ai_response
        })
    except Exception as e:
        logger.error(f"AI move failed: {e}")
        # Fallback to random valid move instead of failing
        empty_positions = [i for i in range(9) if room.board[i] == '']
        if empty_positions:
            fallback_move = empty_positions[0]
            room.make_move(fallback_move, 'O')
            room.add_chat_message("Technical difficulties, but I'm improvising! 😅", 'ai')

        return jsonify({
            'room_data': room.to_dict(),
            'markdown': room.to_markdown(),
            'ai_move': {'move': fallback_move if empty_positions else None, 'message': 'Technical difficulties!'}
        })

@app.route('/rooms/<room_id>/chat', methods=['POST'])
def room_chat(room_id):
    if room_id not in rooms:
        return jsonify({'error': 'Room not found'}), 404

    room = rooms[room_id]
    data = request.json
    user_message = data.get('message', '')

    if not user_message.strip():
        return jsonify({'error': 'Empty message'}), 400

    # Add user message
    room.add_chat_message(user_message, 'user')

    # Get AI response
    try:
        ai_response = get_ai_chat_for_room(room, user_message)
        room.add_chat_message(ai_response, 'ai')

        return jsonify({
            'room_data': room.to_dict(),
            'markdown': room.to_markdown(),
            'ai_response': ai_response
        })
    except Exception as e:
        logger.error(f"AI chat failed: {e}")
        return jsonify({'error': 'AI chat failed'}), 500

# Helper functions for AI interactions
def get_ai_move_for_room(room):
    board_string = ""
    for i in range(0, 9, 3):
        row = [room.board[i] or ' ', room.board[i+1] or ' ', room.board[i+2] or ' ']
        board_string += f"{row[0]} | {row[1]} | {row[2]}\n"
        if i < 6:
            board_string += "---------\n"

    empty_positions = [i for i in range(9) if room.board[i] == '']

    messages = [
        {
            "role": "system",
            "content": f"""You are a competitive Tic-Tac-Toe AI with personality. You play as 'O' and the human plays as 'X'.

Rules:
1. You MUST choose from these available positions ONLY: {empty_positions}
2. Add a short, witty comment about your move or the game state
3. Be competitive but fun - trash talk, celebrate good moves, react to the situation
4. Keep messages under 50 words
5. Use emojis occasionally

ALWAYS respond with valid JSON in this exact format:
{{"move": <one of {empty_positions}>, "message": "your witty comment"}}

Board positions:
0 | 1 | 2
---------
3 | 4 | 5
---------
6 | 7 | 8"""
        },
        {
            "role": "user",
            "content": f"Current board:\n{board_string}\n\nAvailable positions: {empty_positions}\n\nBoard array: {room.board}"
        }
    ]

    response = client.chat.complete(
        model="mistral-medium-latest",
        messages=messages,
        temperature=0.1,
        response_format={"type": "json_object"}
    )

    return json.loads(response.choices[0].message.content)

def get_ai_chat_for_room(room, user_message):
    board_string = ""
    for i in range(0, 9, 3):
        row = [room.board[i] or ' ', room.board[i+1] or ' ', room.board[i+2] or ' ']
        board_string += f"{row[0]} | {row[1]} | {row[2]}\n"
        if i < 6:
            board_string += "---------\n"

    messages = [
        {
            "role": "system",
            "content": f"""You are a competitive, witty Tic-Tac-Toe AI with personality. You're currently playing a game.

Current board state:
{board_string}

Respond to the human's message with personality - be competitive, funny, encouraging, or trash-talking as appropriate.
Keep responses under 50 words. Use emojis occasionally. Don't make game moves in chat - that happens separately."""
        },
        {
            "role": "user",
            "content": user_message
        }
    ]

    response = client.chat.complete(
        model="mistral-medium-latest",
        messages=messages
    )

    return response.choices[0].message.content

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=7860, debug=True)

Room 클래스는 보드 상태, 수 이력, 채팅 메시지를 추적해요. 각 방은 짧은 UUID를 갖고 생성·수 두기·승자 확인·AI 상대와의 채팅이라는 전체 게임 수명주기를 지원해요.

두 AI 헬퍼 함수는 mistral-medium-latest를 서로 다른 시스템 프롬프트와 함께 사용해요. get_ai_move_for_room은 JSON 모드로 구조화된 수와 농담 메시지를 얻어요 — 프롬프트가 아직 비어 있는 보드 위치를 명시적으로 나열해서 모델이 점유된 칸을 고르지 않게 해요. get_ai_chat_for_room은 게임 중 자유 형식 대화를 처리해요.

네 개의 Flask 엔드포인트가 이 로직을 REST API로 노출해요:

Endpoint Method Purpose
/rooms POST 새 게임 방 만들기
/rooms/<room_id> GET 방 상태와 보드 가져오기
/rooms/<room_id>/move POST 수 두기 (AI 대응 수 트리거)
/rooms/<room_id>/chat POST AI 상대와 채팅

Step 2 — MCP 서버 레이어

mcp_server.py를 만드세요. 게임 로직을 Vibe가 SSE로 발견·호출할 수 있는 MCP 도구로 감싸요.

import os
import asyncio
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from app import Room, rooms, get_ai_move_for_room, get_ai_chat_for_room

load_dotenv()

# --- MCP Server Setup ---
mcp = FastMCP(
    name="TicTacToeRooms",
    host="0.0.0.0",
    port=7860,
)

# --- Global state for current user session ---
current_session = {
    'active_room_id': None,
    'username': 'MCPPlayer'
}

# --- MCP Tools ---

@mcp.tool()
def create_room() -> dict:
    """
    Create a new tic-tac-toe game room.
    Returns:
        dict: Room information including room ID and initial markdown state
    """
    global current_session
    try:
        room = Room()
        rooms[room.id] = room

        current_session['active_room_id'] = room.id

        return {
            "status": "success",
            "room_id": room.id,
            "message": f"Created new tic-tac-toe room: {room.id}",
            "markdown_state": room.to_markdown(),
            "instructions": "Use make_move() to play or send_chat() to talk with Mistral AI",
            "game_info": {
                "your_symbol": "X",
                "ai_symbol": "O",
                "board_positions": "0-8 (left to right, top to bottom)"
            }
        }
    except Exception as e:
        return {
            "status": "error",
            "message": f"Failed to create room: {str(e)}"
        }

@mcp.tool()
def get_room_state(room_id: str = None) -> dict:
    """
    Get the current state of a tic-tac-toe room in markdown format.
    Args:
        room_id (str, optional): Room ID to check (uses current active room if not provided)
    Returns:
        dict: Current room state with markdown representation
    """
    global current_session
    try:
        # Use provided room_id or current active room
        target_room_id = room_id or current_session.get('active_room_id')

        if not target_room_id:
            return {
                "status": "error",
                "message": "No active room. Create a room first using create_room()."
            }

        if target_room_id not in rooms:
            return {
                "status": "error",
                "message": f"Room {target_room_id} not found. It may have been cleaned up."
            }

        room = rooms[target_room_id]

        return {
            "status": "success",
            "room_id": target_room_id,
            "markdown_state": room.to_markdown(),
            "game_status": room.game_status,
            "current_player": room.current_player,
            "moves_made": room.moves_count,
            "your_turn": room.current_player == 'X' and room.game_status == 'active'
        }
    except Exception as e:
        return {
            "status": "error",
            "message": f"Failed to get room state: {str(e)}"
        }

@mcp.tool()
async def make_move(position: int, room_id: str = None) -> dict:
    """
    Make a move in a tic-tac-toe game. This will also trigger the AI's response move.
    Args:
        position (int): Board position (0-8, left to right, top to bottom)
        room_id (str, optional): Room ID (uses current active room if not provided)
    Returns:
        dict: Result of your move and the AI's response with updated game state
    """
    global current_session
    try:
        # Use provided room_id or current active room
        target_room_id = room_id or current_session.get('active_room_id')

        if not target_room_id:
            return {
                "status": "error",
                "message": "No active room. Create a room first using create_room()."
            }

        if target_room_id not in rooms:
            return {
                "status": "error",
                "message": f"Room {target_room_id} not found."
            }

        room = rooms[target_room_id]

        # Validate move
        if position < 0 or position > 8:
            return {
                "status": "error",
                "message": "Invalid position. Use 0-8 (left to right, top to bottom)."
            }

        if room.game_status != 'active':
            return {
                "status": "error",
                "message": f"Game is over. Status: {room.game_status}",
                "markdown_state": room.to_markdown()
            }

        if room.current_player != 'X':
            return {
                "status": "error",
                "message": "It's not your turn! Wait for AI to move.",
                "markdown_state": room.to_markdown()
            }

        # Make human move
        if not room.make_move(position, 'X'):
            return {
                "status": "error",
                "message": f"Invalid move! Position {position} may already be occupied.",
                "markdown_state": room.to_markdown()
            }

        result_message = f"✅ You played X at position {position}\n\n"

        # Check if game ended after human move
        if room.game_status != 'active':
            if room.winner == 'X':
                result_message += "🎉 Congratulations! You won!\n\n"
            else:
                result_message += "🤝 It's a draw!\n\n"

            result_message += room.to_markdown()
            return {
                "status": "success",
                "message": result_message,
                "game_over": True,
                "winner": room.winner
            }

        # Get AI move
        try:
            ai_response = get_ai_move_for_room(room)
            if ai_response and 'move' in ai_response:
                # Validate AI move
                ai_move = ai_response['move']
                if 0 <= ai_move <= 8 and room.board[ai_move] == '':
                    room.make_move(ai_move, 'O')
                    if 'message' in ai_response:
                        room.add_chat_message(ai_response['message'], 'ai')
                else:
                    # Fallback to first available position
                    empty_positions = [i for i in range(9) if room.board[i] == '']
                    if empty_positions:
                        ai_move = empty_positions[0]
                        ai_response['move'] = ai_move
                        room.make_move(ai_move, 'O')
                        room.add_chat_message("Oops, had a brain freeze! But I'm still playing!", 'ai')

                result_message += f"🤖 Mistral AI played O at position {ai_response['move']}\n"
                if 'message' in ai_response:
                    result_message += f"💬 Mistral says: \"{ai_response['message']}\"\n\n"
                else:
                    result_message += "\n"

                # Check if AI won
                if room.game_status == 'won' and room.winner == 'O':
                    result_message += "💀 Mistral AI wins this round!\n\n"
                elif room.game_status == 'draw':
                    result_message += "🤝 It's a draw!\n\n"
            else:
                result_message += "⚠️ AI move failed, but you can continue\n\n"

        except Exception as e:
            result_message += f"⚠️ AI move error: {str(e)}\n\n"

        result_message += room.to_markdown()

        return {
            "status": "success",
            "message": result_message,
            "game_over": room.game_status != 'active',
            "winner": room.winner if room.game_status == 'won' else None,
            "your_turn": room.current_player == 'X' and room.game_status == 'active'
        }

    except Exception as e:
        return {
            "status": "error",
            "message": f"Failed to make move: {str(e)}"
        }

@mcp.tool()
async def send_chat(message: str, room_id: str = None) -> dict:
    """
    Send a chat message to Mistral AI in the current game room.
    Args:
        message (str): Your message to send to the AI
        room_id (str, optional): Room ID (uses current active room if not provided)
    Returns:
        dict: Your message and the AI's response with updated room state
    """
    global current_session
    try:
        # Use provided room_id or current active room
        target_room_id = room_id or current_session.get('active_room_id')

        if not target_room_id:
            return {
                "status": "error",
                "message": "No active room. Create a room first using create_room()."
            }

        if target_room_id not in rooms:
            return {
                "status": "error",
                "message": f"Room {target_room_id} not found."
            }

        room = rooms[target_room_id]

        # Add user message
        room.add_chat_message(message, 'user')

        # Get AI response
        ai_response = get_ai_chat_for_room(room, message)
        room.add_chat_message(ai_response, 'ai')

        result_message = f"💬 **You:** {message}\n💬 **Mistral AI:** {ai_response}\n\n"
        result_message += room.to_markdown()

        return {
            "status": "success",
            "message": result_message,
            "your_message": message,
            "ai_response": ai_response
        }

    except Exception as e:
        return {
            "status": "error",
            "message": f"Failed to send chat: {str(e)}"
        }

@mcp.tool()
def list_rooms() -> dict:
    """
    List all active tic-tac-toe game rooms.
    Returns:
        dict: List of active rooms with their status
    """
    try:
        if not rooms:
            return {
                "status": "success",
                "message": "No active rooms. Use create_room() to start a new game!",
                "active_rooms": [],
                "count": 0
            }

        room_list = []
        for room_id, room in rooms.items():
            room_info = {
                "room_id": room_id,
                "game_status": room.game_status,
                "current_player": room.current_player,
                "moves_count": room.moves_count,
                "winner": room.winner,
                "is_your_turn": room.current_player == 'X' and room.game_status == 'active',
                "is_active": current_session.get('active_room_id') == room_id
            }
            room_list.append(room_info)

        active_room_id = current_session.get('active_room_id')
        message = f"Found {len(room_list)} active rooms."
        if active_room_id:
            message += f" Current active room: {active_room_id}"

        return {
            "status": "success",
            "message": message,
            "active_rooms": room_list,
            "count": len(room_list),
            "current_active_room": active_room_id
        }
    except Exception as e:
        return {
            "status": "error",
            "message": f"Failed to list rooms: {str(e)}"
        }

@mcp.tool()
def get_help() -> dict:
    """
    Get help information about playing tic-tac-toe.
    Returns:
        dict: Instructions and tips for playing the game
    """
    return {
        "status": "success",
        "message": "Tic-Tac-Toe Game Help",
        "instructions": {
            "how_to_play": [
                "1. Create a new game room with create_room()",
                "2. Make moves using make_move(position) where position is 0-8",
                "3. Chat with Mistral AI using send_chat('your message')",
                "4. Check game state anytime with get_room_state()"
            ],
            "board_layout": {
                "description": "Board positions (0-8):",
                "layout": [
                    "0 | 1 | 2",
                    "---------",
                    "3 | 4 | 5",
                    "---------",
                    "6 | 7 | 8"
                ]
            },
            "symbols": {
                "you": "X (you go first)",
                "ai": "O (Mistral AI)"
            },
            "tips": [
                "The AI has personality and will trash talk!",
                "You can have multiple rooms active at once",
                "Use list_rooms() to see all your games"
            ]
        },
        "available_commands": [
            "create_room() - Start a new game",
            "make_move(position) - Make your move (0-8)",
            "send_chat('message') - Chat with AI",
            "get_room_state() - Check current game",
            "list_rooms() - See all active games",
            "get_help() - Show this help"
        ]
    }

# --- Server Execution ---
if __name__ == "__main__":
    print(f"Tic-Tac-Toe Rooms MCP Server starting on port 7860...")
    print("Available game features:")
    print("- Create multiple game rooms")
    print("- Play against Mistral AI with personality")
    print("- Real-time chat with the AI")
    print("- Markdown state representation")
    print("- Room management")
    print()
    print("MCP Tools available:")
    print("- create_room()")
    print("- make_move(position)")
    print("- send_chat(message)")
    print("- get_room_state()")
    print("- list_rooms()")
    print("- get_help()")
    print()
    print("This MCP server is ready for Vibe integration!")
    print("Running Tic-Tac-Toe MCP server with SSE transport")
    mcp.run(transport="sse")

FastMCP가 SSE 전송, 도구 발견, 스키마 생성을 처리해요. @mcp.tool() 데코레이터가 각 함수를 호출 가능한 도구로 노출해요. Vibe는 커넥터를 등록하면 이 도구들을 자동으로 발견해요.

여섯 개 도구와 역할:

Tool Purpose
create_room() 새 게임 세션 시작
get_room_state() 현재 보드와 상태 확인
make_move(position) 수 두기 (AI 대응 수 트리거)
send_chat(message) 게임 중 AI와 채팅
list_rooms() 모든 활성 게임 보기
get_help() 지시문과 보드 레이아웃 얻기

Step 3 — Docker로 컨테이너화

배포를 위해 모든 것을 패키징하는 Dockerfile을 만들어요.

FROM python:3.11-slim

# Set working directory
WORKDIR /app

# Copy requirements first for better caching
COPY requirements.txt .

# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Copy all application files
COPY . .

# Expose port 7860 for Hugging Face Spaces
EXPOSE 7860

# Create startup script that runs MCP server only (simpler approach)
RUN echo '#!/bin/bash\n\
echo "Starting Tic-Tac-Toe MCP Server on port 7860..."\n\
echo "This is the MCP server for Vibe integration"\n\
echo "MCP Tools: create_room, make_move, send_chat, get_room_state, list_rooms"\n\
python mcp_server.py' > start.sh && chmod +x start.sh

CMD ["./start.sh"]

컨테이너는 app.py를 임포트하는 mcp_server.py를 실행해서, Hugging Face Spaces의 기본 포트인 7860에서 SSE 엔드포인트를 노출해요.

Step 4 — Hugging Face Spaces에 배포

Step 1에서 클론한 Space 저장소에 파일을 푸시해요.

git add app.py mcp_server.py requirements.txt Dockerfile
git commit -m "Add tic-tac-toe MCP server"
git push

Space의 Logs 탭에서 빌드를 모니터링해요. 상태가 Running이 되면 SSE 엔드포인트가 다음 주소에서 살아있어요.

https://<your-username>-<space-name>.hf.space/sse

Step 5 — Vibe에 연결

MCP 서버를 Studio에서 또는 프로그래밍 방식으로 등록할 수 있어요.

Studio에서 MCP 서버 등록

  • Connectors 탭으로 이동해 Add Connector를 클릭해요.
  • 모달에서 Custom MCP Server 탭을 클릭해요.
  • 커넥터 제목을 Tic Tac Toe처럼 지정해요.
  • Space URL을 올바른 형식으로 추가해요: https://<your-username>-<space-name>.hf.space/sse
  • 선택적으로 "Play tic-tac-toe against Mistral" 같은 설명을 추가해요.

Mistral API로 프로그래밍 방식 등록

배포된 MCP 서버를 Mistral API로 Vibe 커넥터로 등록해요.

from mistralai.client import Mistral
import os

client = Mistral(api_key=os.getenv("MISTRAL_API_KEY"))

connector = client.beta.connectors.create(
    name="tictactoe-mcp",
    url="https://<your-username>-<space-name>.hf.space/sse",
    description="Play tic-tac-toe against Mistral",
    visibility="private",
)

print(f"Connector ID: {connector.id}")
print(f"Connector name: {connector.name}")

# This MCP server doesn't require authentication,
# but you must add credentials to enable the server on your project.
await client.beta.connectors.create_or_update_user_credentials_async(
    connector_id_or_name="connector.name",
    name=f"new-credential",
    credentials={'headers':{}},
    is_default=True,
)

URL을 실제 Hugging Face Spaces 엔드포인트로 바꿔요.

커넥터 검증

인증되면 Vibe가 서버에 도달할 수 있는지 확인해요.

connector_info = client.beta.connectors.get(connector_id=connector.id)
print(f"Status: {connector_info.status}")
print(f"Tools: {connector_info.tools}")

여섯 도구가 모두 나열되면 돼요.

Vibe에서 플레이

  • Vibe를 열고 새 대화에서 tictactoe-mcp 커넥터를 활성화해요.
  • "Let's play tic-tac-toe"라고 입력하면 Vibe가 MCP 도구를 호출하는 걸 볼 수 있어요.

정리 (Clean up)

불필요한 리소스 사용을 피하려면 사용이 끝나면 커넥터와 Hugging Face Space를 삭제해요. Studio에서 커넥터를 클릭해 세 점을 선택하고 Delete를 고르거나 프로그래밍 방식으로 지울 수 있어요.

client.beta.connectors.delete(connector_id=connector.id)

Space는 Settings 탭 맨 아래의 Delete this Space로 삭제해요.

요약 (Summary)

이 쿡북은 틱택토 MCP 서버를 처음부터 만들고 Vibe에 재생 가능한 커넥터로 연결하는 과정을 다뤘어요. 만든 것: 방 관리와 AI 수가 포함된 Flask REST API, FastMCP로 SSE 위에 6개 도구를 노출하는 MCP 서버 레이어, Hugging Face Spaces에 배포된 Docker 컨테이너, Connectors API로 등록된 Vibe 커넥터. 사용한 Mistral 기능: 구조화된 AI 수를 위한 JSON mode의 Chat Completions API, 게임 중 자유 형식 대화용 Chat Completions API, MCP 서버 등록용 Connectors API.

더 알아보기 (Learn more)