generated from VLADIMIR/template
add mcp
This commit is contained in:
@@ -69,3 +69,38 @@ make test
|
|||||||
- `preset.yml` — отображаемое имя и описание;
|
- `preset.yml` — отображаемое имя и описание;
|
||||||
- `skills/software-law/` — база знаний: `SKILL.md` (правила консультаций) и `references/` (лицензии, авторские права, ПДн, договоры).
|
- `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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -137,16 +137,23 @@ func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCORSExposesContentDisposition — фронт через fetch должен читать
|
// TestCORSHeaders — фронт через fetch должен читать Content-Disposition
|
||||||
// Content-Disposition (имя файла архива) из ответа.
|
// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки
|
||||||
func TestCORSExposesContentDisposition(t *testing.T) {
|
// 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) {
|
h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/scenarios/1/archive", nil))
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/scenarios/1/archive", nil))
|
||||||
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "Content-Disposition" {
|
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", got)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"evening_detective_server/internal/repos/users_repo"
|
"evening_detective_server/internal/repos/users_repo"
|
||||||
"evening_detective_server/internal/services/file_service"
|
"evening_detective_server/internal/services/file_service"
|
||||||
"evening_detective_server/internal/services/game_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/scenarios_service"
|
||||||
"evening_detective_server/internal/services/ui_service"
|
"evening_detective_server/internal/services/ui_service"
|
||||||
"evening_detective_server/internal/services/users_service"
|
"evening_detective_server/internal/services/users_service"
|
||||||
@@ -35,6 +36,7 @@ import (
|
|||||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
@@ -231,6 +233,20 @@ func main() {
|
|||||||
// проверки в хендлере не спасут от OOM.
|
// проверки в хендлере не спасут от OOM.
|
||||||
mainMux.Handle("/api/", limitArchiveUploadBody(gwmux))
|
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{
|
gwServer := &http.Server{
|
||||||
Addr: ":8090",
|
Addr: ":8090",
|
||||||
Handler: cors(mainMux),
|
Handler: cors(mainMux),
|
||||||
@@ -245,9 +261,14 @@ func cors(h http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
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-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")
|
// Mcp-Session-Id / Mcp-Protocol-Version / Last-Event-ID — заголовки
|
||||||
// Content-Disposition нужен фронту, чтобы прочитать имя файла архива.
|
// streamable HTTP MCP-эндпоинта /api/mcp (сессия, версия протокола,
|
||||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
|
// 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" {
|
if r.Method == "OPTIONS" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.26
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0
|
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
|
github.com/stretchr/testify v1.12.0
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754
|
||||||
google.golang.org/grpc v1.83.0
|
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/ssooidc v1.36.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // 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/aws/smithy-go v1.27.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/rogpeppe/go-internal v1.15.0 // 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/vearutop/statigz v1.4.0 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user