From 2cff5fe513ca74a3716b96e79bc714e25bf271fb Mon Sep 17 00:00:00 2001 From: "Tom C." Date: Sat, 4 Jul 2026 02:53:38 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20r=C3=A9=C3=A9criture=20Go=20du=20serveu?= =?UTF-8?q?r=20MCP=20(client=20de=20l'API=20/v1)=20=E2=80=94=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Porte le serveur MCP de Next.js/TS vers Go dans go/ (le TS reste intact à la racine). Le serveur devient un client HTTP mince de footics-api /v1 : il vérifie le JWT Supabase localement (double-mode JWKS/HS256, même logique que footics-api internal/auth) puis relaie le Bearer vers /v1. Zéro accès DB. - SDK officiel github.com/modelcontextprotocol/go-sdk v1.6.1 (Streamable HTTP stateless + auth.RequireBearerToken + PRM RFC 9728 sur les 2 chemins well-known) - 9 outils de lecture mappés sur /v1, formes de sortie GELÉES (identiques au TS lib/tools.ts) ; inputSchema fixés explicitement - submit_prediction = stub gated OFF (POST /v1/predictions = M4, non livré) - Tests : unit (auth double-mode, mapping de forme par outil vs contrat TS) + intégration contre l'API staging (skip propre si injoignable) Gate vert : go build && go vet && gofmt -l (vide) && go test ./... Validé à la main contre l'API staging (127.0.0.1:8099). Claude-Session: https://claude.ai/code/session_01CsRaJpQmoPyXRqH7g29mwN --- go/.env.example | 43 +++ go/README.md | 116 ++++++++ go/cmd/mcp/main.go | 190 ++++++++++++++ go/go.mod | 17 ++ go/go.sum | 20 ++ go/internal/apiclient/apiclient.go | 276 +++++++++++++++++++ go/internal/auth/auth.go | 238 +++++++++++++++++ go/internal/auth/auth_test.go | 85 ++++++ go/internal/config/config.go | 127 +++++++++ go/internal/config/config_test.go | 72 +++++ go/internal/tools/integration_test.go | 307 ++++++++++++++++++++++ go/internal/tools/ratelimit.go | 72 +++++ go/internal/tools/schemas.go | 137 ++++++++++ go/internal/tools/shapes.go | 328 +++++++++++++++++++++++ go/internal/tools/shapes_test.go | 160 +++++++++++ go/internal/tools/tools.go | 364 ++++++++++++++++++++++++++ 16 files changed, 2552 insertions(+) create mode 100644 go/.env.example create mode 100644 go/README.md create mode 100644 go/cmd/mcp/main.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/internal/apiclient/apiclient.go create mode 100644 go/internal/auth/auth.go create mode 100644 go/internal/auth/auth_test.go create mode 100644 go/internal/config/config.go create mode 100644 go/internal/config/config_test.go create mode 100644 go/internal/tools/integration_test.go create mode 100644 go/internal/tools/ratelimit.go create mode 100644 go/internal/tools/schemas.go create mode 100644 go/internal/tools/shapes.go create mode 100644 go/internal/tools/shapes_test.go create mode 100644 go/internal/tools/tools.go diff --git a/go/.env.example b/go/.env.example new file mode 100644 index 0000000..c1325d5 --- /dev/null +++ b/go/.env.example @@ -0,0 +1,43 @@ +# Footics MCP (Go) — environment variables +# Copy to .env and export before running ./bin/mcp. No DATABASE_URL: this server +# is a thin client of footics-api /v1, it never touches Postgres. + +# footics-api base URL. In the compose network this is the internal address; for +# local dev point it at the staging API. The client appends /v1/... and relays +# the caller's Bearer. Required. +FOOTICS_API_URL="http://127.0.0.1:8099" + +# JWT verification mode — set EXACTLY ONE (mirrors footics-api internal/auth): +# AUTH_JWKS_URL → asymmetric ES256/RS256, keys fetched + cached from the URL (prod) +# AUTH_JWT_SECRET → HS256 with the legacy Supabase secret (staging/dev) +# AUTH_JWKS_URL="https://.supabase.co/auth/v1/.well-known/jwks.json" +AUTH_JWT_SECRET="" +AUTH_JWT_AUD="authenticated" + +# Public origin of this server; Resource = MCP_PUBLIC_URL + "/mcp" (the OAuth +# protected-resource identifier advertised in the RFC 9728 metadata). +MCP_PUBLIC_URL="https://mcp.footics.app" + +# OAuth issuer advertised as authorization_servers[0] in the metadata (Supabase). +# Empty → the field is omitted. +MCP_OAUTH_ISSUER="https://.supabase.co/auth/v1" + +# HTTP listen address. +HTTP_ADDR=":8080" + +# Kill switch: "false" → every /mcp request gets 503 without touching auth/API. +MCP_ENABLED="true" + +# Require auth? "true" (default). "false" = authless test mode (identity from +# MCP_TEST_USER_ID; reads still need a relayed Bearer, so the API will reject them). +MCP_REQUIRE_AUTH="true" + +# Expose submit_prediction as a real write? Its real path lands with footics-api +# POST /v1/predictions (M4); until then the tool is a stub regardless. +MCP_ENABLE_WRITES="false" + +# Per-user tool-call rate limit (calls/minute/user). 0 = off. Default 30. +MCP_RATE_LIMIT_PER_MIN="30" + +# Identity fallback for authless test mode (MCP_REQUIRE_AUTH=false). +# MCP_TEST_USER_ID="" diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..398ecb1 --- /dev/null +++ b/go/README.md @@ -0,0 +1,116 @@ +# Footics MCP — Go rewrite (client of `/v1`) + +Go port of the Footics MCP server. It is a **thin HTTP client of `footics-api` +`/v1`**: it verifies the Supabase JWT locally, then relays the caller's Bearer to +the API — **no database access**. This is the staging deliverable for PRD 15 +(`docs/prd/15-mcp-go.md`); it lives in `go/` alongside the untouched Next.js/TS +server at the repo root. + +> **The TypeScript server stays.** Nothing at the repo root is removed or moved. +> The live server (`mcp.footics.app` on Vercel) is unaffected. Whether/when Go +> replaces TS is **Tom's call** — see [Repo structure](#repo-structure). + +## What it does + +- Verifies the Supabase JWT **locally**, double-mode (JWKS ES256/RS256 or HS256), + same logic as `footics-api/internal/auth`. +- Serves RFC 9728 **protected-resource metadata** on both well-known paths + the + `WWW-Authenticate` challenge on 401 → OAuth discovery still points at Supabase. +- Exposes the **10 frozen tools** over Streamable HTTP (stateless). Each read is + one `/v1` call with the Bearer relayed; the output shape is byte-identical to + the TS server (`lib/tools.ts`). `whoami` is served from the JWT alone. + +## Quickstart + +```bash +cd go +cp .env.example .env # point FOOTICS_API_URL at the staging API +set -a && . ./.env && set +a +go run ./cmd/mcp # listens on HTTP_ADDR (default :8080) +``` + +Gate (what CI runs): + +```bash +go build ./... && go vet ./... && gofmt -l . # last must print nothing +go test ./... +``` + +The integration test (`internal/tools/integration_test.go`) stands up the real +stack in front of a running staging API (`http://127.0.0.1:8099`), mints an alice +Bearer, and asserts the frozen shape of every read tool. It **skips** cleanly +when the API is unreachable. + +## Environment + +| Var | Required | Default | Notes | +|---|---|---|---| +| `FOOTICS_API_URL` | ✅ | — | Base URL of `footics-api` (`/v1` appended). No `DATABASE_URL` exists. | +| `AUTH_JWKS_URL` \| `AUTH_JWT_SECRET` | ✅ (exactly one) | — | JWKS (prod) or HS256 (staging). Mutually exclusive. | +| `AUTH_JWT_AUD` | | `authenticated` | Expected `aud` claim. | +| `MCP_PUBLIC_URL` | | `https://mcp.footics.app` | `Resource` = this + `/mcp`. | +| `MCP_OAUTH_ISSUER` | | — | `authorization_servers[0]` in the metadata (Supabase). | +| `HTTP_ADDR` | | `:8080` | | +| `MCP_ENABLED` | | `true` | `false` → 503 kill switch. | +| `MCP_REQUIRE_AUTH` | | `true` | `false` → authless test mode (`MCP_TEST_USER_ID`). | +| `MCP_ENABLE_WRITES` | | `false` | See [submit_prediction](#submit_prediction-awaits-m4). | +| `MCP_RATE_LIMIT_PER_MIN` | | `30` | Per-user; 0 = off. Global on this single process. | + +## Tools → `/v1` endpoints + +| Tool | Endpoint | Notes | +|---|---|---| +| `whoami` | — (JWT) | `{userId, email}` from the token. | +| `list_matches` | `GET /v1/matches?competition=&status=&limit=` | flat array, projected to the frozen `MatchJson`. | +| `get_match` | `GET /v1/matches/{id}` | + `events[]` (`[]` when scheduled); 404 → "Match introuvable." | +| `get_my_standing` | `GET /v1/me?competition=` | projects `{userId,username,competition,points,exact,rank,rankOf}`; 404 → "Profil introuvable." | +| `get_my_predictions` | `GET /v1/predictions?competition=&when=&limit=` | drops `winnerTeamCode`; `result`/`points` present-null. | +| `get_leaderboard` | `GET /v1/leaderboard?competition=&group=&limit=` | projects `rows`. | +| `list_my_groups` | `GET /v1/groups?competition=` | projects `groups` (`owner` bool). | +| `get_joker_status` | `GET /v1/jokers?competition=` | passthrough. | +| `search` | `GET /v1/search?q=` | passthrough (`{matches,groups,users}`). | +| `submit_prediction` | `POST /v1/predictions` | **stub — awaits M4.** | + +Projection notes (to stay byte-identical to `lib/tools.ts`): +- **`kickoffAt`** is normalised to `Date.toISOString()` form (always `.000Z`) — + `/v1/matches` already emits millis, `/v1/predictions` and `/v1/search` do not. +- **`venue`** empty string → `null` (the TS `venue ?? null` for an absent venue). +- **`score`** = the regulation `Score` for finished, `LiveScore` for live, else `null`. +- Input schemas are **fixed explicitly** (`internal/tools/schemas.go`) — enums, + bounds, defaults and FR descriptions — not inferred from Go structs, so + `tools/list` matches the zod-derived TS schemas. + +### `submit_prediction` awaits M4 + +The write path (`footics-api POST /v1/predictions`) ships with **M4** and is not +delivered yet. The tool is registered (prod runs writes ON) but is a **stub**: +with `MCP_ENABLE_WRITES=false` it returns *"écriture pas encore disponible sur +cette instance"*; with writes on it returns a *"arrivera avec l'API /v1 POST +(M4)"* error. Wiring the real call is a one-function change once M4 lands. + +## Layout + +``` +go/ + cmd/mcp/ server wiring (mux, PRM, auth middleware, CORS, kill switch, health) + internal/config/ env parsing (no DATABASE_URL) + internal/auth/ JWT double-mode verifier → go-sdk auth.TokenVerifier + internal/apiclient/ thin /v1 HTTP client (relays the Bearer) + internal/tools/ 10 tools: registration, gate (auth+rate-limit), projections, schemas +``` + +Uses the official SDK `github.com/modelcontextprotocol/go-sdk` (v1.6.1): +`mcp.NewStreamableHTTPHandler` (stateless), `mcp.AddTool`, `auth.RequireBearerToken`, +`auth.ProtectedResourceMetadataHandler`. Routing is stdlib `net/http.ServeMux` +(five static routes — chi would buy nothing here). + +## Repo structure + +Delivered under `go/` so the branch is **purely additive**: not a single TS file +at the repo root is moved or deleted, and the live Vercel server is untouched. +PRD 15 §9 envisions an eventual in-place rewrite (TS → history + a `v0-nextjs` +tag); promoting `go/` to the root and archiving the TS is a **separate decision +for Tom**, out of scope for this staging deliverable. + +Not included here (land with the cutover, N4): Dockerfile / GHCR image, Go CI +workflow, tunnel + compose service. See PRD 15 §10–11. diff --git a/go/cmd/mcp/main.go b/go/cmd/mcp/main.go new file mode 100644 index 0000000..0d503e3 --- /dev/null +++ b/go/cmd/mcp/main.go @@ -0,0 +1,190 @@ +// Command mcp is the Footics MCP server: a thin client of footics-api /v1. +// +// It verifies the Supabase JWT locally (double-mode JWKS/HS256), serves the RFC +// 9728 protected-resource metadata + WWW-Authenticate challenge so OAuth +// discovery points at Supabase, and exposes the 10 frozen tools over Streamable +// HTTP (stateless). Every read relays the caller's Bearer to footics-api; there +// is no database access here. +package main + +import ( + "context" + "encoding/json" + "log" + "log/slog" + "net/http" + "os" + "time" + + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" + + "github.com/footics/mcp-server/internal/apiclient" + "github.com/footics/mcp-server/internal/auth" + "github.com/footics/mcp-server/internal/config" + "github.com/footics/mcp-server/internal/tools" +) + +const version = "0.1.0-go" + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + api := apiclient.New(cfg.APIBaseURL) + + server := mcp.NewServer(&mcp.Implementation{Name: "footics-mcp", Version: version}, nil) + tools.Register(server, tools.Deps{ + API: api, + EnableWrites: cfg.EnableWrites, + TestUserID: cfg.TestUserID, + RateLimitPerMin: cfg.RateLimitPerMin, + }) + + mux := http.NewServeMux() + mux.Handle("/mcp", mcpHandler(cfg, server)) + mux.Handle("/.well-known/oauth-protected-resource", protectedResourceHandler(cfg)) + mux.Handle("/.well-known/oauth-protected-resource/mcp", protectedResourceHandler(cfg)) + mux.HandleFunc("/health", healthHandler(cfg, api)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) + }) + + mode := "hs256" + if cfg.UseJWKS() { + mode = "jwks" + } + slog.Info("footics-mcp starting", + "addr", cfg.HTTPAddr, "resource", cfg.Resource, "apiBaseURL", cfg.APIBaseURL, + "authMode", mode, "requireAuth", cfg.RequireAuth, "enabled", cfg.Enabled, + "writes", cfg.EnableWrites, "rateLimitPerMin", cfg.RateLimitPerMin, + ) + // ponytail: bare ListenAndServe — graceful shutdown lands with the container + // lifecycle at cutover (N4). Nothing here holds state worth draining. + log.Fatal(http.ListenAndServe(cfg.HTTPAddr, mux)) +} + +// mcpHandler wraps the Streamable HTTP handler with the kill switch, bearer auth +// (when required), and CORS. Order (outer→inner): CORS → kill switch → auth → MCP. +func mcpHandler(cfg config.Config, server *mcp.Server) http.Handler { + streamable := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return server }, + &mcp.StreamableHTTPOptions{Stateless: true}, + ) + + var h http.Handler = streamable + if cfg.RequireAuth { + verifier := auth.NewVerifier(cfg) + h = sdkauth.RequireBearerToken(verifier.Verify, &sdkauth.RequireBearerTokenOptions{ + ResourceMetadataURL: cfg.PublicURL + "/.well-known/oauth-protected-resource/mcp", + })(h) + } + if !cfg.Enabled { + h = http.HandlerFunc(serviceOff) + } + return withCORS(h) +} + +// protectedResourceHandler serves the RFC 9728 metadata (authorization_servers → +// Supabase). Served identically on both well-known paths. +func protectedResourceHandler(cfg config.Config) http.Handler { + var servers []string + if cfg.OAuthIssuer != "" { + servers = []string{cfg.OAuthIssuer} + } + return sdkauth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: cfg.Resource, + AuthorizationServers: servers, + BearerMethodsSupported: []string{"header"}, + ScopesSupported: []string{"openid", "email", "profile"}, + ResourceName: "Footics MCP", + ResourceDocumentation: "https://footics.app", + }) +} + +// serviceOff is the MCP_ENABLED=false response: a JSON-RPC error, no auth/API +// traffic (mirrors the TS kill switch). +func serviceOff(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "3600") + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "jsonrpc": "2.0", + "id": nil, + "error": map[string]any{ + "code": -32000, + "message": "Footics MCP est temporairement hors service (maintenance). Réessaie plus tard.", + }, + }) +} + +// healthHandler reports config + a best-effort footics-api liveness probe. Unlike +// the TS server there is no `db` section — the DB lives behind footics-api now. +func healthHandler(cfg config.Config, api *apiclient.Client) http.HandlerFunc { + mode := "hs256" + if cfg.UseJWKS() { + mode = "jwks" + } + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !cfg.Enabled { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, "enabled": false, "resource": cfg.Resource, + "reason": "MCP_ENABLED=false — service coupé volontairement (maintenance).", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "enabled": true, + "resource": cfg.Resource, + "auth": map[string]any{"required": cfg.RequireAuth, "mode": mode}, + "writes": cfg.EnableWrites, + "rateLimitPerMin": cfg.RateLimitPerMin, + "api": map[string]any{"url": cfg.APIBaseURL, "ok": probeAPI(r.Context(), cfg.APIBaseURL)}, + }) + } +} + +// probeAPI does a bounded GET of footics-api /healthz. +func probeAPI(ctx context.Context, base string) bool { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/healthz", nil) + if err != nil { + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// withCORS mirrors the TS CORS contract (browser MCP clients need it) and answers +// the OPTIONS preflight. WWW-Authenticate is exposed so 401 discovery works +// cross-origin. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Accept") + h.Set("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..cfd8f04 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,17 @@ +module github.com/footics/mcp-server + +go 1.25.2 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/jsonschema-go v0.4.3 + github.com/modelcontextprotocol/go-sdk v1.6.1 +) + +require ( + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..2d6f186 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,20 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/go/internal/apiclient/apiclient.go b/go/internal/apiclient/apiclient.go new file mode 100644 index 0000000..3da4629 --- /dev/null +++ b/go/internal/apiclient/apiclient.go @@ -0,0 +1,276 @@ +// Package apiclient is a thin HTTP client of footics-api /v1. It relays the +// caller's Bearer verbatim (footics-api re-verifies as the trust boundary and +// scopes the data) and adds ?competition= and the per-tool query params. It has +// NO database access; footics-api owns all reads. +// +// The response structs decode only the fields the MCP tools project into their +// frozen output shapes; extra /v1 view-model fields are ignored. +package apiclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// requestTimeout bounds each /v1 call. The API is one internal hop in prod. +const requestTimeout = 10 * time.Second + +// Client calls footics-api /v1. +type Client struct { + base string + hc *http.Client +} + +// New builds a client for the given base URL (e.g. http://api:8080). +func New(baseURL string) *Client { + return &Client{base: baseURL, hc: &http.Client{Timeout: requestTimeout}} +} + +/* ── /v1 response shapes (only the projected fields) ───────────────────────── */ + +type Score struct { + H int `json:"h"` + A int `json:"a"` +} + +type Team struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type LiveClock struct { + Period string `json:"period"` + DisplayMinute *int `json:"displayMinute"` + Running bool `json:"running"` +} + +// Match mirrors the /v1 matchJSON fields the MCP needs. +type Match struct { + ID string `json:"id"` + Phase string `json:"phase"` + Group *string `json:"group"` + Status string `json:"status"` + KickoffAt string `json:"kickoffAt"` + Venue string `json:"venue"` + Competition struct { + Kind string `json:"kind"` + } `json:"competition"` + Home Team `json:"home"` + Away Team `json:"away"` + Score *Score `json:"score"` + LiveScore *Score `json:"liveScore"` + Live *LiveClock `json:"live"` + Mine *Score `json:"mine"` + JokerApplied *bool `json:"jokerApplied"` + Points *int `json:"points"` +} + +type Event struct { + Type string `json:"type"` + Period string `json:"period"` + Minute int `json:"minute"` + MinutePlus *int `json:"minutePlus"` + TeamCode *string `json:"teamCode"` + Player *string `json:"player"` + Detail *string `json:"detail"` +} + +// MatchDetail is a Match plus its event timeline. +type MatchDetail struct { + Match + Events []Event `json:"events"` +} + +// Me mirrors the /v1/me MeProfile subset the MCP projects. +type Me struct { + ID string `json:"id"` + Username string `json:"username"` + Points int `json:"points"` + Exact int `json:"exact"` + Rank int `json:"rank"` + RankOf int `json:"rankOf"` +} + +type Prediction struct { + MatchID string `json:"matchId"` + Fixture string `json:"fixture"` + KickoffAt string `json:"kickoffAt"` + Status string `json:"status"` + Prediction struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + } `json:"prediction"` + Result *struct { + Home int `json:"home"` + Away int `json:"away"` + } `json:"result"` + Points *int `json:"points"` +} + +type LeaderRow struct { + Rank int `json:"rank"` + Username string `json:"username"` + Points int `json:"points"` + Exact int `json:"exact"` + Me bool `json:"me"` +} + +type Group struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Members int `json:"members"` + MyRank int `json:"myRank"` + MyPoints int `json:"myPoints"` + Owner *string `json:"owner"` +} + +type Joker struct { + Bucket string `json:"bucket"` + Label string `json:"label"` + Used int `json:"used"` + Quota int `json:"quota"` + Remaining int `json:"remaining"` +} + +type SearchResults struct { + Matches []struct { + ID string `json:"id"` + Fixture string `json:"fixture"` + KickoffAt string `json:"kickoffAt"` + } `json:"matches"` + Groups []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"groups"` + Users []struct { + ID string `json:"id"` + Username string `json:"username"` + Me bool `json:"me"` + } `json:"users"` +} + +/* ── typed calls ───────────────────────────────────────────────────────────── */ + +// Matches → GET /v1/matches?competition=&status=&limit=. +func (c *Client) Matches(ctx context.Context, token, comp, status string, limit int) ([]Match, error) { + q := url.Values{"competition": {comp}, "limit": {strconv.Itoa(limit)}} + if status != "" { + q.Set("status", status) + } + var out []Match + _, err := c.getJSON(ctx, token, "/v1/matches", q, &out) + return out, err +} + +// Match → GET /v1/matches/{id}. found=false on 404. +func (c *Client) Match(ctx context.Context, token, id string) (*MatchDetail, bool, error) { + var out MatchDetail + code, err := c.getJSON(ctx, token, "/v1/matches/"+url.PathEscape(id), nil, &out) + if code == http.StatusNotFound { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &out, true, nil +} + +// Me → GET /v1/me?competition=. found=false on 404. +func (c *Client) Me(ctx context.Context, token, comp string) (*Me, bool, error) { + var out struct { + Me *Me `json:"me"` + } + code, err := c.getJSON(ctx, token, "/v1/me", url.Values{"competition": {comp}}, &out) + if code == http.StatusNotFound { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if out.Me == nil { + return nil, false, nil + } + return out.Me, true, nil +} + +// Predictions → GET /v1/predictions?competition=&when=&limit=. +func (c *Client) Predictions(ctx context.Context, token, comp, when string, limit int) ([]Prediction, error) { + q := url.Values{"competition": {comp}, "when": {when}, "limit": {strconv.Itoa(limit)}} + var out []Prediction + _, err := c.getJSON(ctx, token, "/v1/predictions", q, &out) + return out, err +} + +// Leaderboard → GET /v1/leaderboard?competition=&group=&limit=; returns rows. +func (c *Client) Leaderboard(ctx context.Context, token, comp, groupID string, limit int) ([]LeaderRow, error) { + q := url.Values{"competition": {comp}, "limit": {strconv.Itoa(limit)}} + if groupID != "" { + q.Set("group", groupID) + } + var out struct { + Rows []LeaderRow `json:"rows"` + } + _, err := c.getJSON(ctx, token, "/v1/leaderboard", q, &out) + return out.Rows, err +} + +// Groups → GET /v1/groups?competition=; returns the caller's groups. +func (c *Client) Groups(ctx context.Context, token, comp string) ([]Group, error) { + var out struct { + Groups []Group `json:"groups"` + } + _, err := c.getJSON(ctx, token, "/v1/groups", url.Values{"competition": {comp}}, &out) + return out.Groups, err +} + +// Jokers → GET /v1/jokers?competition=. +func (c *Client) Jokers(ctx context.Context, token, comp string) ([]Joker, error) { + var out []Joker + _, err := c.getJSON(ctx, token, "/v1/jokers", url.Values{"competition": {comp}}, &out) + return out, err +} + +// Search → GET /v1/search?q=. +func (c *Client) Search(ctx context.Context, token, query string) (SearchResults, error) { + var out SearchResults + _, err := c.getJSON(ctx, token, "/v1/search", url.Values{"q": {query}}, &out) + return out, err +} + +// getJSON does an authenticated GET and decodes a 2xx body into dst. It returns +// the HTTP status (so callers can special-case 404) and a non-nil error on any +// non-2xx status or transport/decode failure. +func (c *Client) getJSON(ctx context.Context, token, path string, q url.Values, dst any) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil) + if err != nil { + return 0, err + } + if q != nil { + req.URL.RawQuery = q.Encode() + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := c.hc.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp.StatusCode, fmt.Errorf("footics-api GET %s: status %d", path, resp.StatusCode) + } + if err := json.Unmarshal(body, dst); err != nil { + return resp.StatusCode, fmt.Errorf("footics-api GET %s: decode: %w", path, err) + } + return resp.StatusCode, nil +} diff --git a/go/internal/auth/auth.go b/go/internal/auth/auth.go new file mode 100644 index 0000000..9728ce3 --- /dev/null +++ b/go/internal/auth/auth.go @@ -0,0 +1,238 @@ +// Package auth verifies Supabase-issued user JWTs (double-mode: JWKS or HS256) +// and adapts the result to the go-sdk auth.TokenVerifier contract. +// +// The verification logic mirrors footics-api internal/auth (same aud + exp + +// method checks, same JWKS cache with a refresh cooldown). The MCP server uses +// the extracted identity only to (a) answer whoami and key the rate-limiter and +// (b) relay the caller's Bearer to footics-api, which re-verifies as the trust +// boundary. It grants no data access on its own. +package auth + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + + "github.com/footics/mcp-server/internal/config" +) + +const ( + jwksTTL = time.Hour + jwksRefreshCooldown = 30 * time.Second + jwksMaxBody = 1 << 20 +) + +// ExtraEmail / ExtraToken are the keys under which the verifier stashes the +// caller's email and raw Bearer in TokenInfo.Extra (the latter is relayed to +// footics-api by the tool handlers). +const ( + ExtraEmail = "email" + ExtraToken = "token" +) + +// Verifier verifies Supabase user JWTs. Mode is fixed at boot: JWKS (asymmetric +// ES256/RS256, keys fetched from AUTH_JWKS_URL) or HS256 (AUTH_JWT_SECRET). +type Verifier struct { + secret []byte + jwks *jwksCache + opts []jwt.ParserOption + useHS bool +} + +// NewVerifier builds the verifier from config (exactly one mode selected). +func NewVerifier(cfg config.Config) *Verifier { + common := []jwt.ParserOption{ + jwt.WithAudience(cfg.JWTAud), + jwt.WithExpirationRequired(), + } + if cfg.UseJWKS() { + return &Verifier{ + jwks: newJWKSCache(cfg.JWKSURL), + opts: append(common, jwt.WithValidMethods([]string{"ES256", "RS256"})), + } + } + return &Verifier{ + secret: []byte(cfg.JWTSecret), + useHS: true, + opts: append(common, jwt.WithValidMethods([]string{"HS256"})), + } +} + +// Verify satisfies the go-sdk auth.TokenVerifier signature. On success it returns +// a TokenInfo carrying the user id, expiration, and (in Extra) the email + the +// raw Bearer for relay. On any failure it returns an error unwrapping to +// sdkauth.ErrInvalidToken so the middleware answers 401 + WWW-Authenticate. +func (v *Verifier) Verify(_ context.Context, token string, _ *http.Request) (*sdkauth.TokenInfo, error) { + claims := jwt.MapClaims{} + parsed, err := jwt.ParseWithClaims(token, claims, v.keyfunc, v.opts...) + if err != nil || !parsed.Valid { + return nil, fmt.Errorf("%w: %v", sdkauth.ErrInvalidToken, err) + } + sub, _ := claims["sub"].(string) // Supabase `sub` = user UUID + if sub == "" { + return nil, fmt.Errorf("%w: missing sub", sdkauth.ErrInvalidToken) + } + exp, err := claims.GetExpirationTime() + if err != nil || exp == nil { + return nil, fmt.Errorf("%w: missing exp", sdkauth.ErrInvalidToken) + } + email, _ := claims["email"].(string) + return &sdkauth.TokenInfo{ + UserID: sub, + Expiration: exp.Time, + Extra: map[string]any{ExtraEmail: email, ExtraToken: token}, + }, nil +} + +// keyfunc supplies the verification key for the token's algorithm, refusing any +// algorithm that does not belong to the configured mode. +func (v *Verifier) keyfunc(t *jwt.Token) (any, error) { + switch t.Method.(type) { + case *jwt.SigningMethodHMAC: + if !v.useHS { + return nil, jwt.ErrTokenSignatureInvalid + } + return v.secret, nil + case *jwt.SigningMethodECDSA, *jwt.SigningMethodRSA: + if v.useHS { + return nil, jwt.ErrTokenSignatureInvalid + } + kid, _ := t.Header["kid"].(string) + return v.jwks.key(kid) + default: + return nil, jwt.ErrTokenSignatureInvalid + } +} + +// --- JWKS cache (copied from footics-api internal/auth) --------------------- + +type jwksCache struct { + url string + hc *http.Client + + mu sync.Mutex + keys map[string]crypto.PublicKey + expiry time.Time + lastRefresh time.Time +} + +func newJWKSCache(url string) *jwksCache { + return &jwksCache{url: url, hc: &http.Client{Timeout: 5 * time.Second}, keys: map[string]crypto.PublicKey{}} +} + +func (c *jwksCache) key(kid string) (crypto.PublicKey, error) { + c.mu.Lock() + defer c.mu.Unlock() + if k, ok := c.keys[kid]; ok && time.Now().Before(c.expiry) { + return k, nil + } + // Cooldown: an unknown kid inside the window is rejected without a fetch, so a + // spray of bogus kids cannot turn into one origin request each. + if time.Since(c.lastRefresh) < jwksRefreshCooldown { + return nil, fmt.Errorf("unknown key id %q", kid) + } + if err := c.refresh(); err != nil { + return nil, err + } + if k, ok := c.keys[kid]; ok { + return k, nil + } + return nil, fmt.Errorf("unknown key id %q", kid) +} + +func (c *jwksCache) refresh() error { + c.lastRefresh = time.Now() // attempts count too: failures must not bypass the cooldown + + req, err := http.NewRequest(http.MethodGet, c.url, nil) + if err != nil { + return err + } + resp, err := c.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("jwks: %s returned %d", c.url, resp.StatusCode) + } + var set struct { + Keys []jwk `json:"keys"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, jwksMaxBody)).Decode(&set); err != nil { + return fmt.Errorf("jwks: decode %s: %w", c.url, err) + } + keys := make(map[string]crypto.PublicKey, len(set.Keys)) + for _, k := range set.Keys { + pub, err := k.publicKey() + if err != nil { + continue // skip an unsupported key, keep the usable ones + } + keys[k.Kid] = pub + } + if len(keys) == 0 { + return fmt.Errorf("jwks: no usable keys at %s", c.url) + } + c.keys = keys + c.expiry = time.Now().Add(jwksTTL) + return nil +} + +// jwk is one JSON Web Key (subset: EC/P-256 and RSA public keys). +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Crv string `json:"crv"` + N string `json:"n"` + E string `json:"e"` + X string `json:"x"` + Y string `json:"y"` +} + +func (k jwk) publicKey() (crypto.PublicKey, error) { + switch k.Kty { + case "EC": + if k.Crv != "P-256" { + return nil, fmt.Errorf("unsupported EC curve %q", k.Crv) + } + x, err := b64(k.X) + if err != nil { + return nil, err + } + y, err := b64(k.Y) + if err != nil { + return nil, err + } + return &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}, nil + case "RSA": + n, err := b64(k.N) + if err != nil { + return nil, err + } + e, err := b64(k.E) + if err != nil { + return nil, err + } + exp := 0 + for _, b := range e { + exp = exp<<8 | int(b) + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: exp}, nil + default: + return nil, fmt.Errorf("unsupported key type %q", k.Kty) + } +} + +func b64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } diff --git a/go/internal/auth/auth_test.go b/go/internal/auth/auth_test.go new file mode 100644 index 0000000..9b034b3 --- /dev/null +++ b/go/internal/auth/auth_test.go @@ -0,0 +1,85 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + + "github.com/footics/mcp-server/internal/config" +) + +const testSecret = "dev-staging-hs256-secret-000000" + +func hsVerifier() *Verifier { + return NewVerifier(config.Config{JWTSecret: testSecret, JWTAud: "authenticated"}) +} + +func mintHS(t *testing.T, secret string, claims jwt.MapClaims) string { + t.Helper() + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatalf("sign: %v", err) + } + return s +} + +func TestVerifyValidHS256(t *testing.T) { + tok := mintHS(t, testSecret, jwt.MapClaims{ + "sub": "user-123", "aud": "authenticated", "email": "a@b.co", + "exp": time.Now().Add(time.Hour).Unix(), + }) + ti, err := hsVerifier().Verify(context.Background(), tok, nil) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if ti.UserID != "user-123" { + t.Errorf("UserID = %q", ti.UserID) + } + if email, _ := ti.Extra[ExtraEmail].(string); email != "a@b.co" { + t.Errorf("email = %q", email) + } + if relayed, _ := ti.Extra[ExtraToken].(string); relayed != tok { + t.Error("raw token not stashed for relay") + } + if ti.Expiration.Before(time.Now()) { + t.Error("Expiration in the past") + } +} + +func TestVerifyRejects(t *testing.T) { + future := time.Now().Add(time.Hour).Unix() + cases := map[string]string{ + "wrong secret": mintHS(t, "not-the-secret", jwt.MapClaims{"sub": "u", "aud": "authenticated", "exp": future}), + "wrong aud": mintHS(t, testSecret, jwt.MapClaims{"sub": "u", "aud": "someone-else", "exp": future}), + "expired": mintHS(t, testSecret, jwt.MapClaims{"sub": "u", "aud": "authenticated", "exp": time.Now().Add(-time.Hour).Unix()}), + "missing sub": mintHS(t, testSecret, jwt.MapClaims{"aud": "authenticated", "exp": future}), + "missing exp": mintHS(t, testSecret, jwt.MapClaims{"sub": "u", "aud": "authenticated"}), + "garbage": "not.a.jwt", + } + v := hsVerifier() + for name, tok := range cases { + t.Run(name, func(t *testing.T) { + _, err := v.Verify(context.Background(), tok, nil) + if err == nil { + t.Fatal("want error, got nil") + } + if !errors.Is(err, sdkauth.ErrInvalidToken) { + t.Errorf("error %v does not unwrap to ErrInvalidToken", err) + } + }) + } +} + +func TestVerifyRejectsRS256InHSMode(t *testing.T) { + // An HS-mode verifier must refuse an asymmetric alg (alg-confusion guard). + tok := mintHS(t, testSecret, jwt.MapClaims{"sub": "u", "aud": "authenticated", "exp": time.Now().Add(time.Hour).Unix()}) + // Tamper the header alg is overkill; instead assert JWKS-mode verifier refuses HS256. + jwksV := NewVerifier(config.Config{JWKSURL: "https://example.invalid/jwks", JWTAud: "authenticated"}) + if _, err := jwksV.Verify(context.Background(), tok, nil); err == nil { + t.Fatal("JWKS-mode verifier accepted an HS256 token") + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go new file mode 100644 index 0000000..6ec65c8 --- /dev/null +++ b/go/internal/config/config.go @@ -0,0 +1,127 @@ +// Package config loads runtime configuration from the environment, failing +// closed with a clear error when a required value is missing or invalid. +// +// The Go MCP server is a thin client of footics-api /v1: it verifies the +// Supabase JWT locally (double-mode JWKS/HS256, same logic as footics-api +// internal/auth) and relays the caller's Bearer to the API. It has NO database +// access — there is deliberately no DATABASE_URL here. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// DefaultRateLimitPerMin is the per-user tool-call ceiling (calls/minute). +const DefaultRateLimitPerMin = 30 + +// Config holds all runtime configuration, built once at boot by Load. +type Config struct { + HTTPAddr string // HTTP_ADDR (default ":8080") + + // APIBaseURL is footics-api's base URL (FOOTICS_API_URL), e.g. + // http://api:8080 in the compose network. The client appends /v1/... paths + // and relays the caller's Bearer. Required. + APIBaseURL string + + // PublicURL is this server's public origin (MCP_PUBLIC_URL). Resource is + // PublicURL + "/mcp" — the OAuth protected-resource identifier. + PublicURL string + Resource string + + // User-auth mode, driven by which of these is set (exactly one), mirroring + // footics-api: JWKSURL selects asymmetric verification (ES256/RS256), JWTSecret + // selects HS256 (legacy Supabase secret). Aud defaults to "authenticated". + JWKSURL string // AUTH_JWKS_URL + JWTSecret string // AUTH_JWT_SECRET + JWTAud string // AUTH_JWT_AUD + + // OAuthIssuer is advertised as authorization_servers[0] in the protected + // resource metadata (MCP_OAUTH_ISSUER), e.g. https://.supabase.co/auth/v1. + // Empty → omitted from the metadata. + OAuthIssuer string + + Enabled bool // MCP_ENABLED (default true) — kill switch (503) + RequireAuth bool // MCP_REQUIRE_AUTH (default true) + EnableWrites bool // MCP_ENABLE_WRITES (default false) — submit_prediction + RateLimitPerMin int // MCP_RATE_LIMIT_PER_MIN (default 30; 0 = off) + TestUserID string // MCP_TEST_USER_ID — identity fallback when RequireAuth=false +} + +// UseJWKS reports whether the asymmetric (JWKS) verification mode is selected. +func (c Config) UseJWKS() bool { return c.JWKSURL != "" } + +// Load reads configuration from the environment, applying defaults and returning +// a clear error for any missing-required or invalid value. +func Load() (Config, error) { + public := strings.TrimRight(getenv("MCP_PUBLIC_URL", "https://mcp.footics.app"), "/") + c := Config{ + HTTPAddr: getenv("HTTP_ADDR", ":8080"), + APIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("FOOTICS_API_URL")), "/"), + PublicURL: public, + Resource: public + "/mcp", + JWKSURL: strings.TrimSpace(os.Getenv("AUTH_JWKS_URL")), + JWTSecret: strings.TrimSpace(os.Getenv("AUTH_JWT_SECRET")), + JWTAud: getenv("AUTH_JWT_AUD", "authenticated"), + OAuthIssuer: strings.TrimSpace(os.Getenv("MCP_OAUTH_ISSUER")), + Enabled: getenvBool("MCP_ENABLED", true), + RequireAuth: getenvBool("MCP_REQUIRE_AUTH", true), + EnableWrites: getenvBool("MCP_ENABLE_WRITES", false), + TestUserID: strings.TrimSpace(os.Getenv("MCP_TEST_USER_ID")), + } + + rl, err := getenvInt("MCP_RATE_LIMIT_PER_MIN", DefaultRateLimitPerMin) + if err != nil { + return c, err + } + c.RateLimitPerMin = rl + + if c.APIBaseURL == "" { + return c, fmt.Errorf("FOOTICS_API_URL is required (base URL of footics-api /v1)") + } + + // Exactly one of AUTH_JWKS_URL / AUTH_JWT_SECRET — they select mutually + // exclusive verification modes (mirrors footics-api config.Load). + switch { + case c.JWKSURL == "" && c.JWTSecret == "": + return c, fmt.Errorf("exactly one of AUTH_JWKS_URL or AUTH_JWT_SECRET is required, got neither") + case c.JWKSURL != "" && c.JWTSecret != "": + return c, fmt.Errorf("AUTH_JWKS_URL and AUTH_JWT_SECRET are mutually exclusive, got both") + } + + return c, nil +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// getenvBool mirrors the TS parsing: "false" (case-sensitive) disables a +// default-true flag; "true" enables a default-false flag; anything else = default. +func getenvBool(k string, def bool) bool { + v := os.Getenv(k) + if v == "" { + return def + } + if def { + return v != "false" + } + return v == "true" +} + +func getenvInt(k string, def int) (int, error) { + v := os.Getenv(k) + if v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s must be an integer, got %q", k, v) + } + return n, nil +} diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go new file mode 100644 index 0000000..d2f5022 --- /dev/null +++ b/go/internal/config/config_test.go @@ -0,0 +1,72 @@ +package config + +import "testing" + +func TestLoadDefaults(t *testing.T) { + t.Setenv("FOOTICS_API_URL", "http://api:8080/") + t.Setenv("AUTH_JWT_SECRET", "s3cr3t") + t.Setenv("MCP_PUBLIC_URL", "https://mcp.footics.app/") + + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.APIBaseURL != "http://api:8080" { + t.Errorf("APIBaseURL = %q, want trailing slash trimmed", c.APIBaseURL) + } + if c.Resource != "https://mcp.footics.app/mcp" { + t.Errorf("Resource = %q", c.Resource) + } + if !c.Enabled || !c.RequireAuth || c.EnableWrites { + t.Errorf("flags: enabled=%v requireAuth=%v writes=%v, want true/true/false", c.Enabled, c.RequireAuth, c.EnableWrites) + } + if c.RateLimitPerMin != DefaultRateLimitPerMin { + t.Errorf("RateLimitPerMin = %d, want %d", c.RateLimitPerMin, DefaultRateLimitPerMin) + } + if c.JWTAud != "authenticated" { + t.Errorf("JWTAud = %q, want authenticated", c.JWTAud) + } + if c.UseJWKS() { + t.Error("UseJWKS true with only a secret set") + } +} + +func TestLoadRequiresAPIURL(t *testing.T) { + t.Setenv("AUTH_JWT_SECRET", "s") + if _, err := Load(); err == nil { + t.Fatal("want error when FOOTICS_API_URL is unset") + } +} + +func TestLoadAuthModeExclusive(t *testing.T) { + t.Setenv("FOOTICS_API_URL", "http://api:8080") + + // neither → error + if _, err := Load(); err == nil { + t.Fatal("want error when neither JWKS nor secret is set") + } + // both → error + t.Setenv("AUTH_JWKS_URL", "https://x/jwks") + t.Setenv("AUTH_JWT_SECRET", "s") + if _, err := Load(); err == nil { + t.Fatal("want error when both JWKS and secret are set") + } +} + +func TestLoadWritesToggle(t *testing.T) { + t.Setenv("FOOTICS_API_URL", "http://api:8080") + t.Setenv("AUTH_JWT_SECRET", "s") + t.Setenv("MCP_ENABLE_WRITES", "true") + t.Setenv("MCP_ENABLED", "false") + + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !c.EnableWrites { + t.Error("MCP_ENABLE_WRITES=true not honoured") + } + if c.Enabled { + t.Error("MCP_ENABLED=false not honoured") + } +} diff --git a/go/internal/tools/integration_test.go b/go/internal/tools/integration_test.go new file mode 100644 index 0000000..5e0479a --- /dev/null +++ b/go/internal/tools/integration_test.go @@ -0,0 +1,307 @@ +package tools + +// integration_test.go — end-to-end against the running staging footics-api +// (http://127.0.0.1:8099). It stands up the REAL stack (RequireBearerToken → +// Streamable HTTP → tools → /v1 relay), connects a go-sdk MCP client carrying a +// minted alice Bearer, invokes every read tool, and asserts the frozen shape on +// live data. Skips cleanly when the staging API is unreachable. +// +// Run: go test ./internal/tools/ -run TestIntegration -v + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/footics/mcp-server/internal/apiclient" + "github.com/footics/mcp-server/internal/auth" + "github.com/footics/mcp-server/internal/config" +) + +const ( + stagingURL = "http://127.0.0.1:8099" + stagingSecret = "dev-staging-hs256-secret-000000" + aliceSub = "3da4cf94-51f9-416f-9e0e-532ca33fa2a5" + aliceEmail = "alice@staging.test" +) + +// bearerRT injects the caller's Bearer on every client request (the client +// transport has no header hook otherwise). +type bearerRT struct { + token string + base http.RoundTripper +} + +func (b bearerRT) RoundTrip(r *http.Request) (*http.Response, error) { + r2 := r.Clone(r.Context()) + r2.Header.Set("Authorization", "Bearer "+b.token) + return b.base.RoundTrip(r2) +} + +func mintAlice(t *testing.T) string { + t.Helper() + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": aliceSub, "aud": "authenticated", "email": aliceEmail, + "exp": time.Now().Add(time.Hour).Unix(), + }).SignedString([]byte(stagingSecret)) + if err != nil { + t.Fatalf("mint: %v", err) + } + return s +} + +func stagingReachable() bool { + c := &http.Client{Timeout: 1500 * time.Millisecond} + resp, err := c.Get(stagingURL + "/healthz") + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// session stands up the real MCP stack in front of the staging API and returns a +// connected client session. +func session(t *testing.T) (*mcp.ClientSession, context.Context) { + t.Helper() + cfg := config.Config{ + JWTSecret: stagingSecret, JWTAud: "authenticated", + APIBaseURL: stagingURL, PublicURL: "http://mcp.test", Resource: "http://mcp.test/mcp", + RequireAuth: true, Enabled: true, RateLimitPerMin: 0, + } + server := mcp.NewServer(&mcp.Implementation{Name: "footics-mcp-test", Version: "test"}, nil) + Register(server, Deps{API: apiclient.New(cfg.APIBaseURL), RateLimitPerMin: 0}) + + streamable := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return server }, + &mcp.StreamableHTTPOptions{Stateless: true}, + ) + authed := sdkauth.RequireBearerToken(auth.NewVerifier(cfg).Verify, + &sdkauth.RequireBearerTokenOptions{ResourceMetadataURL: cfg.Resource})(streamable) + + ts := httptest.NewServer(authed) + t.Cleanup(ts.Close) + + hc := &http.Client{Transport: bearerRT{token: mintAlice(t), base: http.DefaultTransport}} + transport := &mcp.StreamableClientTransport{Endpoint: ts.URL, HTTPClient: hc, DisableStandaloneSSE: true} + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + t.Cleanup(cancel) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil) + sess, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = sess.Close() }) + return sess, ctx +} + +// call invokes a tool and returns its decoded (non-error) JSON payload. +func call(t *testing.T, sess *mcp.ClientSession, ctx context.Context, name string, args map[string]any) any { + t.Helper() + res, err := sess.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("%s: transport error: %v", name, err) + } + if len(res.Content) != 1 { + t.Fatalf("%s: want 1 content block, got %d", name, len(res.Content)) + } + tc, ok := res.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("%s: content not text: %T", name, res.Content[0]) + } + if res.IsError { + t.Fatalf("%s: tool error: %s", name, tc.Text) + } + var v any + if err := json.Unmarshal([]byte(tc.Text), &v); err != nil { + t.Fatalf("%s: bad JSON: %v\n%s", name, err, tc.Text) + } + return v +} + +func TestIntegrationReadTools(t *testing.T) { + if !stagingReachable() { + t.Skipf("staging API not reachable at %s — skipping", stagingURL) + } + sess, ctx := session(t) + + // tools/list — all 10 tools present. + lt, err := sess.ListTools(ctx, nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + names := map[string]bool{} + for _, tool := range lt.Tools { + names[tool.Name] = true + } + for _, want := range []string{"whoami", "list_matches", "get_match", "get_my_standing", "get_my_predictions", "get_leaderboard", "list_my_groups", "get_joker_status", "search", "submit_prediction"} { + if !names[want] { + t.Errorf("tools/list missing %q", want) + } + } + if len(lt.Tools) != 10 { + t.Errorf("tools/list count = %d, want 10", len(lt.Tools)) + } + + // whoami — identity straight from the JWT. + who := call(t, sess, ctx, "whoami", nil).(map[string]any) + if who["userId"] != aliceSub { + t.Errorf("whoami userId = %v", who["userId"]) + } + if who["email"] != aliceEmail { + t.Errorf("whoami email = %v", who["email"]) + } + + // list_matches — flat array; FRA-MAR present with alice's prediction. + matches := call(t, sess, ctx, "list_matches", map[string]any{"competition": "wc"}).([]any) + if len(matches) == 0 { + t.Fatal("list_matches empty") + } + fraMar := findMatch(matches, "FRA", "MAR") + if fraMar == nil { + t.Fatal("list_matches: FRA-MAR not found") + } + assertMatchShape(t, fraMar) + if fraMar["myPrediction"] == nil { + t.Error("FRA-MAR: alice's myPrediction should be present") + } + matchID, _ := fraMar["id"].(string) + + // get_match — detail carries an events array. + detail := call(t, sess, ctx, "get_match", map[string]any{"matchId": matchID}).(map[string]any) + assertMatchShape(t, detail) + if _, ok := detail["events"].([]any); !ok { + t.Errorf("get_match: events not an array: %T", detail["events"]) + } + + // get_my_standing — alice's standing for wc. + st := call(t, sess, ctx, "get_my_standing", map[string]any{"competition": "wc"}).(map[string]any) + for _, k := range []string{"userId", "username", "competition", "points", "exact", "rank", "rankOf"} { + if _, ok := st[k]; !ok { + t.Errorf("get_my_standing missing %q", k) + } + } + if st["userId"] != aliceSub || st["username"] != "alice" { + t.Errorf("get_my_standing identity = %v/%v", st["userId"], st["username"]) + } + + // get_my_predictions — prediction has {home,away,joker} and NO winnerTeamCode. + preds := call(t, sess, ctx, "get_my_predictions", map[string]any{"competition": "wc", "when": "all"}).([]any) + if len(preds) == 0 { + t.Fatal("get_my_predictions empty (alice has predictions in the seed)") + } + p0 := preds[0].(map[string]any) + for _, k := range []string{"matchId", "fixture", "kickoffAt", "status", "prediction", "result", "points"} { + if _, ok := p0[k]; !ok { + t.Errorf("prediction missing %q", k) + } + } + pred := p0["prediction"].(map[string]any) + if _, leaked := pred["winnerTeamCode"]; leaked { + t.Error("prediction leaked winnerTeamCode (must be dropped in the frozen shape)") + } + for _, k := range []string{"home", "away", "joker"} { + if _, ok := pred[k]; !ok { + t.Errorf("prediction.%s missing", k) + } + } + + // get_leaderboard — flat rows, alice flagged me:true. + board := call(t, sess, ctx, "get_leaderboard", map[string]any{"competition": "wc"}).([]any) + if len(board) == 0 { + t.Fatal("get_leaderboard empty") + } + var sawMe bool + for _, r := range board { + row := r.(map[string]any) + for _, k := range []string{"rank", "username", "points", "exact", "me"} { + if _, ok := row[k]; !ok { + t.Errorf("leaderboard row missing %q", k) + } + } + if row["me"] == true { + sawMe = true + } + } + if !sawMe { + t.Error("get_leaderboard: no row flagged me:true for alice") + } + + // list_my_groups — array (alice may have none → []). + if _, ok := call(t, sess, ctx, "list_my_groups", map[string]any{"competition": "wc"}).([]any); !ok { + t.Error("list_my_groups did not return an array") + } + + // get_joker_status — buckets; wc has a "group" bucket, quota 12. + jokers := call(t, sess, ctx, "get_joker_status", map[string]any{"competition": "wc"}).([]any) + if len(jokers) == 0 { + t.Fatal("get_joker_status empty") + } + var groupBucket map[string]any + for _, j := range jokers { + b := j.(map[string]any) + for _, k := range []string{"bucket", "label", "used", "quota", "remaining"} { + if _, ok := b[k]; !ok { + t.Errorf("joker bucket missing %q", k) + } + } + if b["bucket"] == "group" { + groupBucket = b + } + } + if groupBucket == nil || groupBucket["quota"].(float64) != 12 { + t.Errorf("get_joker_status: group bucket wrong: %v", groupBucket) + } + + // search — {matches,groups,users}; FRA-MAR surfaced by "fra". + sr := call(t, sess, ctx, "search", map[string]any{"query": "fra"}).(map[string]any) + for _, k := range []string{"matches", "groups", "users"} { + if _, ok := sr[k].([]any); !ok { + t.Errorf("search.%s not an array", k) + } + } + if len(sr["matches"].([]any)) == 0 { + t.Error("search 'fra' found no matches (expected FRA-MAR)") + } + + // submit_prediction — stub: writes OFF ⇒ tool error with the FR message. + res, err := sess.CallTool(ctx, &mcp.CallToolParams{Name: "submit_prediction", Arguments: map[string]any{"matchId": matchID, "homeScore": 1, "awayScore": 0}}) + if err != nil { + t.Fatalf("submit_prediction transport error: %v", err) + } + if !res.IsError { + t.Error("submit_prediction should be a tool error while writes are OFF") + } +} + +func findMatch(matches []any, home, away string) map[string]any { + for _, m := range matches { + mm := m.(map[string]any) + h, _ := mm["home"].(map[string]any) + a, _ := mm["away"].(map[string]any) + if h != nil && a != nil && h["code"] == home && a["code"] == away { + return mm + } + } + return nil +} + +func assertMatchShape(t *testing.T, m map[string]any) { + t.Helper() + for _, k := range []string{"id", "competition", "phase", "group", "status", "kickoffAt", "venue", "home", "away", "score", "live", "myPrediction"} { + if _, ok := m[k]; !ok { + t.Errorf("match missing key %q", k) + } + } + if m["competition"] != "wc" && m["competition"] != "friendlies" { + t.Errorf("match.competition = %v, want wc|friendlies", m["competition"]) + } +} diff --git a/go/internal/tools/ratelimit.go b/go/internal/tools/ratelimit.go new file mode 100644 index 0000000..08159bb --- /dev/null +++ b/go/internal/tools/ratelimit.go @@ -0,0 +1,72 @@ +package tools + +// ratelimit.go — per-user fixed-window limiter, ported from lib/rate-limit.ts. +// On the single long-lived VPS process this is finally a real global ceiling +// (the serverless TS server counted per-lambda). footics-api has its own limiter +// + Cloudflare WAF in front, so this is a courtesy cap, not the security control. + +import ( + "sync" + "time" +) + +const ( + rlWindow = time.Minute + rlMaxEntries = 10_000 +) + +type rlEntry struct { + startedAt time.Time + count int +} + +type rateLimiter struct { + perMin int + mu sync.Mutex + // ponytail: one global map + mutex. Fine at Footics' scale (a handful of + // calls/sec); shard by user hash only if this mutex ever shows up in a profile. + windows map[string]*rlEntry +} + +func newRateLimiter(perMin int) *rateLimiter { + return &rateLimiter{perMin: perMin, windows: make(map[string]*rlEntry)} +} + +// retryAfter returns 0 when the call is allowed, or the seconds to wait when the +// user's window is full (mirrors rateLimit() in the TS server). +func (rl *rateLimiter) retryAfter(userID string, now time.Time) int { + if rl.perMin <= 0 { + return 0 + } + rl.mu.Lock() + defer rl.mu.Unlock() + + w := rl.windows[userID] + if w == nil || now.Sub(w.startedAt) >= rlWindow { + if len(rl.windows) >= rlMaxEntries { + rl.prune(now) + } + rl.windows[userID] = &rlEntry{startedAt: now, count: 1} + return 0 + } + if w.count < rl.perMin { + w.count++ + return 0 + } + remaining := w.startedAt.Add(rlWindow).Sub(now).Seconds() + if remaining < 1 { + return 1 + } + return int(remaining) + 1 // ceil, matching Math.ceil in the TS server +} + +func (rl *rateLimiter) prune(now time.Time) { + for k, w := range rl.windows { + if now.Sub(w.startedAt) >= rlWindow { + delete(rl.windows, k) + } + } + if len(rl.windows) >= rlMaxEntries { + rl.windows = make(map[string]*rlEntry) + } +} diff --git a/go/internal/tools/schemas.go b/go/internal/tools/schemas.go new file mode 100644 index 0000000..9458c0b --- /dev/null +++ b/go/internal/tools/schemas.go @@ -0,0 +1,137 @@ +package tools + +// schemas.go — the FROZEN input schemas, fixed EXPLICITLY (not inferred from Go +// structs) so tools/list advertises the same params, enums, bounds, defaults and +// FR descriptions as the zod-derived TS schemas. The SDK applies schema defaults +// (competition→"wc", when→"all", joker→false) before validating and unmarshaling, +// so absent optional args resolve exactly as they did in the TS server. + +import ( + "encoding/json" + + "github.com/google/jsonschema-go/jsonschema" +) + +// typed argument structs (only used to unmarshal the defaults-applied arguments; +// the wire schema is the explicit one set on each Tool). +type ( + argsWhoami struct{} + argsListMatches struct { + Competition string `json:"competition"` + Status string `json:"status"` + Limit int `json:"limit"` + } + argsGetMatch struct { + MatchID string `json:"matchId"` + } + argsComp struct { + Competition string `json:"competition"` + } + argsPredictions struct { + Competition string `json:"competition"` + When string `json:"when"` + Limit int `json:"limit"` + } + argsLeaderboard struct { + Competition string `json:"competition"` + GroupID string `json:"groupId"` + Limit int `json:"limit"` + } + argsSearch struct { + Query string `json:"query"` + } + argsSubmit struct { + MatchID string `json:"matchId"` + HomeScore int `json:"homeScore"` + AwayScore int `json:"awayScore"` + Joker bool `json:"joker"` + WinnerTeamCode string `json:"winnerTeamCode"` + } +) + +func f64(v float64) *float64 { return &v } +func iptr(v int) *int { return &v } + +func rawDefault(v any) json.RawMessage { + b, _ := json.Marshal(v) + return b +} + +func object(props map[string]*jsonschema.Schema, required ...string) *jsonschema.Schema { + return &jsonschema.Schema{Type: "object", Properties: props, Required: required} +} + +// competitionSchema is the shared `competition` param (enum + default wc). +func competitionSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "string", + Enum: []any{"wc", "friendlies"}, + Default: rawDefault("wc"), + Description: `Compétition : "wc" (Coupe du monde) ou "friendlies" (amicaux). Défaut: wc.`, + } +} + +func schemaWhoami() *jsonschema.Schema { return object(map[string]*jsonschema.Schema{}) } + +func schemaListMatches() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "competition": competitionSchema(), + "status": { + Type: "string", + Enum: []any{"scheduled", "live", "finished", "postponed", "cancelled"}, + Description: "Filtre de statut optionnel.", + }, + "limit": {Type: "integer", Minimum: f64(1), Maximum: f64(200), Description: "Nombre max de matchs (défaut 200)."}, + }) +} + +func schemaGetMatch() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "matchId": {Type: "string", MinLength: iptr(1), Description: "L'id (uuid) du match."}, + }, "matchId") +} + +func schemaComp() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{"competition": competitionSchema()}) +} + +func schemaPredictions() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "competition": competitionSchema(), + "when": { + Type: "string", + Enum: []any{"upcoming", "past", "all"}, + Default: rawDefault("all"), + }, + "limit": {Type: "integer", Minimum: f64(1), Maximum: f64(200)}, + }) +} + +func schemaLeaderboard() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "competition": competitionSchema(), + "groupId": {Type: "string", Description: "Id d'un groupe dont je suis membre (sinon classement général)."}, + "limit": {Type: "integer", Minimum: f64(1), Maximum: f64(200)}, + }) +} + +func schemaSearch() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "query": {Type: "string", MinLength: iptr(1), Description: "Texte recherché."}, + }, "query") +} + +func schemaSubmit() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "matchId": {Type: "string", MinLength: iptr(1), Description: "L'id (uuid) du match — voir list_matches/search."}, + "homeScore": {Type: "integer", Minimum: f64(0), Maximum: f64(20), Description: "Score prédit de l'équipe à domicile (0-20)."}, + "awayScore": {Type: "integer", Minimum: f64(0), Maximum: f64(20), Description: "Score prédit de l'équipe à l'extérieur (0-20)."}, + "joker": {Type: "boolean", Default: rawDefault(false), Description: "Appliquer un joker (double les points). Défaut: false."}, + "winnerTeamCode": { + Type: "string", + MinLength: iptr(2), + MaxLength: iptr(3), + Description: "Match à élimination directe + nul prédit UNIQUEMENT : code FIFA-3 de l'équipe qui se qualifie (pour le +1). Doit être l'une des 2 équipes. Ignoré sur un score décisif ou en phase de poules.", + }, + }, "matchId", "homeScore", "awayScore") +} diff --git a/go/internal/tools/shapes.go b/go/internal/tools/shapes.go new file mode 100644 index 0000000..9d4821b --- /dev/null +++ b/go/internal/tools/shapes.go @@ -0,0 +1,328 @@ +package tools + +// shapes.go — the FROZEN MCP output shapes and the projections from the /v1 +// view-model into them. These structs (field names, order, and null semantics) +// are the public contract that existing MCP connectors depend on; they mirror +// lib/tools.ts + lib/queries.ts of the TS server exactly. +// +// Null semantics: a "T | null" frozen field is a pointer WITHOUT omitempty, so a +// nil marshals to `null` (present key), matching JSON.stringify of the TS +// view-model. Slices are pre-allocated non-nil so an empty result marshals to +// `[]`, never `null`. + +import ( + "time" + + "github.com/footics/mcp-server/internal/apiclient" +) + +/* ── whoami ────────────────────────────────────────────────────────────────── */ + +type outWhoami struct { + UserID string `json:"userId"` + Email *string `json:"email"` +} + +/* ── match (list_matches / get_match) ──────────────────────────────────────── */ + +type outTeam struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type outScore struct { + Home int `json:"home"` + Away int `json:"away"` +} + +type outLive struct { + Period string `json:"period"` + Minute *int `json:"minute"` + Running bool `json:"running"` +} + +type outMyPred struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + Points *int `json:"points"` +} + +type outMatch struct { + ID string `json:"id"` + Competition string `json:"competition"` + Phase string `json:"phase"` + Group *string `json:"group"` + Status string `json:"status"` + KickoffAt string `json:"kickoffAt"` + Venue *string `json:"venue"` + Home outTeam `json:"home"` + Away outTeam `json:"away"` + Score *outScore `json:"score"` + Live *outLive `json:"live"` + MyPrediction *outMyPred `json:"myPrediction"` +} + +type outEvent struct { + Type string `json:"type"` + Period string `json:"period"` + Minute int `json:"minute"` + MinutePlus *int `json:"minutePlus"` + TeamCode *string `json:"teamCode"` + Player *string `json:"player"` + Detail *string `json:"detail"` +} + +// outMatchDetail is outMatch + events (events appended last, mirroring the TS +// spread `{...base, events}`). +type outMatchDetail struct { + outMatch + Events []outEvent `json:"events"` +} + +func projectMatch(m apiclient.Match) outMatch { + // score: present only for live|finished. TS reads the persisted home/away + // score column: Score for finished, LiveScore for live. + var score *outScore + switch m.Status { + case "finished": + score = fromScore(m.Score) + case "live": + score = fromScore(m.LiveScore) + } + + var live *outLive + if m.Status == "live" && m.Live != nil { + live = &outLive{Period: m.Live.Period, Minute: m.Live.DisplayMinute, Running: m.Live.Running} + } + + var pred *outMyPred + if m.Mine != nil { + joker := false + if m.JokerApplied != nil { + joker = *m.JokerApplied + } + pred = &outMyPred{Home: m.Mine.H, Away: m.Mine.A, Joker: joker, Points: m.Points} + } + + return outMatch{ + ID: m.ID, + Competition: m.Competition.Kind, + Phase: m.Phase, + Group: m.Group, + Status: m.Status, + KickoffAt: toISO(m.KickoffAt), + Venue: nilIfEmpty(m.Venue), + Home: outTeam{Code: m.Home.Code, Name: m.Home.Name}, + Away: outTeam{Code: m.Away.Code, Name: m.Away.Name}, + Score: score, + Live: live, + MyPrediction: pred, + } +} + +func projectMatchDetail(d apiclient.MatchDetail) outMatchDetail { + base := projectMatch(d.Match) + events := make([]outEvent, 0, len(d.Events)) // [] not null + if base.Status != "scheduled" { // TS: [] before a match starts + for _, e := range d.Events { + events = append(events, outEvent{ + Type: e.Type, Period: e.Period, Minute: e.Minute, + MinutePlus: e.MinutePlus, TeamCode: e.TeamCode, Player: e.Player, Detail: e.Detail, + }) + } + } + return outMatchDetail{outMatch: base, Events: events} +} + +/* ── get_my_standing ───────────────────────────────────────────────────────── */ + +type outStanding struct { + UserID string `json:"userId"` + Username string `json:"username"` + Competition string `json:"competition"` + Points int `json:"points"` + Exact int `json:"exact"` + Rank int `json:"rank"` + RankOf int `json:"rankOf"` +} + +func projectStanding(me apiclient.Me, comp string) outStanding { + return outStanding{ + UserID: me.ID, Username: me.Username, Competition: comp, + Points: me.Points, Exact: me.Exact, Rank: me.Rank, RankOf: me.RankOf, + } +} + +/* ── get_my_predictions ────────────────────────────────────────────────────── */ + +type outPredScore struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` +} + +type outPrediction struct { + MatchID string `json:"matchId"` + Fixture string `json:"fixture"` + KickoffAt string `json:"kickoffAt"` + Status string `json:"status"` + Prediction outPredScore `json:"prediction"` + Result *outScore `json:"result"` + Points *int `json:"points"` +} + +func projectPredictions(ps []apiclient.Prediction) []outPrediction { + out := make([]outPrediction, 0, len(ps)) + for _, p := range ps { + var result *outScore + if p.Result != nil { + result = &outScore{Home: p.Result.Home, Away: p.Result.Away} + } + out = append(out, outPrediction{ + MatchID: p.MatchID, + Fixture: p.Fixture, + KickoffAt: toISO(p.KickoffAt), + Status: p.Status, + Prediction: outPredScore{Home: p.Prediction.Home, Away: p.Prediction.Away, Joker: p.Prediction.Joker}, + Result: result, + Points: p.Points, + }) + } + return out +} + +/* ── get_leaderboard ───────────────────────────────────────────────────────── */ + +type outLeaderRow struct { + Rank int `json:"rank"` + Username string `json:"username"` + Points int `json:"points"` + Exact int `json:"exact"` + Me bool `json:"me"` +} + +func projectLeaderboard(rows []apiclient.LeaderRow) []outLeaderRow { + out := make([]outLeaderRow, 0, len(rows)) + for _, r := range rows { + out = append(out, outLeaderRow{Rank: r.Rank, Username: r.Username, Points: r.Points, Exact: r.Exact, Me: r.Me}) + } + return out +} + +/* ── list_my_groups ────────────────────────────────────────────────────────── */ + +type outGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Members int `json:"members"` + MyRank int `json:"myRank"` + MyPoints int `json:"myPoints"` + Owner bool `json:"owner"` +} + +func projectGroups(gs []apiclient.Group) []outGroup { + out := make([]outGroup, 0, len(gs)) + for _, g := range gs { + out = append(out, outGroup{ + ID: g.ID, Name: g.Name, Code: g.Code, Members: g.Members, + MyRank: g.MyRank, MyPoints: g.MyPoints, Owner: g.Owner != nil, + }) + } + return out +} + +/* ── get_joker_status ──────────────────────────────────────────────────────── */ + +type outJoker struct { + Bucket string `json:"bucket"` + Label string `json:"label"` + Used int `json:"used"` + Quota int `json:"quota"` + Remaining int `json:"remaining"` +} + +func projectJokers(js []apiclient.Joker) []outJoker { + out := make([]outJoker, 0, len(js)) + for _, j := range js { + out = append(out, outJoker{Bucket: j.Bucket, Label: j.Label, Used: j.Used, Quota: j.Quota, Remaining: j.Remaining}) + } + return out +} + +/* ── search ────────────────────────────────────────────────────────────────── */ + +type outSearchMatch struct { + ID string `json:"id"` + Fixture string `json:"fixture"` + KickoffAt string `json:"kickoffAt"` +} + +type outSearchGroup struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type outSearchUser struct { + ID string `json:"id"` + Username string `json:"username"` + Me bool `json:"me"` +} + +type outSearch struct { + Matches []outSearchMatch `json:"matches"` + Groups []outSearchGroup `json:"groups"` + Users []outSearchUser `json:"users"` +} + +func projectSearch(res apiclient.SearchResults) outSearch { + out := outSearch{ + Matches: make([]outSearchMatch, 0, len(res.Matches)), + Groups: make([]outSearchGroup, 0, len(res.Groups)), + Users: make([]outSearchUser, 0, len(res.Users)), + } + for _, m := range res.Matches { + out.Matches = append(out.Matches, outSearchMatch{ID: m.ID, Fixture: m.Fixture, KickoffAt: toISO(m.KickoffAt)}) + } + for _, g := range res.Groups { + out.Groups = append(out.Groups, outSearchGroup{ID: g.ID, Name: g.Name}) + } + for _, u := range res.Users { + out.Users = append(out.Users, outSearchUser{ID: u.ID, Username: u.Username, Me: u.Me}) + } + return out +} + +/* ── helpers ───────────────────────────────────────────────────────────────── */ + +func fromScore(s *apiclient.Score) *outScore { + if s == nil { + return nil + } + return &outScore{Home: s.H, Away: s.A} +} + +// nilIfEmpty maps the API's empty-string venue back to null, reproducing the TS +// `venue ?? null` for an absent venue (a real venue is always a non-empty name). +// ponytail: this collapses a genuinely-empty venue to null too — impossible in +// the data, and the frozen contract's type is `string | null`. +func nilIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} + +// toISO normalises a /v1 timestamp to the exact JS `Date.toISOString()` form +// (UTC, always 3 fractional digits, `Z`). /v1/matches already emits millis, but +// /v1/predictions and /v1/search use RFC3339 without them — the frozen contract +// (built from toISOString) always has `.000`. Unparseable input is passed +// through untouched rather than dropped. +func toISO(s string) string { + t, err := time.Parse(time.RFC3339, s) // lenient: also accepts a fractional part + if err != nil { + return s + } + return t.UTC().Format("2006-01-02T15:04:05.000Z07:00") +} diff --git a/go/internal/tools/shapes_test.go b/go/internal/tools/shapes_test.go new file mode 100644 index 0000000..de9a557 --- /dev/null +++ b/go/internal/tools/shapes_test.go @@ -0,0 +1,160 @@ +package tools + +// shapes_test.go — asserts each projection produces the EXACT frozen JSON shape +// (field names, order, null semantics) the TS server emitted. Compact JSON is +// compared byte-for-byte; struct field order is the declaration order. + +import ( + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/footics/mcp-server/internal/apiclient" +) + +func sp(s string) *string { return &s } +func ip(i int) *int { return &i } +func bp(b bool) *bool { return &b } + +func eq(t *testing.T, name, got, want string) { + t.Helper() + if got != want { + t.Errorf("%s:\n got: %s\nwant: %s", name, got, want) + } +} + +func TestProjectMatchFinished(t *testing.T) { + m := apiclient.Match{ + ID: "m1", Phase: "group", Status: "finished", KickoffAt: "2026-07-04T15:00:00.000Z", Venue: "", + Home: apiclient.Team{Code: "FRA", Name: "France"}, Away: apiclient.Team{Code: "MAR", Name: "Maroc"}, + Score: &apiclient.Score{H: 2, A: 1}, Mine: &apiclient.Score{H: 2, A: 1}, JokerApplied: bp(false), Points: ip(3), + } + m.Competition.Kind = "wc" + eq(t, "finished", marshalCompact(projectMatch(m)), + `{"id":"m1","competition":"wc","phase":"group","group":null,"status":"finished","kickoffAt":"2026-07-04T15:00:00.000Z","venue":null,"home":{"code":"FRA","name":"France"},"away":{"code":"MAR","name":"Maroc"},"score":{"home":2,"away":1},"live":null,"myPrediction":{"home":2,"away":1,"joker":false,"points":3}}`) +} + +func TestProjectMatchLive(t *testing.T) { + m := apiclient.Match{ + ID: "m2", Phase: "group", Group: sp("A"), Status: "live", KickoffAt: "2026-07-04T18:00:00Z", Venue: "MetLife Stadium", + Home: apiclient.Team{Code: "BRA", Name: "Brésil"}, Away: apiclient.Team{Code: "ARG", Name: "Argentine"}, + LiveScore: &apiclient.Score{H: 1, A: 0}, + Live: &apiclient.LiveClock{Period: "second_half", DisplayMinute: ip(52), Running: true}, + Mine: &apiclient.Score{H: 1, A: 1}, JokerApplied: bp(true), Points: nil, + } + m.Competition.Kind = "wc" + // kickoffAt normalised to millis; score from liveScore; myPrediction.points null. + eq(t, "live", marshalCompact(projectMatch(m)), + `{"id":"m2","competition":"wc","phase":"group","group":"A","status":"live","kickoffAt":"2026-07-04T18:00:00.000Z","venue":"MetLife Stadium","home":{"code":"BRA","name":"Brésil"},"away":{"code":"ARG","name":"Argentine"},"score":{"home":1,"away":0},"live":{"period":"second_half","minute":52,"running":true},"myPrediction":{"home":1,"away":1,"joker":true,"points":null}}`) +} + +func TestProjectMatchScheduledNoPred(t *testing.T) { + m := apiclient.Match{ID: "m3", Phase: "group", Status: "scheduled", KickoffAt: "2026-07-04T20:00:00.000Z", + Home: apiclient.Team{Code: "ESP", Name: "Espagne"}, Away: apiclient.Team{Code: "GER", Name: "Allemagne"}} + m.Competition.Kind = "wc" + eq(t, "scheduled", marshalCompact(projectMatch(m)), + `{"id":"m3","competition":"wc","phase":"group","group":null,"status":"scheduled","kickoffAt":"2026-07-04T20:00:00.000Z","venue":null,"home":{"code":"ESP","name":"Espagne"},"away":{"code":"GER","name":"Allemagne"},"score":null,"live":null,"myPrediction":null}`) +} + +func TestProjectMatchDetailScheduledForcesEmptyEvents(t *testing.T) { + d := apiclient.MatchDetail{ + Match: apiclient.Match{ID: "m3", Phase: "group", Status: "scheduled", KickoffAt: "2026-07-04T20:00:00.000Z", Home: apiclient.Team{Code: "ESP", Name: "Espagne"}, Away: apiclient.Team{Code: "GER", Name: "Allemagne"}}, + Events: []apiclient.Event{{Type: "goal", Period: "first_half", Minute: 5}}, // must be dropped: scheduled ⇒ [] + } + d.Competition.Kind = "wc" + got := marshalCompact(projectMatchDetail(d)) + want := `{"id":"m3","competition":"wc","phase":"group","group":null,"status":"scheduled","kickoffAt":"2026-07-04T20:00:00.000Z","venue":null,"home":{"code":"ESP","name":"Espagne"},"away":{"code":"GER","name":"Allemagne"},"score":null,"live":null,"myPrediction":null,"events":[]}` + eq(t, "detail-scheduled", got, want) +} + +func TestProjectMatchDetailFinishedDropsAssist(t *testing.T) { + d := apiclient.MatchDetail{ + Match: apiclient.Match{ID: "m1", Phase: "group", Status: "finished", KickoffAt: "2026-07-04T15:00:00.000Z", Home: apiclient.Team{Code: "FRA", Name: "France"}, Away: apiclient.Team{Code: "MAR", Name: "Maroc"}, Score: &apiclient.Score{H: 2, A: 1}}, + Events: []apiclient.Event{{Type: "goal", Period: "first_half", Minute: 23, TeamCode: sp("FRA"), Player: sp("Mbappé")}}, + } + d.Competition.Kind = "wc" + got := marshalCompact(projectMatchDetail(d)) + // EventJson has no `assist`; minutePlus/detail present as null. + want := `{"id":"m1","competition":"wc","phase":"group","group":null,"status":"finished","kickoffAt":"2026-07-04T15:00:00.000Z","venue":null,"home":{"code":"FRA","name":"France"},"away":{"code":"MAR","name":"Maroc"},"score":{"home":2,"away":1},"live":null,"myPrediction":null,"events":[{"type":"goal","period":"first_half","minute":23,"minutePlus":null,"teamCode":"FRA","player":"Mbappé","detail":null}]}` + eq(t, "detail-finished", got, want) +} + +func TestProjectPredictions(t *testing.T) { + ps := []apiclient.Prediction{ + {MatchID: "m1", Fixture: "FRA-MAR", KickoffAt: "2026-07-04T15:00:00Z", Status: "finished", + Prediction: struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + }{2, 1, false}, + Result: &struct { + Home int `json:"home"` + Away int `json:"away"` + }{2, 1}, Points: ip(3)}, + {MatchID: "m2", Fixture: "BRA-ARG", KickoffAt: "2026-07-04T18:00:00Z", Status: "scheduled", + Prediction: struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + }{3, 0, false}}, + } + // prediction has NO winnerTeamCode; result/points present-null when absent; kickoffAt normalised. + want := `[{"matchId":"m1","fixture":"FRA-MAR","kickoffAt":"2026-07-04T15:00:00.000Z","status":"finished","prediction":{"home":2,"away":1,"joker":false},"result":{"home":2,"away":1},"points":3},{"matchId":"m2","fixture":"BRA-ARG","kickoffAt":"2026-07-04T18:00:00.000Z","status":"scheduled","prediction":{"home":3,"away":0,"joker":false},"result":null,"points":null}]` + eq(t, "predictions", marshalCompact(projectPredictions(ps)), want) +} + +func TestProjectGroupsOwnerFlag(t *testing.T) { + gs := []apiclient.Group{ + {ID: "g1", Name: "Les Potes", Code: "ABC123", Members: 5, MyRank: 2, MyPoints: 12, Owner: sp("me")}, + {ID: "g2", Name: "Boulot", Code: "XYZ", Members: 3, MyRank: 3, MyPoints: 0, Owner: nil}, + } + want := `[{"id":"g1","name":"Les Potes","code":"ABC123","members":5,"myRank":2,"myPoints":12,"owner":true},{"id":"g2","name":"Boulot","code":"XYZ","members":3,"myRank":3,"myPoints":0,"owner":false}]` + eq(t, "groups", marshalCompact(projectGroups(gs)), want) +} + +func TestProjectSearchEmptyIsArrays(t *testing.T) { + // empty categories marshal to [], not null. + eq(t, "search-empty", marshalCompact(projectSearch(apiclient.SearchResults{})), `{"matches":[],"groups":[],"users":[]}`) +} + +func TestToISO(t *testing.T) { + cases := map[string]string{ + "2026-07-04T18:00:00Z": "2026-07-04T18:00:00.000Z", + "2026-07-04T15:00:00.000Z": "2026-07-04T15:00:00.000Z", + "2026-07-04T15:00:00.42Z": "2026-07-04T15:00:00.420Z", + "not-a-date": "not-a-date", // unparseable passes through + } + for in, want := range cases { + if got := toISO(in); got != want { + t.Errorf("toISO(%q) = %q, want %q", in, got, want) + } + } +} + +func TestJSONEnvelopes(t *testing.T) { + if txt := text(t, jsonErr("Match introuvable.")); txt != `{"error":"Match introuvable."}` { + t.Errorf("jsonErr text = %q", txt) + } + if !jsonErr("x").IsError { + t.Error("jsonErr must set IsError") + } + // jsonOK is pretty-printed (2-space indent) and does not HTML-escape. + if txt := text(t, jsonOK(map[string]string{"a": "&"})); txt != "{\n \"a\": \"&\"\n}" { + t.Errorf("jsonOK text = %q", txt) + } + if jsonOK(map[string]string{}).IsError { + t.Error("jsonOK must not set IsError") + } +} + +func text(t *testing.T, r *mcp.CallToolResult) string { + t.Helper() + if len(r.Content) != 1 { + t.Fatalf("want 1 content block, got %d", len(r.Content)) + } + tc, ok := r.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("content is not TextContent: %T", r.Content[0]) + } + return tc.Text +} diff --git a/go/internal/tools/tools.go b/go/internal/tools/tools.go new file mode 100644 index 0000000..f8cb311 --- /dev/null +++ b/go/internal/tools/tools.go @@ -0,0 +1,364 @@ +// Package tools registers the Footics MCP tools on a go-sdk server. Each read +// tool is one authenticated /v1 call (Bearer relayed) projected into the frozen +// output shape (shapes.go). whoami is served from the JWT alone. submit_prediction +// is a stub until footics-api ships POST /v1/predictions (M4). +// +// Output envelope (frozen): success = one text block of pretty-printed JSON +// (2-space indent, HTML-escaping OFF, matching JSON.stringify(data,null,2)); +// error = one text block of {"error":"…"} with IsError set. +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/footics/mcp-server/internal/apiclient" + "github.com/footics/mcp-server/internal/auth" +) + +// Deps are the tool layer's collaborators. +type Deps struct { + API *apiclient.Client + EnableWrites bool + TestUserID string // identity fallback when MCP_REQUIRE_AUTH=false (no TokenInfo) + RateLimitPerMin int +} + +type toolServer struct { + api *apiclient.Client + enableWrites bool + testUserID string + rl *rateLimiter + rlPerMin int +} + +// Register adds the 10 tools (9 reads + the submit_prediction stub) to server. +func Register(server *mcp.Server, d Deps) { + s := &toolServer{ + api: d.API, + enableWrites: d.EnableWrites, + testUserID: d.TestUserID, + rl: newRateLimiter(d.RateLimitPerMin), + rlPerMin: d.RateLimitPerMin, + } + + mcp.AddTool(server, &mcp.Tool{ + Name: "whoami", + Title: "Qui suis-je", + Description: "Renvoie l'identité Footics de l'utilisateur connecté (id, email). Utile pour vérifier la connexion.", + InputSchema: schemaWhoami(), + }, s.whoami) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_matches", + Title: "Lister les matchs", + Description: "Liste les matchs d'une compétition (avec mon prono, le statut, le score live/final). Filtrable par statut. Pour voir le calendrier, les matchs à venir, en cours ou terminés.", + InputSchema: schemaListMatches(), + }, s.listMatches) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_match", + Title: "Détail d'un match", + Description: "Renvoie un match par son id : équipes, coup d'envoi, statut, score, timeline des buts/cartons (si commencé) et mon prono.", + InputSchema: schemaGetMatch(), + }, s.getMatch) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_my_standing", + Title: "Mon classement", + Description: "Mes points, scores exacts, mon rang et le nombre total de joueurs pour une compétition.", + InputSchema: schemaComp(), + }, s.getMyStanding) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_my_predictions", + Title: "Mes pronos", + Description: "Mes pronostics pour une compétition. `when` = upcoming (à venir), past (terminés) ou all (tous, défaut).", + InputSchema: schemaPredictions(), + }, s.getMyPredictions) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_leaderboard", + Title: "Classement", + Description: "Classement général d'une compétition, ou d'un de mes groupes (via groupId). Trié par points puis scores exacts.", + InputSchema: schemaLeaderboard(), + }, s.getLeaderboard) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_my_groups", + Title: "Mes groupes", + Description: "Les groupes dont je suis membre, avec mon rang et mes points dans chacun pour la compétition donnée.", + InputSchema: schemaComp(), + }, s.listMyGroups) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_joker_status", + Title: "Mes jokers", + Description: "Mon stock de jokers par bucket (poules, 8es, …) pour une compétition : utilisés / quota / restants. Le joker double les points d'un match.", + InputSchema: schemaComp(), + }, s.getJokerStatus) + + mcp.AddTool(server, &mcp.Tool{ + Name: "search", + Title: "Rechercher", + Description: "Recherche transverse : matchs (par équipe/poule), mes groupes (par nom), joueurs (par pseudo).", + InputSchema: schemaSearch(), + }, s.search) + + // submit_prediction always appears (prod runs with writes ON), but the real + // write path lands with footics-api POST /v1/predictions (M4). Until then it + // is a stub — see submitPrediction. + mcp.AddTool(server, &mcp.Tool{ + Name: "submit_prediction", + Title: "Poser un prono", + Description: "Pose ou modifie MON pronostic sur un match (scores 0-20, joker optionnel). Refusé si le coup d'envoi est passé ou si je n'ai plus de joker pour ce bucket. Sur un match à élimination directe : le prono porte sur le score à la fin du temps réglementaire (90'), et si tu prédis un NUL, précise winnerTeamCode (l'équipe qui se qualifie) pour le +1 bonus. Confirme toujours avec l'utilisateur avant d'écrire.", + InputSchema: schemaSubmit(), + }, s.submitPrediction) +} + +/* ── identity + gate (auth + rate-limit) ───────────────────────────────────── */ + +type identity struct { + userID string + token string // the caller's Bearer, relayed to footics-api + email *string +} + +// gate resolves the caller's identity (from the verified JWT, or the TestUserID +// fallback in authless mode) and applies the per-user rate limit. A non-nil +// result is the error to return from the tool. +func (s *toolServer) gate(ctx context.Context) (identity, *mcp.CallToolResult) { + var id identity + if ti := sdkauth.TokenInfoFromContext(ctx); ti != nil { + id.userID = ti.UserID + if e, _ := ti.Extra[auth.ExtraEmail].(string); e != "" { + id.email = &e + } + if t, _ := ti.Extra[auth.ExtraToken].(string); t != "" { + id.token = t + } + } else if s.testUserID != "" { + id.userID = s.testUserID + } + + if id.userID == "" { + return id, jsonErr("Non authentifié.") + } + if retry := s.rl.retryAfter(id.userID, time.Now()); retry > 0 { + return id, jsonErr(fmt.Sprintf("Limite de débit atteinte (%d appels/min) — attends ~%ds avant de réessayer.", s.rlPerMin, retry)) + } + return id, nil +} + +/* ── read tools ────────────────────────────────────────────────────────────── */ + +func (s *toolServer) whoami(ctx context.Context, _ *mcp.CallToolRequest, _ argsWhoami) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + return jsonOK(outWhoami{UserID: id.userID, Email: id.email}), nil, nil +} + +func (s *toolServer) listMatches(ctx context.Context, _ *mcp.CallToolRequest, a argsListMatches) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + ms, err := s.api.Matches(ctx, id.token, comp(a.Competition), a.Status, clampLimit(a.Limit, 200, 200)) + if err != nil { + return apiErr("list_matches", err), nil, nil + } + out := make([]outMatch, 0, len(ms)) + for _, m := range ms { + out = append(out, projectMatch(m)) + } + return jsonOK(out), nil, nil +} + +func (s *toolServer) getMatch(ctx context.Context, _ *mcp.CallToolRequest, a argsGetMatch) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + m, found, err := s.api.Match(ctx, id.token, a.MatchID) + if err != nil { + return apiErr("get_match", err), nil, nil + } + if !found { + return jsonErr("Match introuvable."), nil, nil + } + return jsonOK(projectMatchDetail(*m)), nil, nil +} + +func (s *toolServer) getMyStanding(ctx context.Context, _ *mcp.CallToolRequest, a argsComp) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + c := comp(a.Competition) + me, found, err := s.api.Me(ctx, id.token, c) + if err != nil { + return apiErr("get_my_standing", err), nil, nil + } + if !found { + return jsonErr("Profil introuvable."), nil, nil + } + return jsonOK(projectStanding(*me, c)), nil, nil +} + +func (s *toolServer) getMyPredictions(ctx context.Context, _ *mcp.CallToolRequest, a argsPredictions) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + when := a.When + if when == "" { + when = "all" + } + ps, err := s.api.Predictions(ctx, id.token, comp(a.Competition), when, clampLimit(a.Limit, 100, 200)) + if err != nil { + return apiErr("get_my_predictions", err), nil, nil + } + return jsonOK(projectPredictions(ps)), nil, nil +} + +func (s *toolServer) getLeaderboard(ctx context.Context, _ *mcp.CallToolRequest, a argsLeaderboard) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + rows, err := s.api.Leaderboard(ctx, id.token, comp(a.Competition), a.GroupID, clampLimit(a.Limit, 50, 200)) + if err != nil { + return apiErr("get_leaderboard", err), nil, nil + } + return jsonOK(projectLeaderboard(rows)), nil, nil +} + +func (s *toolServer) listMyGroups(ctx context.Context, _ *mcp.CallToolRequest, a argsComp) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + gs, err := s.api.Groups(ctx, id.token, comp(a.Competition)) + if err != nil { + return apiErr("list_my_groups", err), nil, nil + } + return jsonOK(projectGroups(gs)), nil, nil +} + +func (s *toolServer) getJokerStatus(ctx context.Context, _ *mcp.CallToolRequest, a argsComp) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + js, err := s.api.Jokers(ctx, id.token, comp(a.Competition)) + if err != nil { + return apiErr("get_joker_status", err), nil, nil + } + return jsonOK(projectJokers(js)), nil, nil +} + +func (s *toolServer) search(ctx context.Context, _ *mcp.CallToolRequest, a argsSearch) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + res, err := s.api.Search(ctx, id.token, a.Query) + if err != nil { + return apiErr("search", err), nil, nil + } + return jsonOK(projectSearch(res)), nil, nil +} + +/* ── write tool (stub until M4) ────────────────────────────────────────────── */ + +func (s *toolServer) submitPrediction(ctx context.Context, _ *mcp.CallToolRequest, _ argsSubmit) (*mcp.CallToolResult, any, error) { + if _, errRes := s.gate(ctx); errRes != nil { + return errRes, nil, nil + } + if !s.enableWrites { + return jsonErr("L'écriture de pronos via MCP n'est pas encore disponible sur cette instance (MCP_ENABLE_WRITES=false)."), nil, nil + } + // Writes enabled, but the write path (footics-api POST /v1/predictions) ships + // with M4 and is not wired here yet. + return jsonErr("L'écriture de pronos via MCP arrivera avec l'API /v1 POST (M4) — non encore câblée sur cette instance."), nil, nil +} + +/* ── helpers ───────────────────────────────────────────────────────────────── */ + +// comp defaults an empty competition to "wc" (the schema default already fills +// it; this is belt-and-braces for authless/direct calls). +func comp(c string) string { + if c == "" { + return "wc" + } + return c +} + +// clampLimit mirrors Math.min(Math.max(limit ?? def, 1), max): an absent (0) +// limit uses def, otherwise it is clamped to [1, max]. +func clampLimit(v, def, max int) int { + if v <= 0 { + v = def + } + if v < 1 { + v = 1 + } + if v > max { + v = max + } + return v +} + +func jsonOK(data any) *mcp.CallToolResult { + text, err := marshalPretty(data) + if err != nil { + return jsonErr("Erreur interne de sérialisation.") + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} +} + +func jsonErr(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: marshalCompact(map[string]string{"error": msg})}}, + IsError: true, + } +} + +// apiErr logs the underlying /v1 failure and returns a generic FR tool error (the +// frozen contract never surfaced API internals; it hit the DB directly). +func apiErr(tool string, err error) *mcp.CallToolResult { + slog.Error("[mcp] /v1 call failed", "tool", tool, "err", err) + return jsonErr("Erreur lors de l'appel à l'API Footics. Réessaie plus tard.") +} + +// marshalPretty reproduces JSON.stringify(data, null, 2): 2-space indent with +// HTML escaping OFF (JS does not escape < > &). +func marshalPretty(v any) (string, error) { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return "", err + } + return strings.TrimRight(b.String(), "\n"), nil +} + +// marshalCompact reproduces JSON.stringify(data) for the error envelope. +func marshalCompact(v any) string { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) + return strings.TrimRight(b.String(), "\n") +}