generated from VLADIMIR/template
add mcp
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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