// Package mcp_service собирает MCP-сервер (Model Context Protocol) для // игры «Вечерний детектив»: инструменты MCP вызывают игровые сервисы // напрямую (через интерфейс GamePlayer), без HTTP-прослойки. MCP-сервер // монтируется в основной процесс как HTTP-эндпоинт /mcp (streamable HTTP) // на REST-gateway. package mcp_service import ( "context" "encoding/json" "fmt" "net/url" "strconv" "strings" "time" "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/services/game_service" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) // defaultCallTimeout — таймаут на выполнение одного инструмента: // защита от зависших запросов к БД (10s — как у прежнего HTTP-прокси). const defaultCallTimeout = 10 * time.Second // GamePlayer — граница доступа к игровым сервисам, реализуемая // *game_service.GameService; интерфейс позволяет тестировать сервис // без БД. type GamePlayer interface { GetTeamActions(ctx context.Context, teamId int, password string) (*storytelling.Story, *game_service.Game, error) AddTeamAction(ctx context.Context, teamId int, password, actionCode string) error } // MCPService собирает MCP-сервер с игровыми инструментами. Сервис не // хранит состояние между вызовами: каждый вызов получает id команды и // пароль явно — либо из ссылки на игру через инструмент connect, либо // напрямую. type MCPService struct { game GamePlayer } // NewMCPService создаёт сервис поверх игровых сервисов. func NewMCPService(game GamePlayer) *MCPService { return &MCPService{game: game} } // Server собирает и возвращает MCP-сервер с зарегистрированными // инструментами. func (s *MCPService) Server() *server.MCPServer { srv := server.NewMCPServer( "evening-detective-mcp", "0.1.0", server.WithInstructions("Инструменты для игры «Вечерний детектив»: подключитесь к игре по ссылке (connect) — организатор выдаёт ссылку вида /team-story/{id}?password=..., — затем смотрите историю команды (get_team_story) и делайте ходы (make_move) по кодам точек сценария."), ) srv.AddTool( mcp.NewTool( "connect", mcp.WithDescription("Подключиться к игре по ссылке вида /team-story/{id}?password=... (принимаются относительные и абсолютные ссылки, в т.ч. с любым хостом — запросы по ним не выполняются, из ссылки берутся только id команды и пароль). Извлекает id команды и пароль и сразу возвращает текущую историю команды вместе с этими параметрами для последующих вызовов get_team_story/make_move."), mcp.WithString("url", mcp.Required(), mcp.Description("Ссылка на игру, например /team-story/10?password=team-pass-1")), ), s.handleConnect, ) srv.AddTool( mcp.NewTool( "get_team_story", mcp.WithDescription("Получить текущую историю команды: видимые точки сценария (текст, двери, улики) и информацию об игре. Команда идентифицируется паролем."), mcp.WithNumber("team_id", mcp.Required(), mcp.Description("ID команды")), mcp.WithString("password", mcp.Required(), mcp.Description("Пароль команды")), ), s.handleGetTeamStory, ) srv.AddTool( mcp.NewTool( "make_move", mcp.WithDescription("Сделать ход команды — перейти в точку сценария по её коду. После успешного хода возвращает обновлённую историю команды. Команда идентифицируется паролем."), mcp.WithNumber("team_id", mcp.Required(), mcp.Description("ID команды")), mcp.WithString("password", mcp.Required(), mcp.Description("Пароль команды")), mcp.WithString("code", mcp.Required(), mcp.Description("Код точки сценария, в которую идёт команда")), ), s.handleMakeMove, ) return srv } // connectArgs — результат разбора ссылки на игру. type connectArgs struct { teamID int64 password string } // parseConnectURL разбирает ссылку на игру вида /team-story/{id}?password=... // и возвращает id команды и пароль. // // Ссылка используется ТОЛЬКО как носитель id и пароля: MCP-сервер не // выполняет HTTP-запросов по ней, поэтому схема и хост не проверяются // (протокол-относительная ссылка //host/team-story/{id}?password=... // принимается, «mailto:...» отклоняется на проверке пути). func parseConnectURL(rawURL string) (connectArgs, error) { if strings.TrimSpace(rawURL) == "" { return connectArgs{}, fmt.Errorf("требуется url") } u, err := url.Parse(rawURL) if err != nil { return connectArgs{}, fmt.Errorf("невалидная ссылка: %w", err) } const prefix = "/team-story/" if !strings.HasPrefix(u.Path, prefix) { return connectArgs{}, fmt.Errorf("невалидная ссылка: путь должен быть %s{id}", prefix) } idPart := strings.TrimPrefix(u.Path, prefix) if idPart == "" { return connectArgs{}, fmt.Errorf("невалидная ссылка: отсутствует id команды в пути %q", u.Path) } teamID, err := strconv.ParseInt(idPart, 10, 64) if err != nil || teamID < 0 { return connectArgs{}, fmt.Errorf("невалидная ссылка: id команды %q — не целое неотрицательное число", idPart) } password := u.Query().Get("password") if password == "" { return connectArgs{}, fmt.Errorf("невалидная ссылка: отсутствует query-параметр password") } return connectArgs{teamID: teamID, password: password}, nil } // gameResponse — компактное описание игры для ответа инструментов // (game_service.Game JSON-тегов не имеет). type gameResponse struct { ID int `json:"id"` Name string `json:"name"` Status string `json:"status"` } // storyResponse — JSON-ответ инструментов: история команды + инфо об игре. type storyResponse struct { Story *storytelling.Story `json:"story"` Game *gameResponse `json:"game"` } // storyJSON сериализует историю команды и игру в JSON. func storyJSON(story *storytelling.Story, game *game_service.Game) (string, error) { resp := storyResponse{ Story: story, Game: &gameResponse{ ID: game.ID, Name: game.Name, Status: game.Status, }, } encoded, err := json.Marshal(resp) if err != nil { return "", err } return string(encoded), nil } func (s *MCPService) handleConnect(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { rawURL, _ := req.GetArguments()["url"].(string) args, err := parseConnectURL(rawURL) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("connect: %v", err)), nil } ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() story, game, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("connect: %v", err)), nil } jsonBody, err := storyJSON(story, game) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("connect: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf( "Подключено к команде %d. Пароль: %s. История команды:\n%s", args.teamID, args.password, jsonBody, )), nil } func (s *MCPService) handleGetTeamStory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { teamID, ok := argInt64(req.GetArguments()["team_id"]) if !ok { return mcp.NewToolResultError("get_team_story: требуется team_id (число)"), nil } password, _ := req.GetArguments()["password"].(string) if password == "" { return mcp.NewToolResultError("get_team_story: требуется password"), nil } ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() story, game, err := s.game.GetTeamActions(ctx, int(teamID), password) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("get_team_story: %v", err)), nil } jsonBody, err := storyJSON(story, game) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("get_team_story: %v", err)), nil } return mcp.NewToolResultText(jsonBody), nil } func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() teamID, ok := argInt64(args["team_id"]) if !ok { return mcp.NewToolResultError("make_move: требуется team_id (число)"), nil } password, _ := args["password"].(string) code, _ := args["code"].(string) if password == "" || code == "" { return mcp.NewToolResultError("make_move: требуется password и code"), nil } ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() if err := s.game.AddTeamAction(ctx, int(teamID), password, code); err != nil { return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil } story, game, err := s.game.GetTeamActions(ctx, int(teamID), password) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil } jsonBody, err := storyJSON(story, game) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil } return mcp.NewToolResultText(jsonBody), nil } // argInt64 достаёт int64 из аргумента инструмента: числа приходят как // float64 (JSON), строки — как string. func argInt64(v any) (int64, bool) { switch n := v.(type) { case float64: return int64(n), true case string: id, err := strconv.ParseInt(n, 10, 64) return id, err == nil default: return 0, false } }