Files
evening_detective_server/internal/services/mcp_service/service.go
T
2026-10-11 16:00:18 +07:00

331 lines
12 KiB
Go

// 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
}