generated from VLADIMIR/template
add questions
This commit is contained in:
@@ -31,6 +31,7 @@ const defaultCallTimeout = 10 * time.Second
|
||||
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
|
||||
SubmitTeamAnswers(ctx context.Context, teamId int, password string, answers []game_service.Answer) error
|
||||
}
|
||||
|
||||
// MCPService собирает MCP-сервер с игровыми инструментами. Сервис не
|
||||
@@ -52,7 +53,7 @@ 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) по кодам точек сценария."),
|
||||
server.WithInstructions("Инструменты для игры «Вечерний детектив»: подключитесь к игре по ссылке (connect) — организатор выдаёт ссылку вида /team-story/{id}?password=..., — затем смотрите историю команды (get_team_story), делайте ходы (make_move) по кодам точек сценария и отвечайте на вопросы дела (submit_answers)."),
|
||||
)
|
||||
|
||||
srv.AddTool(
|
||||
@@ -85,6 +86,34 @@ func (s *MCPService) Server() *server.MCPServer {
|
||||
s.handleMakeMove,
|
||||
)
|
||||
|
||||
srv.AddTool(
|
||||
mcp.NewToolWithRawSchema(
|
||||
"submit_answers",
|
||||
"Отправить ответы команды на вопросы дела (список вопросов — в истории команды через get_team_story). Работает, пока игра идёт; после завершения игры ответы изменить нельзя. После успешной отправки возвращает обновлённую историю команды. Команда идентифицируется паролем.",
|
||||
json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "number", "description": "ID команды"},
|
||||
"password": {"type": "string", "description": "Пароль команды"},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"description": "Ответы команды на вопросы дела; переданные коды перезаписываются, пустая строка очищает ответ",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questionCode": {"type": "string", "description": "Код вопроса из истории команды"},
|
||||
"answer": {"type": "string", "description": "Текст ответа команды"}
|
||||
},
|
||||
"required": ["questionCode", "answer"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["team_id", "password", "answers"]
|
||||
}`),
|
||||
),
|
||||
s.handleSubmitAnswers,
|
||||
)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
@@ -248,6 +277,78 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
// submitAnswersArgs — разобранные аргументы инструмента submit_answers.
|
||||
type submitAnswersArgs struct {
|
||||
teamID int64
|
||||
password string
|
||||
answers []game_service.Answer
|
||||
}
|
||||
|
||||
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
|
||||
// answers — массив объектов {questionCode, answer} (JSON-массив приходит
|
||||
// как []any с map[string]any элементами). Ключ questionCode совпадает с
|
||||
// REST JSON (camelCase) — единый формат для клиентов.
|
||||
func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
|
||||
teamID, ok := argInt64(args["team_id"])
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется team_id (число)")
|
||||
}
|
||||
password, _ := args["password"].(string)
|
||||
if password == "" {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется password")
|
||||
}
|
||||
rawAnswers, ok := args["answers"].([]any)
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("требуется answers (массив ответов)")
|
||||
}
|
||||
answers := make([]game_service.Answer, 0, len(rawAnswers))
|
||||
for i, raw := range rawAnswers {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: ожидается объект {questionCode, answer}", i)
|
||||
}
|
||||
questionCode, _ := item["questionCode"].(string)
|
||||
answerText, _ := item["answer"].(string)
|
||||
if questionCode == "" {
|
||||
return submitAnswersArgs{}, fmt.Errorf("answers[%d]: требуется questionCode", i)
|
||||
}
|
||||
answers = append(answers, game_service.Answer{
|
||||
QuestionCode: questionCode,
|
||||
Text: answerText,
|
||||
})
|
||||
}
|
||||
return submitAnswersArgs{
|
||||
teamID: teamID,
|
||||
password: password,
|
||||
answers: answers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, err := parseSubmitAnswersArgs(req.GetArguments())
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := s.game.SubmitTeamAnswers(ctx, int(args.teamID), args.password, args.answers); err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
story, game, err := s.game.GetTeamActions(ctx, int(args.teamID), args.password)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
|
||||
jsonBody, err := storyJSON(story, game)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("submit_answers: %v", err)), nil
|
||||
}
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
// argInt64 достаёт int64 из аргумента инструмента: числа приходят как
|
||||
// float64 (JSON), строки — как string.
|
||||
func argInt64(v any) (int64, bool) {
|
||||
|
||||
@@ -36,6 +36,13 @@ func (f *fakeGamePlayer) AddTeamAction(_ context.Context, teamID int, password,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGamePlayer) SubmitTeamAnswers(_ context.Context, teamID int, password string, answers []game_service.Answer) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultStory() *storytelling.Story {
|
||||
return &storytelling.Story{
|
||||
Introduction: &storytelling.Introduction{
|
||||
@@ -136,10 +143,10 @@ func (f *mcpFixture) toolNames(t *testing.T) []string {
|
||||
|
||||
func assertExactTools(t *testing.T, names []string) {
|
||||
t.Helper()
|
||||
if len(names) != 3 {
|
||||
t.Fatalf("tools = %v; want ровно 3 инструмента", names)
|
||||
if len(names) != 4 {
|
||||
t.Fatalf("tools = %v; want ровно 4 инструмента", names)
|
||||
}
|
||||
for _, want := range []string{"connect", "get_team_story", "make_move"} {
|
||||
for _, want := range []string{"connect", "get_team_story", "make_move", "submit_answers"} {
|
||||
found := false
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
@@ -400,3 +407,56 @@ func TestDefaultCallTimeout(t *testing.T) {
|
||||
t.Fatalf("defaultCallTimeout = %v; want 10s", defaultCallTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswers(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
got := f.call(t, "submit_answers", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "team-pass-1",
|
||||
"answers": []any{
|
||||
map[string]any{"questionCode": "q1", "answer": "Дворецкий"},
|
||||
map[string]any{"questionCode": "q2", "answer": ""},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(got, "entrance") {
|
||||
t.Fatalf("submit_answers: %q; want story JSON в ответе", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswersValidation(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{"нет team_id", map[string]any{"password": "x", "answers": []any{}}, "team_id"},
|
||||
{"нет password", map[string]any{"team_id": float64(1), "answers": []any{}}, "password"},
|
||||
{"нет answers", map[string]any{"team_id": float64(1), "password": "x"}, "answers"},
|
||||
{"элемент не объект", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{"str"}}, "answers[0]"},
|
||||
{"нет questionCode", map[string]any{"team_id": float64(1), "password": "x", "answers": []any{map[string]any{"answer": "x"}}}, "questionCode"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := f.call(t, "submit_answers", tc.args)
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("submit_answers: %q; want подстроку %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitAnswersBusinessError(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{err: errors.New("Игра завершена, ответы больше нельзя изменять")})
|
||||
|
||||
got := f.call(t, "submit_answers", map[string]any{
|
||||
"team_id": float64(10),
|
||||
"password": "wrong",
|
||||
"answers": []any{map[string]any{"questionCode": "q1", "answer": "x"}},
|
||||
})
|
||||
if !strings.Contains(got, "Игра завершена") {
|
||||
t.Fatalf("submit_answers с ошибкой сервиса: %q; want error text", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user