Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions go/.env.example
Original file line number Diff line number Diff line change
@@ -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://<ref>.supabase.co/auth/v1/.well-known/jwks.json"
AUTH_JWT_SECRET="<your-hs256-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://<ref>.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=""
116 changes: 116 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
@@ -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.
190 changes: 190 additions & 0 deletions go/cmd/mcp/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 17 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
20 changes: 20 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Loading
Loading