Files
evening_detective_server/internal/services/mcp_service/service.go
T
2026-08-27 00:11:57 +07:00

264 lines
11 KiB
Go

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