Files
evening_detective_server/internal/services/mcp_service/service.go
T
2026-09-01 01:01:37 +07:00

365 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}