// 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 SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []game_service.Answer) 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) по кодам точек сценария и отвечайте на вопросы дела (submit_answers)."), ) 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, ) srv.AddTool( mcp.NewToolWithRawSchema( "submit_answers", "Отправить ответы команды на вопросы дела (список вопросов — в истории команды через get_team_story). Работает, пока игра идёт; после завершения игры ответы изменить нельзя. После успешной отправки возвращает обновлённую историю команды. Команда идентифицируется паролем.", json.RawMessage(`{ "type": "object", "properties": { "team_id": {"type": "number", "description": "ID команды"}, "password": {"type": "string", "description": "Пароль команды"}, "answers": { "type": "array", "description": "Ответы команды на вопросы дела; переданные коды перезаписываются, пустая строка очищает ответ", "items": { "type": "object", "properties": { "questionCode": {"type": "string", "description": "Код вопроса из истории команды"}, "answer": {"type": "string", "description": "Текст ответа команды"} }, "required": ["questionCode", "answer"] } } }, "required": ["team_id", "password", "answers"] }`), ), s.handleSubmitAnswers, ) 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 } // submitAnswersArgs — разобранные аргументы инструмента submit_answers. type submitAnswersArgs struct { teamID int64 password string answers []game_service.Answer } // parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers: // answers — массив объектов {questionCode, answer} (JSON-массив приходит // как []any с map[string]any элементами). Ключ questionCode совпадает с // REST JSON (camelCase) — единый формат для клиентов. func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) { teamID, ok := argInt64(args["team_id"]) if !ok { return submitAnswersArgs{}, fmt.Errorf("требуется team_id (число)") } password, _ := args["password"].(string) if password == "" { return submitAnswersArgs{}, fmt.Errorf("требуется password") } rawAnswers, ok := args["answers"].([]any) if !ok { return submitAnswersArgs{}, fmt.Errorf("требуется answers (массив ответов)") } answers := make([]game_service.Answer, 0, len(rawAnswers)) for i, raw := range rawAnswers { item, ok := raw.(map[string]any) if !ok { return submitAnswersArgs{}, fmt.Errorf("answers[%d]: ожидается объект {questionCode, answer}", i) } questionCode, _ := item["questionCode"].(string) answerText, _ := item["answer"].(string) if questionCode == "" { return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i) } answers = append(answers, game_service.Answer{ QuestionCode: questionCode, Text: answerText, }) } return submitAnswersArgs{ teamID: teamID, password: password, answers: answers, }, nil } func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args, err := parseSubmitAnswersArgs(req.GetArguments()) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil } ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout) defer cancel() if err := s.game.SubmitTeamAnswers(ctx, int(args.teamID), args.password, args.answers); err != nil { return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil } story, game, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil } jsonBody, err := storyJSON(story, game) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %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 } }