// Package mcp_service exposes game operations through MCP. package mcp_service import ( "context" "encoding/json" "evening_detective_server/internal/services/game_model" "fmt" "net/url" "strings" "time" "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/repos/actions_repo" "evening_detective_server/internal/services/answer_service" "github.com/google/uuid" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) const defaultCallTimeout = 10 * time.Second type IGamePlayer interface { GetTeamActions(ctx context.Context, teamID string, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) AddTeamAction(ctx context.Context, teamID string, password, actionCode string, viaMCP bool) error } type IAnswerSubmitter interface { SubmitTeamAnswers(ctx context.Context, teamID string, password string, answers []answer_service.Answer) error } type MCPService struct { game IGamePlayer answers IAnswerSubmitter } // NewMCPService creates an MCP service. func NewMCPService(game IGamePlayer, answers IAnswerSubmitter) *MCPService { return &MCPService{game: game, answers: answers} } // Server returns the underlying MCP server. 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/019a1234-5678-7abc-8def-0123456789ab?password=team-pass-1")), ), s.handleConnect, ) srv.AddTool( mcp.NewTool( "get_team_story", mcp.WithDescription("Получить текущую историю команды: введение сценария (текст и аудио), видимые точки сценария (текст, двери, улики) и информацию об игре. Команда идентифицируется паролем."), mcp.WithString("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.WithString("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": "string", "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 } type connectArgs struct { teamID string password string } 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 := idPart if !validTeamID(teamID) { return connectArgs{}, fmt.Errorf("невалидная ссылка: id команды %q — не UUID", idPart) } password := u.Query().Get("password") if password == "" { return connectArgs{}, fmt.Errorf("невалидная ссылка: отсутствует query-параметр password") } return connectArgs{teamID: teamID, password: password}, nil } type gameResponse struct { ID int `json:"id"` Name string `json:"name"` Status string `json:"status"` } type storyResponse struct { Story *storytelling.Story `json:"story"` Game *gameResponse `json:"game"` } func storyJSON(story *storytelling.Story, game *game_model.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, 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( "Подключено к команде %s. Пароль: %s. История команды:\n%s", args.teamID, args.password, jsonBody, )), nil } func (s *MCPService) handleGetTeamStory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { teamID, ok := req.GetArguments()["team_id"].(string) if !ok || !validTeamID(teamID) { return mcp.NewToolResultError("get_team_story: требуется team_id (UUID)"), 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, 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 := args["team_id"].(string) if !ok || !validTeamID(teamID) { return mcp.NewToolResultError("make_move: требуется team_id (UUID)"), 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, teamID, password, code, true); err != nil { return mcp.NewToolResultError(fmt.Sprintf("make_move: %v", err)), nil } story, game, _, err := s.game.GetTeamActions(ctx, 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 } type submitAnswersArgs struct { teamID string password string answers []answer_service.Answer } func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) { teamID, ok := args["team_id"].(string) if !ok || !validTeamID(teamID) { return submitAnswersArgs{}, fmt.Errorf("требуется team_id (UUID)") } 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([]answer_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, answer_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.answers.SubmitTeamAnswers(ctx, 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, 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 } func validTeamID(id string) bool { parsed, err := uuid.Parse(id) return err == nil && parsed != uuid.Nil && parsed.String() == id }