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
+12 -5
View File
@@ -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)
}
}
}
+24 -3
View File
@@ -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
}