mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-12 00:45:28 +00:00
initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user