This commit is contained in:
2026-08-27 00:11:57 +07:00
parent bafe22a95d
commit 1acccd6c40
7 changed files with 821 additions and 11 deletions
@@ -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)
}
+263
View File
@@ -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)
}
}