generated from VLADIMIR/template
264 lines
11 KiB
Go
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
|
|
}
|
|
}
|