mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-18 01:17:53 +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_AUDIO_MODEL=
|
||||||
CAPTCHA_LLM_MAX_TOKENS=1024
|
CAPTCHA_LLM_MAX_TOKENS=1024
|
||||||
CAPTCHA_LLM_TIMEOUT=60s
|
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
|
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:
|
build:
|
||||||
go build -o bin/captcha-solver-mcp ./cmd/captcha-solver-mcp
|
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:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
e2e:
|
e2e:
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ Copy `.env.example` as a starting point.
|
|||||||
| `CAPTCHA_LLM_TEMPERATURE` | no | `0` | |
|
| `CAPTCHA_LLM_TEMPERATURE` | no | `0` | |
|
||||||
| `CAPTCHA_LLM_MAX_TOKENS` | no | `1024` | |
|
| `CAPTCHA_LLM_MAX_TOKENS` | no | `1024` | |
|
||||||
| `CAPTCHA_LLM_TIMEOUT` | no | `60s` | Go duration string |
|
| `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` | |
|
| `CAPTCHA_LOG_LEVEL` | no | `info` | |
|
||||||
|
|
||||||
> **Keyless gateways:** if you point `CAPTCHA_LLM_BASE_URL` at an OpenAI-compatible gateway
|
> **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
|
./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
|
## Maximizing accuracy
|
||||||
|
|
||||||
### Image grids (reCAPTCHA v2 / hCaptcha)
|
### 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
@@ -21,10 +33,74 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("llm: %v", err)
|
log.Fatalf("llm: %v", err)
|
||||||
}
|
}
|
||||||
server := mcp.NewServer(&mcp.Implementation{Name: "captcha-solver-mcp", Version: "v0.1.0"}, nil)
|
server := mcp.NewServer(&mcp.Implementation{Name: "captcha-solver-mcp", Version: version()}, nil)
|
||||||
mcpserver.New(client, cfg.Provider, cfg.Model).Register(server)
|
handlers := mcpserver.New(client, cfg.Provider, cfg.Model)
|
||||||
|
handlers.Register(server)
|
||||||
|
|
||||||
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
|
switch cfg.Transport {
|
||||||
log.Fatalf("server: %v", err)
|
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
|
APIKey string
|
||||||
Model string
|
Model string
|
||||||
AudioModel string
|
AudioModel string
|
||||||
|
HTTPPath string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
|
Transport string
|
||||||
|
HTTPAddr string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
}
|
}
|
||||||
@@ -28,7 +31,11 @@ var validProviders = map[string]bool{
|
|||||||
"anthropic": true, "openai": true, "openai-compatible": true, "gemini": true,
|
"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) {
|
func Load() (Config, error) {
|
||||||
c := Config{
|
c := Config{
|
||||||
Provider: os.Getenv("CAPTCHA_LLM_PROVIDER"),
|
Provider: os.Getenv("CAPTCHA_LLM_PROVIDER"),
|
||||||
@@ -37,18 +44,29 @@ func Load() (Config, error) {
|
|||||||
Model: os.Getenv("CAPTCHA_LLM_MODEL"),
|
Model: os.Getenv("CAPTCHA_LLM_MODEL"),
|
||||||
AudioModel: os.Getenv("CAPTCHA_LLM_AUDIO_MODEL"),
|
AudioModel: os.Getenv("CAPTCHA_LLM_AUDIO_MODEL"),
|
||||||
LogLevel: envOr("CAPTCHA_LOG_LEVEL", "info"),
|
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,
|
MaxTokens: 1024,
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
if !validProviders[c.Provider] {
|
if !validProviders[c.Provider] {
|
||||||
return Config{}, fmt.Errorf("CAPTCHA_LLM_PROVIDER must be one of anthropic|openai|openai-compatible|gemini, got %q", 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 == "" {
|
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)")
|
return Config{}, fmt.Errorf("CAPTCHA_LLM_API_KEY is required (or set CAPTCHA_LLM_BASE_URL for a keyless local endpoint)")
|
||||||
}
|
}
|
||||||
if c.Model == "" {
|
if c.Model == "" {
|
||||||
return Config{}, fmt.Errorf("CAPTCHA_LLM_MODEL is required")
|
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 != "" {
|
if v := os.Getenv("CAPTCHA_LLM_MAX_TOKENS"); v != "" {
|
||||||
n, err := strconv.Atoi(v)
|
n, err := strconv.Atoi(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,7 +21,25 @@ func TestLoad(t *testing.T) {
|
|||||||
},
|
},
|
||||||
want: Config{
|
want: Config{
|
||||||
Provider: "anthropic", APIKey: "k", Model: "claude-opus-4-8",
|
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"},
|
env: map[string]string{"CAPTCHA_LLM_PROVIDER": "nope", "CAPTCHA_LLM_API_KEY": "k", "CAPTCHA_LLM_MODEL": "m"},
|
||||||
wantErr: true,
|
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 {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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