From 391828d892629caf60d2dc946dd2180650ceb52c Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 11 Jun 2026 22:55:35 +0100 Subject: [PATCH] initial commit --- .dockerignore | 18 +++ .env.example | 7 ++ Dockerfile | 43 +++++++ Makefile | 4 +- README.md | 33 ++++++ cmd/captcha-solver-mcp/main.go | 86 +++++++++++++- deploy/k8s/README.md | 137 ++++++++++++++++++++++ deploy/k8s/agentgateway-backend.yaml | 41 +++++++ deploy/k8s/deployment.yaml | 55 +++++++++ deploy/k8s/external-secret.yaml | 44 +++++++ deploy/k8s/service.yaml | 13 +++ internal/config/config.go | 20 +++- internal/config/config_test.go | 37 +++++- test/e2e/http_test.go | 164 +++++++++++++++++++++++++++ 14 files changed, 694 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy/k8s/README.md create mode 100644 deploy/k8s/agentgateway-backend.yaml create mode 100644 deploy/k8s/deployment.yaml create mode 100644 deploy/k8s/external-secret.yaml create mode 100644 deploy/k8s/service.yaml create mode 100644 test/e2e/http_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6b372c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.gitignore +.golangci.yml +Dockerfile +docker-compose*.yml +*.md +docs/ +examples/ +node_modules/ +test/ +bin/ +.env +.env.example +*.log +*.test +*.out +.DS_Store diff --git a/.env.example b/.env.example index 1ad6def..016795a 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,11 @@ CAPTCHA_LLM_MODEL=claude-opus-4-8 CAPTCHA_LLM_AUDIO_MODEL= CAPTCHA_LLM_MAX_TOKENS=1024 CAPTCHA_LLM_TIMEOUT=60s + +# Transport: stdio (default, desktop clients) | http (MCP StreamableHTTP for cluster) +CAPTCHA_TRANSPORT=stdio +# HTTP transport only: listen address + MCP path. /healthz is always served. +CAPTCHA_HTTP_ADDR=:8080 +CAPTCHA_HTTP_PATH=/mcp + CAPTCHA_LOG_LEVEL=info diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..853e8bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.7 + +# Build stage: cross-compile on the buildx host's native arch, then COPY +# the right per-arch binary into the final stage. Avoids QEMU emulation +# (much faster; same result for a static Go binary). +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src + +# Cache module download across builds. +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY . . + +# Build a fully static, stripped binary. CGO_ENABLED=0 → no glibc dep, runs +# on scratch/distroless. -trimpath strips local paths from the binary. +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 \ + GOOS=$TARGETOS \ + GOARCH=$TARGETARCH \ + go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/captcha-solver-mcp \ + ./cmd/captcha-solver-mcp + +# Final stage: distroless static + nonroot. Matches the k8s securityContext +# (runAsNonRoot, readOnlyRootFilesystem, drop ALL caps, seccompRuntimeDefault). +# No shell — the binary handles SIGTERM via signal.NotifyContext in main.go. +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=build /out/captcha-solver-mcp /captcha-solver-mcp + +EXPOSE 8080 + +USER nonroot:nonroot + +ENTRYPOINT ["/captcha-solver-mcp"] diff --git a/Makefile b/Makefile index de2b322..2089360 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ -.PHONY: build test e2e e2e-live e2e-browser lint tidy +.PHONY: build test e2e e2e-live e2e-browser run-http lint tidy build: go build -o bin/captcha-solver-mcp ./cmd/captcha-solver-mcp +run-http: build + CAPTCHA_TRANSPORT=http CAPTCHA_HTTP_ADDR=:8080 ./bin/captcha-solver-mcp test: go test ./... e2e: diff --git a/README.md b/README.md index d223081..dfaac94 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,9 @@ Copy `.env.example` as a starting point. | `CAPTCHA_LLM_TEMPERATURE` | no | `0` | | | `CAPTCHA_LLM_MAX_TOKENS` | no | `1024` | | | `CAPTCHA_LLM_TIMEOUT` | no | `60s` | Go duration string | +| `CAPTCHA_TRANSPORT` | no | `stdio` | `stdio` (MCP over stdin/stdout) or `http` (MCP StreamableHTTP) | +| `CAPTCHA_HTTP_ADDR` | when `CAPTCHA_TRANSPORT=http` | `:8080` | Listen address for HTTP transport | +| `CAPTCHA_HTTP_PATH` | when `CAPTCHA_TRANSPORT=http` | `/mcp` | MCP StreamableHTTP endpoint path. `/healthz` is always served. | | `CAPTCHA_LOG_LEVEL` | no | `info` | | > **Keyless gateways:** if you point `CAPTCHA_LLM_BASE_URL` at an OpenAI-compatible gateway @@ -179,6 +182,36 @@ export CAPTCHA_LLM_MODEL=openai/gpt-5.4 # or zai/GLM-4.6V-FlashX, claude- ./bin/captcha-solver-mcp ``` +### HTTP transport (for cluster / gateway deployment) + +```sh +export CAPTCHA_TRANSPORT=http +export CAPTCHA_HTTP_ADDR=0.0.0.0:8080 +export CAPTCHA_LLM_PROVIDER=openai-compatible +export CAPTCHA_LLM_BASE_URL=http://agentgateway-proxy.agentgateway-system.svc.cluster.local/v1 +export CAPTCHA_LLM_MODEL=minimax/MiniMax-M3 +./bin/captcha-solver-mcp +# MCP StreamableHTTP on http://0.0.0.0:8080/mcp +# Liveness/readiness on http://0.0.0.0:8080/healthz +``` + +The HTTP transport runs the server in **stateless** StreamableHTTP mode +(per-request sessions, no `Mcp-Session-Id` validation), matching the +`sessionRouting: Stateless` convention used by the other MCP servers in the +home cluster's `agentgateway-system` namespace. SIGINT / SIGTERM trigger a +graceful shutdown with a 10 s drain. + +The example `BASE_URL` above points at the in-cluster +`agentgateway-proxy` (which fronts `api.minimax.io` via the `minimax` +AgentgatewayBackend, holding the upstream auth). No `CAPTCHA_LLM_API_KEY` is +needed when routing through the in-cluster gateway. Pointing at a public +endpoint (e.g. `https://llmgw.h.raczylo.com/v1` or `https://api.openai.com/v1`) +requires a key — see `.env.example`. + +Kubernetes manifests that match the cluster's existing MCP server conventions +(Deployment, Service, `AgentgatewayBackend`) live under +[`deploy/k8s/`](./deploy/k8s). + ## Maximizing accuracy ### Image grids (reCAPTCHA v2 / hCaptcha) diff --git a/cmd/captcha-solver-mcp/main.go b/cmd/captcha-solver-mcp/main.go index 8eed5bc..da40f9a 100644 --- a/cmd/captcha-solver-mcp/main.go +++ b/cmd/captcha-solver-mcp/main.go @@ -1,9 +1,21 @@ -// Command captcha-solver-mcp runs the captcha solver MCP server over stdio. +// Command captcha-solver-mcp runs the captcha solver MCP server. +// +// Transport is selected at startup by CAPTCHA_TRANSPORT: +// +// stdio — default. Speaks MCP over stdin/stdout for desktop clients. +// http — serves MCP StreamableHTTP at CAPTCHA_HTTP_PATH (default /mcp) +// and /healthz on CAPTCHA_HTTP_ADDR (default :8080). package main import ( "context" + "errors" "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -21,10 +33,74 @@ func main() { if err != nil { log.Fatalf("llm: %v", err) } - server := mcp.NewServer(&mcp.Implementation{Name: "captcha-solver-mcp", Version: "v0.1.0"}, nil) - mcpserver.New(client, cfg.Provider, cfg.Model).Register(server) + server := mcp.NewServer(&mcp.Implementation{Name: "captcha-solver-mcp", Version: version()}, nil) + handlers := mcpserver.New(client, cfg.Provider, cfg.Model) + handlers.Register(server) - if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { - log.Fatalf("server: %v", err) + switch cfg.Transport { + case "stdio": + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatalf("server: %v", err) + } + case "http": + runHTTP(server, cfg) + default: + // Validated by config.Load; unreachable. + log.Fatalf("transport: unsupported %q", cfg.Transport) } } + +func runHTTP(server *mcp.Server, cfg config.Config) { + mux := http.NewServeMux() + + handler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return server }, + &mcp.StreamableHTTPOptions{ + Stateless: true, + }, + ) + mux.Handle(cfg.HTTPPath, handler) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 0, // 0 = no limit on the body; LLM-bound tool calls can be long. + WriteTimeout: 0, // long-lived SSE/streamable responses. + IdleTimeout: 120 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + log.Printf("captcha-solver-mcp http listening on %s (path=%s)", cfg.HTTPAddr, cfg.HTTPPath) + errCh <- srv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + log.Printf("captcha-solver-mcp shutting down") + shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutCtx); err != nil { + log.Fatalf("shutdown: %v", err) + } + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("http: %v", err) + } + } +} + +func version() string { + if v := os.Getenv("CAPTCHA_VERSION"); v != "" { + return v + } + return "v0.1.0" +} diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000..c197e33 --- /dev/null +++ b/deploy/k8s/README.md @@ -0,0 +1,137 @@ +# k8s manifests for `captcha-solver-mcp` + +These manifests follow the conventions used by the other MCP servers in the +home cluster's `agentgateway-system` namespace (compare +`home-cluster/.../base/mcp-servers/time/` and `.../mcp-servers/web-search/`). + +## Resources + +| File | Resource | +|------|----------| +| `deployment.yaml` | `Deployment/mcp-captcha-solver` | +| `service.yaml` | `Service/mcp-captcha-solver` (ClusterIP, port 8085) | +| `agentgateway-backend.yaml` | `AgentgatewayBackend/mcp-captcha-solver` (per-server, hand-written) | +| `external-secret.yaml` | `ExternalSecret/1p-mcp-captcha-solver` — **optional**, only if you point at a public LLM endpoint | + +## Install + +The Deployment talks to the LLM through the in-cluster **agentgateway-proxy** +(ClusterIP `10.43.198.35`, port 80), which fronts the `minimax` AgentgatewayBackend +and holds the upstream `api.minimax.io` auth in the `agentgateway-minimax-auth` +secret. From the pod's perspective the endpoint is **keyless** — no +`CAPTCHA_LLM_API_KEY` is set, and no `ExternalSecret` is needed. + +### 1. Add the manifests to `home-cluster` + +There are two equally good layouts. Pick **layout B** (chart-driven) for +consistency with the existing per-server MCP backends unless you need a custom +`AgentgatewayPolicy`. + +#### Layout A — hand-written, fully self-contained + +```sh +mkdir -p ~/Documents/projects/home-cluster/kubernetes/namespaces/agentgateway-system/base/mcp-servers/captcha-solver +cp deploy/k8s/{deployment,service,agentgateway-backend}.yaml \ + ~/Documents/projects/home-cluster/kubernetes/namespaces/agentgateway-system/base/mcp-servers/captcha-solver/ +``` + +Then edit `home-cluster/.../base/mcp-servers/kustomization.yaml`: + +```yaml + - ./captcha-solver/deployment.yaml + - ./captcha-solver/service.yaml + - ./captcha-solver/agentgateway-backend.yaml +``` + +#### Layout B — chart-driven (recommended, matches existing per-server MCPs) + +Copy `deployment.yaml` and `service.yaml` exactly as in Layout A, **but skip +`agentgateway-backend.yaml`** — the `agentgw-cfg` chart will generate it from +`mcpServers` in values. + +Edit `home-cluster/.../agentgw-cfg/values.yaml`: + +```yaml +mcpServers: + - name: captcha-solver + host: mcp-captcha-solver.agentgateway-system.svc.cluster.local + port: 8085 + # path: /mcp (default) + # protocol: StreamableHTTP (default) +``` + +This produces: + +- `AgentgatewayBackend/mcp-captcha-solver` (per-server, StreamableHTTP, + Stateless — matches the other MCP backends) +- `HTTPRoute/mcp-per-server` with a rule on + `/mcp/captcha-solver` → that backend (per-server alias) + +The same `values.yaml` entry also adds this server to the umbrella +`mcp-servers` backend that the catch-all `/mcp` HTTPRoute uses. + +### 2. (Only if you switch the BASE_URL) Add the ExternalSecret + +Skip this section if you keep the default +`http://agentgateway-proxy.agentgateway-system.svc.cluster.local/v1`. The +in-cluster gateway is keyless for cluster callers. + +If you change `CAPTCHA_LLM_BASE_URL` to a public endpoint (e.g. +`https://llmgw.h.raczylo.com/v1` from outside the cluster, or +`https://api.openai.com/v1`), append `external-secret.yaml` to +`home-cluster/.../base/mcp-servers/eso.yaml` and re-add the +`valueFrom: secretKeyRef` block in the Deployment for `CAPTCHA_LLM_API_KEY`. + +### 3. Build & push the image + +The manifests reference `ghcr.io/lukaszraczylo/mcp-captchasolver:latest`. Build +and push from this repo: + +```sh +docker buildx build --builder multi-arch \ + --platform linux/amd64,linux/arm64 \ + -t ghcr.io/lukaszraczylo/mcp-captchasolver:latest \ + -t ghcr.io/lukaszraczylo/mcp-captchasolver:$(git rev-parse --short HEAD) \ + --push . +``` + +(The `multi-registry-secret` patch in +`home-cluster/.../base/mcp-servers/kustomization.yaml` already grants the +service account permission to pull `ghcr.io/lukaszraczylo/mcp-*` images.) + +### 4. Verify + +```sh +kubectl --context admin@home -n agentgateway-system \ + rollout status deploy/mcp-captcha-solver +kubectl --context admin@home -n agentgateway-system \ + get agentgatewaybackend mcp-captcha-solver +curl -s http://10.0.2.101/mcp/captcha-solver \ + -H 'Accept: application/json, text/event-stream' \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' +``` + +The last call should respond with a `serverInfo` block naming +`captcha-solver-mcp` v0.1.0 (or whatever `CAPTCHA_VERSION` was baked in at +build time). From inside a debug pod: + +```sh +kubectl --context admin@home -n agentgateway-system exec -it deploy/mcp-captcha-solver -- \ + wget -qO- http://agentgateway-proxy.agentgateway-system.svc.cluster.local/v1/models | head +``` + +should list `minimax/MiniMax-M3` (proving the in-cluster path works). + +## Port choice + +`8085` is the next free port in the 80xx range used by the other MCP servers +(8081, 8083, 8084, 8087, 8088, 8089, 8092 are taken). Bump the `containerPort`, +`Service.port`, `Deployment` env `CAPTCHA_HTTP_ADDR`, and the `mcpServers` +`port:` if you want a different one. + +## Resource sizing + +Vision models can be heavier than text-only ones, so the limits (`1 CPU`, +`512Mi`) are a step above `mcp-time` (`200m`, `128Mi`) and on par with +`mcp-web-search`. Tune after observing real load. diff --git a/deploy/k8s/agentgateway-backend.yaml b/deploy/k8s/agentgateway-backend.yaml new file mode 100644 index 0000000..6dc6f4e --- /dev/null +++ b/deploy/k8s/agentgateway-backend.yaml @@ -0,0 +1,41 @@ +# AgentgatewayBackend for captcha-solver-mcp. +# +# Two options for getting this into the cluster — pick one: +# +# A) Hand-written: drop this file into +# home-cluster/kubernetes/namespaces/agentgateway-system/base/mcp-servers/captcha-solver/agentgateway-backend.yaml +# and add it to the kustomization.yaml alongside deployment.yaml/service.yaml. +# +# B) Generated by the agentgw-cfg chart: add an entry to the `mcpServers` list +# in home-cluster/kubernetes/namespaces/agentgateway-system/agentgw-cfg/values.yaml: +# +# mcpServers: +# - name: captcha-solver +# host: mcp-captcha-solver.agentgateway-system.svc.cluster.local +# port: 8085 +# # path: /mcp (default) +# # protocol: StreamableHTTP (default) +# +# This produces BOTH a per-server AgentgatewayBackend (matching the file +# below) AND a per-server HTTPRoute at /mcp/captcha-solver. The chart +# template lives at: +# home-cluster/kubernetes/namespaces/agentgateway-system/agentgw-cfg/templates/mcp.yaml +# +# Option B is preferred (matches the existing per-server backends) unless you +# also need to attach AgentgatewayPolicy/auth that the chart doesn't expose. +--- +apiVersion: agentgateway.dev/v1alpha1 +kind: AgentgatewayBackend +metadata: + name: mcp-captcha-solver + namespace: agentgateway-system +spec: + mcp: + sessionRouting: Stateless + targets: + - name: captcha-solver + static: + host: mcp-captcha-solver.agentgateway-system.svc.cluster.local + path: /mcp + port: 8085 + protocol: StreamableHTTP diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml new file mode 100644 index 0000000..41ae801 --- /dev/null +++ b/deploy/k8s/deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcp-captcha-solver + namespace: agentgateway-system + labels: + app.kubernetes.io/name: mcp-captcha-solver + app.kubernetes.io/part-of: mcp-servers +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: mcp-captcha-solver + template: + metadata: + labels: + app.kubernetes.io/name: mcp-captcha-solver + app.kubernetes.io/part-of: mcp-servers + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + containers: + - name: mcp + image: ghcr.io/lukaszraczylo/mcp-captchasolver:latest + imagePullPolicy: IfNotPresent + ports: + - { name: http, containerPort: 8085 } + env: + - { name: CAPTCHA_TRANSPORT, value: "http" } + - { name: CAPTCHA_HTTP_ADDR, value: ":8085" } + - { name: CAPTCHA_HTTP_PATH, value: "/mcp" } + - { name: CAPTCHA_LLM_PROVIDER, value: "openai-compatible" } + - { name: CAPTCHA_LLM_BASE_URL, value: "http://agentgateway-proxy.agentgateway-system.svc.cluster.local/v1" } + - { name: CAPTCHA_LLM_MODEL, value: "minimax/MiniMax-M3" } + - { name: CAPTCHA_LLM_TIMEOUT, value: "120s" } + - { name: CAPTCHA_LLM_MAX_TOKENS, value: "2048" } + readinessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 2 + periodSeconds: 5 + livenessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 15 + periodSeconds: 15 + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { cpu: 1, memory: 512Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: { drop: [ALL] } + seccompProfile: { type: RuntimeDefault } diff --git a/deploy/k8s/external-secret.yaml b/deploy/k8s/external-secret.yaml new file mode 100644 index 0000000..c66f74b --- /dev/null +++ b/deploy/k8s/external-secret.yaml @@ -0,0 +1,44 @@ +# Optional: ExternalSecret for the captcha-solver API key. +# +# The default Deployment points at the keyless in-cluster LLM gateway +# (agentgateway-proxy → api.minimax.io via the `minimax` AgentgatewayBackend, +# which holds the upstream auth in `agentgateway-minimax-auth`). No client +# API key is needed in that case, and this file is **not** required. +# +# Only apply this if you change `CAPTCHA_LLM_BASE_URL` to a public endpoint +# that needs an API key (e.g. https://llmgw.h.raczylo.com/v1 from outside the +# cluster, or https://api.openai.com/v1). 1Password layout (matches the +# convention in home-cluster/.../base/mcp-servers/eso.yaml): +# +# vault: +# item: ns_agentgateway/mcp_captcha_solver_llm_api_key +# field: key (1Password item key) +# +# Append to home-cluster/kubernetes/namespaces/agentgateway-system/base/mcp-servers/eso.yaml. +# The Secret is consumed by the Deployment as `mcp-captcha-solver` / `CAPTCHA_LLM_API_KEY`, +# and the Deployment's `env:` block must be amended to re-add the +# `valueFrom: secretKeyRef` lookup. +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: 1p-mcp-captcha-solver + namespace: agentgateway-system +spec: + refreshInterval: 24h + secretStoreRef: + name: 1password-homecluster + kind: ClusterSecretStore + target: + name: mcp-captcha-solver + creationPolicy: Owner + deletionPolicy: Retain + template: + engineVersion: v2 + type: Opaque + data: + CAPTCHA_LLM_API_KEY: "{{ .key }}" + data: + - secretKey: key + remoteRef: + key: ns_agentgateway/mcp_captcha_solver_llm_api_key diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml new file mode 100644 index 0000000..f9d54af --- /dev/null +++ b/deploy/k8s/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mcp-captcha-solver + namespace: agentgateway-system + labels: + app.kubernetes.io/name: mcp-captcha-solver + app.kubernetes.io/part-of: mcp-servers +spec: + selector: + app.kubernetes.io/name: mcp-captcha-solver + ports: + - { name: http, port: 8085, targetPort: http } diff --git a/internal/config/config.go b/internal/config/config.go index 98e7cca..da36938 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,7 +19,10 @@ type Config struct { APIKey string Model string AudioModel string + HTTPPath string LogLevel string + Transport string + HTTPAddr string Timeout time.Duration MaxTokens int } @@ -28,7 +31,11 @@ var validProviders = map[string]bool{ "anthropic": true, "openai": true, "openai-compatible": true, "gemini": true, } -// Load reads CAPTCHA_LLM_* env vars and returns a validated Config. +var validTransports = map[string]bool{ + "stdio": true, "http": true, +} + +// Load reads CAPTCHA_LLM_* and CAPTCHA_TRANSPORT_* env vars and returns a validated Config. func Load() (Config, error) { c := Config{ Provider: os.Getenv("CAPTCHA_LLM_PROVIDER"), @@ -37,18 +44,29 @@ func Load() (Config, error) { Model: os.Getenv("CAPTCHA_LLM_MODEL"), AudioModel: os.Getenv("CAPTCHA_LLM_AUDIO_MODEL"), LogLevel: envOr("CAPTCHA_LOG_LEVEL", "info"), + Transport: envOr("CAPTCHA_TRANSPORT", "stdio"), + HTTPAddr: envOr("CAPTCHA_HTTP_ADDR", ":8080"), + HTTPPath: envOr("CAPTCHA_HTTP_PATH", "/mcp"), MaxTokens: 1024, Timeout: 60 * time.Second, } if !validProviders[c.Provider] { return Config{}, fmt.Errorf("CAPTCHA_LLM_PROVIDER must be one of anthropic|openai|openai-compatible|gemini, got %q", c.Provider) } + if !validTransports[c.Transport] { + return Config{}, fmt.Errorf("CAPTCHA_TRANSPORT must be one of stdio|http, got %q", c.Transport) + } if c.APIKey == "" && c.BaseURL == "" { return Config{}, fmt.Errorf("CAPTCHA_LLM_API_KEY is required (or set CAPTCHA_LLM_BASE_URL for a keyless local endpoint)") } if c.Model == "" { return Config{}, fmt.Errorf("CAPTCHA_LLM_MODEL is required") } + if c.Transport == "http" { + if c.HTTPPath == "" || c.HTTPPath[0] != '/' { + return Config{}, fmt.Errorf("CAPTCHA_HTTP_PATH must be an absolute path beginning with '/', got %q", c.HTTPPath) + } + } if v := os.Getenv("CAPTCHA_LLM_MAX_TOKENS"); v != "" { n, err := strconv.Atoi(v) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 91ba0b1..553d444 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,7 +21,25 @@ func TestLoad(t *testing.T) { }, want: Config{ Provider: "anthropic", APIKey: "k", Model: "claude-opus-4-8", - LogLevel: "info", MaxTokens: 1024, Timeout: 60 * time.Second, + LogLevel: "info", Transport: "stdio", HTTPAddr: ":8080", HTTPPath: "/mcp", + MaxTokens: 1024, Timeout: 60 * time.Second, + }, + }, + { + name: "valid http transport", + env: map[string]string{ + "CAPTCHA_LLM_PROVIDER": "openai-compatible", + "CAPTCHA_LLM_BASE_URL": "http://localhost:11434/v1", + "CAPTCHA_LLM_MODEL": "qwen2.5vl", + "CAPTCHA_TRANSPORT": "http", + "CAPTCHA_HTTP_ADDR": "127.0.0.1:9090", + "CAPTCHA_HTTP_PATH": "/mcp", + }, + want: Config{ + Provider: "openai-compatible", BaseURL: "http://localhost:11434/v1", + Model: "qwen2.5vl", + LogLevel: "info", Transport: "http", HTTPAddr: "127.0.0.1:9090", HTTPPath: "/mcp", + MaxTokens: 1024, Timeout: 60 * time.Second, }, }, { @@ -34,6 +52,23 @@ func TestLoad(t *testing.T) { env: map[string]string{"CAPTCHA_LLM_PROVIDER": "nope", "CAPTCHA_LLM_API_KEY": "k", "CAPTCHA_LLM_MODEL": "m"}, wantErr: true, }, + { + name: "bad transport", + env: map[string]string{ + "CAPTCHA_LLM_PROVIDER": "openai", "CAPTCHA_LLM_API_KEY": "k", "CAPTCHA_LLM_MODEL": "m", + "CAPTCHA_TRANSPORT": "quic", + }, + wantErr: true, + }, + { + name: "http transport with relative path", + env: map[string]string{ + "CAPTCHA_LLM_PROVIDER": "openai", "CAPTCHA_LLM_API_KEY": "k", "CAPTCHA_LLM_MODEL": "m", + "CAPTCHA_TRANSPORT": "http", + "CAPTCHA_HTTP_PATH": "mcp", + }, + wantErr: true, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/test/e2e/http_test.go b/test/e2e/http_test.go new file mode 100644 index 0000000..f813668 --- /dev/null +++ b/test/e2e/http_test.go @@ -0,0 +1,164 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/url" + "os/exec" + "strings" + "syscall" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// startServer starts the captcha-solver-mcp binary in HTTP transport mode, +// returning the base URL (e.g. "http://127.0.0.1:34567") and a stop func. +func startServer(t *testing.T, llmURL string) (string, func()) { + t.Helper() + + // Pick a free port by asking the kernel. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, "../../bin/captcha-solver-mcp") + cmd.Env = append(cmd.Environ(), + "CAPTCHA_LLM_PROVIDER=openai-compatible", + "CAPTCHA_LLM_BASE_URL="+llmURL, + "CAPTCHA_LLM_API_KEY=test", + "CAPTCHA_LLM_MODEL=fake", + "CAPTCHA_TRANSPORT=http", + "CAPTCHA_HTTP_ADDR="+addr, + "CAPTCHA_HTTP_PATH=/mcp", + ) + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Start(); err != nil { + cancel() + t.Fatalf("start: %v", err) + } + + // Wait for /healthz to respond. + base := "http://" + addr + if !waitHealthy(base+"/healthz", 5*time.Second) { + _ = cmd.Process.Kill() + cancel() + t.Fatalf("server failed to become healthy at %s", base) + } + + return base, func() { + _ = cmd.Process.Signal(syscall.SIGTERM) + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + } + cancel() + } +} + +func waitHealthy(url string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + resp, err := http.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true + } + } + time.Sleep(50 * time.Millisecond) + } + return false +} + +func TestHealthz(t *testing.T) { + llmSrv := startFakeLLM() + defer llmSrv.Close() + + base, stop := startServer(t, llmSrv.URL) + defer stop() + + resp, err := http.Get(base + "/healthz") + if err != nil { + t.Fatalf("get healthz: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if strings.TrimSpace(string(body)) != "ok" { + t.Fatalf("healthz body = %q, want %q", body, "ok") + } +} + +func TestSolveGridOverHTTP(t *testing.T) { + llmSrv := startFakeLLM() + defer llmSrv.Close() + + base, stop := startServer(t, llmSrv.URL) + defer stop() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + endpoint, _ := url.Parse(base + "/mcp") + client := mcp.NewClient(&mcp.Implementation{Name: "e2e-http", Version: "v0"}, nil) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: endpoint.String()}, nil) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer func() { _ = session.Close() }() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "solve_grid_captcha", + Arguments: map[string]any{ + "screenshot": "iVBORw0KGgo=", + "instruction": "select all buses", + "rows": 3, + "cols": 3, + "image_width": 300, + "image_height": 300, + }, + }) + if err != nil { + t.Fatalf("call: %v", err) + } + if res.IsError { + t.Fatalf("tool returned error: %+v", res.Content) + } + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got struct { + Tiles []int `json:"tiles"` + } + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v\nraw=%s", err, raw) + } + want := []int{0, 1, 2} + if len(got.Tiles) != len(want) { + t.Fatalf("tiles = %v, want %v", got.Tiles, want) + } + for i := range want { + if got.Tiles[i] != want[i] { + t.Fatalf("tiles = %v, want %v", got.Tiles, want) + } + } +}