initial commit

This commit is contained in:
2026-06-11 22:55:35 +01:00
parent eabebcf89b
commit 391828d892
14 changed files with 694 additions and 8 deletions
+18
View File
@@ -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
+7
View File
@@ -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
+43
View File
@@ -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"]
+3 -1
View File
@@ -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:
+33
View File
@@ -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)
+81 -5
View File
@@ -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"
}
+137
View File
@@ -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.
+41
View File
@@ -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
+55
View File
@@ -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 }
+44
View File
@@ -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: <whatever stores ns_agentgateway>
# 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
+13
View File
@@ -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 }
+19 -1
View File
@@ -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 {
+36 -1
View File
@@ -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) {
+164
View File
@@ -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)
}
}
}