From 1acccd6c40b06bcb86ade8d7796fb732d07aad28 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Thu, 27 Aug 2026 00:11:57 +0700 Subject: [PATCH] add mcp --- README.md | 35 ++ cmd/evening_detective_server/gateway_test.go | 17 +- cmd/evening_detective_server/main.go | 27 +- go.mod | 8 +- internal/services/mcp_service/http_test.go | 87 ++++ internal/services/mcp_service/service.go | 263 ++++++++++++ internal/services/mcp_service/service_test.go | 395 ++++++++++++++++++ 7 files changed, 821 insertions(+), 11 deletions(-) create mode 100644 internal/services/mcp_service/http_test.go create mode 100644 internal/services/mcp_service/service.go create mode 100644 internal/services/mcp_service/service_test.go diff --git a/README.md b/README.md index fa95317..bfe614a 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,38 @@ make test - `preset.yml` — отображаемое имя и описание; - `skills/software-law/` — база знаний: `SKILL.md` (правила консультаций) и `references/` (лицензии, авторские права, ПДн, договоры). +## MCP-сервер (встроен в основной сервис) + +MCP-сервер (Model Context Protocol) встроен в основной бинарь и доступен как HTTP-эндпоинт `/api/mcp` (streamable HTTP) на REST-gateway — чтобы LLM-клиент (Claude Desktop, IDE с MCP-поддержкой и т.п.) мог играть в «Вечерний детектив». Инструменты вызывают игровые сервисы напрямую, отдельный процесс не нужен. + +Инструменты: + +| Инструмент | Назначение | +|---|---| +| `connect` | Подключиться к игре по ссылке `/team-story/{id}?password=...`; возвращает id команды, пароль и текущую историю | +| `get_team_story` | История команды: точки, двери, улики + инфо об игре (по паролю команды, без авторизации) | +| `make_move` | Ход команды в точку по её коду; возвращает обновлённую историю (по паролю команды, без авторизации) | + +Ссылку на игру (`/team-story/{id}?password=...`) выдаёт команде организатор личным каналом — например, вместе с паролем. Принимаются относительные и абсолютные ссылки (в т.ч. с любым хостом): HTTP-запросы по ним не выполняются, из ссылки берутся только id команды и пароль. Игроку авторизация не нужна: команда идентифицируется паролем из ссылки. + +Запуск — обычный запуск основного сервиса: + +```shell +make run +``` + +MCP-эндпоинт: `http://localhost:8090/api/mcp`. ВНИМАНИЕ: REST-gateway слушает `:8090` (gRPC — `:8080`), а `docker-compose.yml` публикует наружу только `8080` — для доступа к `/api/mcp` извне нужна публикация порта 8090 в compose либо проксирование пути `/api/mcp` через reverse-proxy (nginx/caddy). Эндпоинт публичный (как и REST `/api/teams/{id}/story`); секрет — пароль команды. + +Подключение к MCP-клиенту (пример для Claude Desktop / аналогов): + +```json +{ + "mcpServers": { + "evening-detective": { + "type": "http", + "url": "http://localhost:8090/api/mcp" + } + } +} +``` + diff --git a/cmd/evening_detective_server/gateway_test.go b/cmd/evening_detective_server/gateway_test.go index 266e63e..a156b4d 100644 --- a/cmd/evening_detective_server/gateway_test.go +++ b/cmd/evening_detective_server/gateway_test.go @@ -137,16 +137,23 @@ func TestGatewayDownloadZipWithContentDisposition(t *testing.T) { } } -// TestCORSExposesContentDisposition — фронт через fetch должен читать -// Content-Disposition (имя файла архива) из ответа. -func TestCORSExposesContentDisposition(t *testing.T) { +// TestCORSHeaders — фронт через fetch должен читать Content-Disposition +// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки +// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose. +func TestCORSHeaders(t *testing.T) { h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/scenarios/1/archive", nil)) - if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "Content-Disposition" { - t.Errorf("Access-Control-Expose-Headers = %q, want Content-Disposition", got) + if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version" { + t.Errorf("Access-Control-Expose-Headers = %q, want Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version", got) + } + allow := rec.Header().Get("Access-Control-Allow-Headers") + for _, want := range []string{"Mcp-Session-Id", "Mcp-Protocol-Version", "Last-Event-ID"} { + if !strings.Contains(allow, want) { + t.Errorf("Access-Control-Allow-Headers = %q, не содержит %q", allow, want) + } } } diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index 6940b25..bb36335 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -21,6 +21,7 @@ import ( "evening_detective_server/internal/repos/users_repo" "evening_detective_server/internal/services/file_service" "evening_detective_server/internal/services/game_service" + "evening_detective_server/internal/services/mcp_service" "evening_detective_server/internal/services/scenarios_service" "evening_detective_server/internal/services/ui_service" "evening_detective_server/internal/services/users_service" @@ -35,6 +36,7 @@ import ( "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/jackc/pgx/v5/pgxpool" "github.com/joho/godotenv" + "github.com/mark3labs/mcp-go/server" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/encoding/protojson" @@ -231,6 +233,20 @@ func main() { // проверки в хендлере не спасут от OOM. mainMux.Handle("/api/", limitArchiveUploadBody(gwmux)) + // MCP-сервер (Model Context Protocol): эндпоинт /api/mcp (streamable HTTP). + // Инструменты MCP вызывают игровые сервисы напрямую (см. mcp_service). + // WithDisableLocalhostProtection: mcp-go отклоняет loopback-запросы с + // не-localhost Host-заголовком (защита от DNS-rebinding), что ломает + // работу за reverse-proxy (nginx/caddy) — контур деплоя проекта. + // Эндпоинт публичный, секрет — пароль команды (аналогично публичному + // REST /api/teams/{id}/story). Путь /api/mcp длиннее /api/, поэтому + // http.ServeMux отдаёт MCP-запросы этому хендлеру, а не gateway. + mcpHTTP := server.NewStreamableHTTPServer( + mcp_service.NewMCPService(gameService).Server(), + server.WithDisableLocalhostProtection(true), + ) + mainMux.Handle("/api/mcp", mcpHTTP) + gwServer := &http.Server{ Addr: ":8090", Handler: cors(mainMux), @@ -245,9 +261,14 @@ func cors(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE") - w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password") - // Content-Disposition нужен фронту, чтобы прочитать имя файла архива. - w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") + // Mcp-Session-Id / Mcp-Protocol-Version / Last-Event-ID — заголовки + // streamable HTTP MCP-эндпоинта /api/mcp (сессия, версия протокола, + // SSE-реконнект); без них браузерные MCP-клиенты заблокируются preflight. + w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID") + // Content-Disposition нужен фронту, чтобы прочитать имя файла архива; + // Mcp-Session-Id / Mcp-Protocol-Version — браузерному MCP-клиенту, + // чтобы читать сессионные заголовки ответов /api/mcp. + w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version") if r.Method == "OPTIONS" { return } diff --git a/go.mod b/go.mod index f561cc3..2cc43d9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 + github.com/mark3labs/mcp-go v0.58.0 github.com/stretchr/testify v1.12.0 google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 google.golang.org/grpc v1.83.0 @@ -30,11 +31,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect github.com/aws/smithy-go v1.27.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/vearutop/statigz v1.4.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/services/mcp_service/http_test.go b/internal/services/mcp_service/http_test.go new file mode 100644 index 0000000..a6e81c0 --- /dev/null +++ b/internal/services/mcp_service/http_test.go @@ -0,0 +1,87 @@ +package mcp_service + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + "time" + + "evening_detective_server/internal/modules/storytelling" + "evening_detective_server/internal/services/game_service" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// Интеграционный тест HTTP-«клея»: MCP-сервер монтируется как +// streamable HTTP (эндпоинт /api/mcp) и вызывается через HTTP-клиент mcp-go. +// Проверяются initialize, tools/list (точное множество) и tools/call +// connect — как это будет делать внешний MCP-клиент. +func TestStreamableHTTPEndToEnd(t *testing.T) { + svc := NewMCPService(&fakeGamePlayer{story: defaultStory(), game: defaultGame()}) + + mcpHTTP := server.NewStreamableHTTPServer(svc.Server()) + srv := httptest.NewServer(mcpHTTP) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + // ВАЖНО: клиент POST-ит строго на переданный baseURL без добавления + // пути — эндпоинт /api/mcp передаём явно. + mcpClient, err := client.NewStreamableHttpClient(srv.URL + "/api/mcp") + if err != nil { + t.Fatalf("NewStreamableHttpClient() error = %v", err) + } + t.Cleanup(func() { _ = mcpClient.Close() }) + + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{Name: "http_it", Version: "1.0.0"} + if _, err := mcpClient.Initialize(ctx, initRequest); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + + toolsResult, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + t.Fatalf("ListTools() error = %v", err) + } + names := make([]string, 0, len(toolsResult.Tools)) + for _, tool := range toolsResult.Tools { + names = append(names, tool.Name) + } + assertExactTools(t, names) + + callRequest := mcp.CallToolRequest{} + callRequest.Params.Name = "connect" + callRequest.Params.Arguments = map[string]any{ + "url": "/team-story/10?password=team-pass-1", + } + result, err := mcpClient.CallTool(ctx, callRequest) + if err != nil { + t.Fatalf("CallTool(connect) error = %v", err) + } + var text strings.Builder + for _, content := range result.Content { + if tc, ok := content.(mcp.TextContent); ok { + text.WriteString(tc.Text) + } + } + if !strings.Contains(text.String(), "Подключено к команде 10") { + t.Fatalf("CallTool(connect) = %q; want success message", text.String()) + } + if !strings.Contains(text.String(), "entrance") { + t.Fatalf("CallTool(connect) = %q; want story JSON", text.String()) + } +} + +// Проверка, что структуры моков сериализуемы (защита от изменения типов +// storytelling/game_service без обновления моков). +func TestMockTypesUsable(t *testing.T) { + _ = defaultStory() + _ = defaultGame() + _ = (*game_service.Game)(nil) + _ = (*storytelling.Story)(nil) +} diff --git a/internal/services/mcp_service/service.go b/internal/services/mcp_service/service.go new file mode 100644 index 0000000..7b053e5 --- /dev/null +++ b/internal/services/mcp_service/service.go @@ -0,0 +1,263 @@ +// 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 + } +} diff --git a/internal/services/mcp_service/service_test.go b/internal/services/mcp_service/service_test.go new file mode 100644 index 0000000..35c24ca --- /dev/null +++ b/internal/services/mcp_service/service_test.go @@ -0,0 +1,395 @@ +package mcp_service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "evening_detective_server/internal/modules/storytelling" + "evening_detective_server/internal/services/game_service" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" +) + +// fakeGamePlayer — мок GamePlayer с предзаданными историей, игрой и +// ошибками (реализует интерфейс GamePlayer, БД не нужна). +type fakeGamePlayer struct { + story *storytelling.Story + game *game_service.Game + err error +} + +func (f *fakeGamePlayer) GetTeamActions(_ context.Context, teamID int, password string) (*storytelling.Story, *game_service.Game, error) { + if f.err != nil { + return nil, nil, f.err + } + return f.story, f.game, nil +} + +func (f *fakeGamePlayer) AddTeamAction(_ context.Context, teamID int, password, code string) error { + if f.err != nil { + return f.err + } + return nil +} + +func defaultStory() *storytelling.Story { + return &storytelling.Story{ + Places: []*storytelling.Place{ + { + Code: "entrance", + Name: "Вход", + Text: "Вы у входа в особняк.", + Doors: []*storytelling.Door{ + {Code: "hall", Name: "Зал"}, + }, + }, + }, + } +} + +func defaultGame() *game_service.Game { + return &game_service.Game{ + ID: 1, + Name: "Игра 1", + Status: "started", + } +} + +// mcpFixture поднимает in-process MCP-клиент над сервисом с моком +// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис. +type mcpFixture struct { + client *client.Client +} + +func newMCPFixture(t *testing.T, player GamePlayer) *mcpFixture { + t.Helper() + + svc := NewMCPService(player) + + mcpClient, err := client.NewInProcessClient(svc.Server()) + if err != nil { + t.Fatalf("NewInProcessClient() error = %v", err) + } + t.Cleanup(func() { _ = mcpClient.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + if err := mcpClient.Start(ctx); err != nil { + t.Fatalf("client.Start() error = %v", err) + } + + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{Name: "mcp_service_test", Version: "1.0.0"} + if _, err := mcpClient.Initialize(ctx, initRequest); err != nil { + t.Fatalf("client.Initialize() error = %v", err) + } + + return &mcpFixture{client: mcpClient} +} + +// call вызывает инструмент с аргументами и возвращает текст результата. +func (f *mcpFixture) call(t *testing.T, name string, args map[string]any) string { + t.Helper() + request := mcp.CallToolRequest{} + request.Params.Name = name + request.Params.Arguments = args + + result, err := f.client.CallTool(context.Background(), request) + if err != nil { + t.Fatalf("CallTool(%s) error = %v", name, err) + } + var text strings.Builder + for _, content := range result.Content { + switch c := content.(type) { + case mcp.TextContent: + text.WriteString(c.Text) + default: + t.Fatalf("CallTool(%s) unexpected content type %T", name, content) + } + } + return text.String() +} + +// toolNames возвращает имена инструментов сервера. +func (f *mcpFixture) toolNames(t *testing.T) []string { + t.Helper() + toolsResult, err := f.client.ListTools(context.Background(), mcp.ListToolsRequest{}) + if err != nil { + t.Fatalf("ListTools() error = %v", err) + } + names := make([]string, 0, len(toolsResult.Tools)) + for _, tool := range toolsResult.Tools { + names = append(names, tool.Name) + } + return names +} + +func assertExactTools(t *testing.T, names []string) { + t.Helper() + if len(names) != 3 { + t.Fatalf("tools = %v; want ровно 3 инструмента", names) + } + for _, want := range []string{"connect", "get_team_story", "make_move"} { + found := false + for _, name := range names { + if name == want { + found = true + break + } + } + if !found { + t.Errorf("tools = %v; не содержит %q", names, want) + } + } +} + +func TestServerListsTools(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) + assertExactTools(t, f.toolNames(t)) +} + +func TestConnectRelativeURL(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) + + got := f.call(t, "connect", map[string]any{ + "url": "/team-story/10?password=team-pass-1", + }) + if !strings.Contains(got, "Подключено к команде 10") { + t.Fatalf("connect: %q; want team id in message", got) + } + if !strings.Contains(got, "team-pass-1") { + t.Fatalf("connect: %q; want password echoed", got) + } + if !strings.Contains(got, "entrance") { + t.Fatalf("connect: %q; want story JSON", got) + } + if !strings.Contains(got, `"game":{"id":1,"name":"Игра 1","status":"started"}`) { + t.Fatalf("connect: %q; want game DTO", got) + } +} + +// TestConnectProtocolRelativeURL: протокол-относительная ссылка принимается +// (запросы по ней не выполняются — из ссылки берутся только id и пароль). +func TestConnectProtocolRelativeURL(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) + + got := f.call(t, "connect", map[string]any{ + "url": "//host/team-story/10?password=team-pass-1", + }) + if !strings.Contains(got, "Подключено к команде 10") { + t.Fatalf("connect: %q; want success", got) + } +} + +func TestConnectFullURL(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) + + got := f.call(t, "connect", map[string]any{ + "url": "http://any.example:9999/team-story/10?password=team-pass-1", + }) + if !strings.Contains(got, "Подключено к команде 10") { + t.Fatalf("connect: %q; want success (хост игнорируется)", got) + } +} + +func TestConnectMissingURL(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{}) + + got := f.call(t, "connect", map[string]any{}) + if !strings.Contains(got, "требуется url") { + t.Fatalf("connect без url: %q; want args hint", got) + } +} + +func TestConnectInvalidURLs(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{}) + + cases := []struct { + name string + url string + want string + }{ + {"пустой password", "/team-story/10", "password"}, + {"не тот путь", "/game/10?password=x", "путь должен быть"}, + {"mailto-схема", "mailto:team@example.com", "путь должен быть"}, + {"id не число", "/team-story/abc?password=x", "id команды"}, + {"отрицательный id", "/team-story/-5?password=x", "id команды"}, + {"ошибка url.Parse в пути", "/team-story/%zz?password=x", "невалидная ссылка"}, + {"нет id в пути", "/team-story/?password=x", "отсутствует id команды"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := f.call(t, "connect", map[string]any{"url": tc.url}) + if !strings.Contains(got, tc.want) { + t.Fatalf("connect(%q): %q; want подстроку %q", tc.url, got, tc.want) + } + }) + } +} + +func TestConnectBusinessError(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("team not found")}) + + got := f.call(t, "connect", map[string]any{ + "url": "/team-story/10?password=wrong", + }) + if !strings.Contains(got, "team not found") { + t.Fatalf("connect с ошибкой сервиса: %q; want error text", got) + } +} + +func TestGetTeamStory(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) + + got := f.call(t, "get_team_story", map[string]any{ + "team_id": float64(10), + "password": "team-pass-1", + }) + if !strings.Contains(got, "entrance") { + t.Fatalf("get_team_story: %q; want story JSON", got) + } +} + +func TestGetTeamStoryBusinessError(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("team not found")}) + + got := f.call(t, "get_team_story", map[string]any{ + "team_id": float64(10), + "password": "wrong", + }) + if !strings.Contains(got, "team not found") { + t.Fatalf("get_team_story с ошибкой сервиса: %q; want error text", got) + } +} + +func TestGetTeamStoryInvalidTeamID(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{}) + + got := f.call(t, "get_team_story", map[string]any{ + "team_id": "not-a-number", + "password": "x", + }) + if !strings.Contains(got, "team_id") { + t.Fatalf("get_team_story с нечисловым team_id: %q; want args hint", got) + } +} + +func TestMakeMove(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()}) + + got := f.call(t, "make_move", map[string]any{ + "team_id": "10", // строка тоже принимается + "password": "team-pass-1", + "code": "hall", + }) + if !strings.Contains(got, "entrance") { + t.Fatalf("make_move: %q; want updated story JSON", got) + } +} + +func TestMakeMoveActionError(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("code is forbidden")}) + + got := f.call(t, "make_move", map[string]any{ + "team_id": float64(10), + "password": "team-pass-1", + "code": "forbidden", + }) + if !strings.Contains(got, "code is forbidden") { + t.Fatalf("make_move: %q; want error text", got) + } +} + +func TestMakeMoveMissingCode(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{}) + + got := f.call(t, "make_move", map[string]any{ + "team_id": float64(10), + "password": "team-pass-1", + "code": "", + }) + if !strings.Contains(got, "password и code") { + t.Fatalf("make_move без code: %q; want args hint", got) + } +} + +func TestMakeMoveWithoutTeamID(t *testing.T) { + f := newMCPFixture(t, &fakeGamePlayer{}) + + got := f.call(t, "make_move", map[string]any{ + "password": "x", + "code": "hall", + }) + if !strings.Contains(got, "team_id") { + t.Fatalf("make_move без team_id: %q; want args hint", got) + } +} + +func TestParseConnectURL(t *testing.T) { + okCases := []struct { + name string + url string + teamID int64 + password string + }{ + {"относительная", "/team-story/10?password=p1", 10, "p1"}, + {"полный URL", "http://host:8090/team-story/7?password=p2", 7, "p2"}, + {"протокол-относительная", "//host/team-story/3?password=p3", 3, "p3"}, + {"лишний query", "/team-story/1?password=p4&foo=bar", 1, "p4"}, + } + for _, tc := range okCases { + t.Run(tc.name, func(t *testing.T) { + args, err := parseConnectURL(tc.url) + if err != nil { + t.Fatalf("parseConnectURL(%q) error = %v", tc.url, err) + } + if args.teamID != tc.teamID || args.password != tc.password { + t.Fatalf("parseConnectURL(%q) = %+v; want teamID=%d password=%q", tc.url, args, tc.teamID, tc.password) + } + }) + } + + errCases := []struct { + name string + url string + }{ + {"пустая", ""}, + {"пробелы", " "}, + {"нет password", "/team-story/10"}, + {"не тот путь", "/game/10?password=x"}, + {"id не число", "/team-story/abc?password=x"}, + {"отрицательный id", "/team-story/-5?password=x"}, + {"ошибка парсинга", "/team-story/%zz?password=x"}, + } + for _, tc := range errCases { + t.Run(tc.name, func(t *testing.T) { + if _, err := parseConnectURL(tc.url); err == nil { + t.Fatalf("parseConnectURL(%q) error = nil; want error", tc.url) + } + }) + } +} + +// TestNewMCPService — конструктор не требует БД и возвращает сервис. +func TestNewMCPService(t *testing.T) { + svc := NewMCPService(&fakeGamePlayer{}) + if svc == nil { + t.Fatal("NewMCPService() = nil") + } + if svc.game == nil { + t.Fatal("NewMCPService(): game == nil") + } +} + +// TestDefaultCallTimeout — защита от случайного изменения таймаута. +func TestDefaultCallTimeout(t *testing.T) { + if defaultCallTimeout != 10*time.Second { + t.Fatalf("defaultCallTimeout = %v; want 10s", defaultCallTimeout) + } +}