diff --git a/go.mod b/go.mod index 637f0f3..9042d5e 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( connectrpc.com/connect v1.19.1 github.com/alicebob/miniredis/v2 v2.37.0 github.com/evalops/proto v0.0.0-20260414193513-3db7075bd55b - github.com/evalops/service-runtime v0.1.45-0.20260415082800-4de81511447f + github.com/evalops/service-runtime v0.1.48 github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/nats-io/nats.go v1.50.0 @@ -18,6 +18,7 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -25,6 +26,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.15 // indirect @@ -38,11 +40,20 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect ) diff --git a/go.sum b/go.sum index d0f6f3f..bbcb326 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,16 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -16,8 +20,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/evalops/proto v0.0.0-20260414193513-3db7075bd55b h1:lc1B3iZgerJlkQRiLTcUtCadb1zCB6d941snfxhMkxM= github.com/evalops/proto v0.0.0-20260414193513-3db7075bd55b/go.mod h1:EXB8IcqMaV58Tt0w2GaQia3YOwzrEvnIWFZetHX+IxE= -github.com/evalops/service-runtime v0.1.45-0.20260415082800-4de81511447f h1:KXmATjln5IfD2qa7u8LFlejNDJ4qJCJ1rqOt/sLmx+Q= -github.com/evalops/service-runtime v0.1.45-0.20260415082800-4de81511447f/go.mod h1:slYEbsp+qsfQAK/vjXml9J2k+6N2I96FT435l7dLvRE= +github.com/evalops/service-runtime v0.1.48 h1:LKICxc2GOJEoLZSzfsRlKMEoZ0fxO/fMXVCRy1z2xIA= +github.com/evalops/service-runtime v0.1.48/go.mod h1:3kPegrWrMpsJWX+Kv0+J47bmaB2gcWhtVRXGGZlD3Rk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= @@ -29,12 +33,18 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -53,10 +63,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.12.6 h1:Egbx9Vl7Ch8wTtpXPGqbehkZ+IncKqShUxvrt1+Enc8= +github.com/nats-io/nats-server/v2 v2.12.6/go.mod h1:4HPlrvtmSO3yd7KcElDNMx9kv5EBJBnJJzQPptXlheo= github.com/nats-io/nats.go v1.50.0 h1:5zAeQrTvyrKrWLJ0fu02W3br8ym57qf7csDzgLOpcds= github.com/nats-io/nats.go v1.50.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= @@ -95,14 +111,20 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -111,6 +133,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -119,8 +143,18 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/agentmcp/anonymous.go b/internal/agentmcp/anonymous.go new file mode 100644 index 0000000..a077439 --- /dev/null +++ b/internal/agentmcp/anonymous.go @@ -0,0 +1,116 @@ +package agentmcp + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/evalops/agent-mcp/internal/config" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + AnonymousSandboxHeader = "X-EvalOps-Anonymous-Sandbox" + + anonymousAuthMessage = "This action requires authentication. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GITHUB_TOKEN, or GH_TOKEN in your environment, or call evalops_register with a user_token." +) + +func (rc *requestContext) currentSession() (*SessionState, bool) { + sid := rc.mcpSessionID() + if sid == "" { + return nil, false + } + return rc.deps.Sessions.Get(sid) +} + +func (rc *requestContext) isAnonymousRequest() bool { + if rc.request == nil { + return false + } + if strings.EqualFold(strings.TrimSpace(rc.request.Header.Get(AnonymousSandboxHeader)), "true") { + return true + } + state, ok := rc.currentSession() + return ok && state.IsAnonymous() +} + +func (rc *requestContext) authenticationRequiredResult(action string) *mcpsdk.CallToolResult { + message := anonymousAuthMessage + if strings.TrimSpace(action) != "" { + message = fmt.Sprintf("%s Authenticate to %s.", anonymousAuthMessage, strings.TrimSpace(action)) + } + payload := map[string]any{ + "error": "authentication_required", + "message": message, + "upgrade_options": upgradeOptions(rc.deps.Config), + } + return structuredToolError(payload) +} + +func (rc *requestContext) anonymousCheckActionResult(input checkActionInput) checkActionOutput { + riskLevel := anonymousRiskLevel(input) + return checkActionOutput{ + Decision: "allow", + DryRun: true, + Message: "Anonymous sandbox dry-run only. Authenticate to enforce policies, create approvals, and persist audit history.", + Reasons: []string{"anonymous sandbox mode does not enforce governance decisions"}, + RiskLevel: riskLevel, + UpgradeOptions: upgradeOptions(rc.deps.Config), + } +} + +func anonymousRiskLevel(input checkActionInput) string { + text := strings.ToLower(strings.TrimSpace(input.ActionType + " " + input.ActionPayload)) + switch { + case containsAny(text, + "bash", "shell", "exec", "sudo", "rm ", " delete", "drop ", "truncate", "deploy", "prod", + "terraform apply", "kubectl", "curl ", "http", "api", "payment", "message", "email", "slack", + "git push", "write_file", "database"): + return "high" + case containsAny(text, "edit", "write", "create", "install", "commit", "run", "patch", "apply"): + return "medium" + default: + return "low" + } +} + +func containsAny(value string, candidates ...string) bool { + for _, candidate := range candidates { + if strings.Contains(value, candidate) { + return true + } + } + return false +} + +func upgradeOptions(cfg config.Config) []map[string]any { + options := []map[string]any{ + { + "env_vars": []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN"}, + "method": "federation", + }, + } + if resourceURL := strings.TrimRight(strings.TrimSpace(cfg.ResourceURL), "/"); resourceURL != "" { + options = append(options, map[string]any{ + "method": "oauth", + "url": resourceURL + "/.well-known/oauth-protected-resource", + }) + } + options = append(options, map[string]any{ + "header": "Authorization: Bearer pk_...", + "method": "api_key", + }) + return options +} + +func structuredToolError(payload map[string]any) *mcpsdk.CallToolResult { + body, err := json.Marshal(payload) + if err != nil { + body = []byte(`{"error":"internal_error","message":"failed to marshal tool error payload"}`) + } + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: string(body)}}, + IsError: true, + StructuredContent: payload, + } +} diff --git a/internal/agentmcp/anonymous_test.go b/internal/agentmcp/anonymous_test.go new file mode 100644 index 0000000..62676e8 --- /dev/null +++ b/internal/agentmcp/anonymous_test.go @@ -0,0 +1,137 @@ +package agentmcp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/evalops/agent-mcp/internal/config" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func callResultMap(t *testing.T, result *mcpsdk.CallToolResult) map[string]any { + t.Helper() + if result == nil { + t.Fatal("expected call tool result") + } + if mapped, ok := result.StructuredContent.(map[string]any); ok { + return mapped + } + for _, content := range result.Content { + text, ok := content.(*mcpsdk.TextContent) + if !ok { + continue + } + var mapped map[string]any + if err := json.Unmarshal([]byte(text.Text), &mapped); err == nil { + return mapped + } + } + t.Fatalf("expected structured content map, got %#v", result) + return nil +} + +func TestAnonymousRegisterReturnsAuthenticationRequiredResult(t *testing.T) { + deps := &Deps{ + Config: config.Config{ResourceURL: "https://mcp.evalops.dev"}, + Sessions: NewSessionStore(), + Metrics: NewTestMetrics(), + Events: NoopEventPublisher{}, + Breakers: NewBreakers(config.BreakerConfig{FailureThreshold: 5}), + Logger: testLogger, + } + deps.Sessions.Set("sess_anon", &SessionState{ + SessionType: SessionTypeAnonymous, + ExpiresAt: time.Now().Add(time.Hour), + Surface: "mcp", + }) + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Mcp-Session-Id", "sess_anon") + req.Header.Set(AnonymousSandboxHeader, "true") + rc := &requestContext{deps: deps, request: req, logger: testLogger} + + result, out, err := rc.toolRegister(context.Background(), nil, registerInput{AgentType: "codex", Surface: "cli"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Registered { + t.Fatalf("expected register output to remain false, got %#v", out) + } + if !result.IsError { + t.Fatalf("expected tool error result, got %#v", result) + } + payload := callResultMap(t, result) + if payload["error"] != "authentication_required" { + t.Fatalf("expected authentication_required, got %#v", payload["error"]) + } +} + +func TestAnonymousCheckActionReturnsDryRun(t *testing.T) { + deps := &Deps{ + Config: config.Config{ResourceURL: "https://mcp.evalops.dev"}, + Sessions: NewSessionStore(), + Metrics: NewTestMetrics(), + Events: NoopEventPublisher{}, + Breakers: NewBreakers(config.BreakerConfig{FailureThreshold: 5}), + Logger: testLogger, + } + deps.Sessions.Set("sess_anon", &SessionState{ + SessionType: SessionTypeAnonymous, + ExpiresAt: time.Now().Add(time.Hour), + Surface: "mcp", + }) + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Mcp-Session-Id", "sess_anon") + req.Header.Set(AnonymousSandboxHeader, "true") + rc := &requestContext{deps: deps, request: req, logger: testLogger} + + _, out, err := rc.toolCheckAction(context.Background(), nil, checkActionInput{ + ActionType: "Bash", + ActionPayload: "rm -rf /tmp/work", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !out.DryRun { + t.Fatalf("expected dry-run output, got %#v", out) + } + if out.Decision != "allow" { + t.Fatalf("expected allow in dry-run mode, got %#v", out.Decision) + } + if out.RiskLevel != "high" { + t.Fatalf("expected high dry-run risk, got %#v", out.RiskLevel) + } + if len(out.UpgradeOptions) == 0 { + t.Fatalf("expected upgrade options, got %#v", out) + } +} + +func TestReadAgentStatusAnonymousSession(t *testing.T) { + deps := &Deps{Sessions: NewSessionStore(), Config: config.Config{}} + deps.Sessions.Set("sess_anon", &SessionState{ + SessionType: SessionTypeAnonymous, + ExpiresAt: time.Now().Add(time.Hour), + Surface: "mcp", + }) + + result, err := readAgentStatus(deps, "sess_anon") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var data map[string]any + if err := json.Unmarshal([]byte(result.Contents[0].Text), &data); err != nil { + t.Fatalf("decode resource payload: %v", err) + } + if data["anonymous"] != true { + t.Fatalf("expected anonymous=true, got %#v", data) + } + if data["registered"] != false { + t.Fatalf("expected registered=false, got %#v", data) + } +} diff --git a/internal/agentmcp/resources.go b/internal/agentmcp/resources.go index a6873fd..b467289 100644 --- a/internal/agentmcp/resources.go +++ b/internal/agentmcp/resources.go @@ -114,6 +114,16 @@ func readAgentStatus(deps *Deps, sessionID string) (*mcpsdk.ReadResourceResult, "message": "no active session \u2014 call evalops_register first", }) } + if state.IsAnonymous() { + return jsonResource("evalops://agent/status", map[string]any{ + "active_sessions": deps.Sessions.ActiveCount(), + "anonymous": true, + "expires_at": state.ExpiresAt.Format("2006-01-02T15:04:05Z"), + "message": "anonymous sandbox session active \u2014 authenticate to register an agent or unlock write operations", + "registered": false, + "session_type": SessionTypeAnonymous, + }) + } return jsonResource("evalops://agent/status", map[string]any{ "registered": true, "agent_id": state.AgentID, @@ -155,6 +165,14 @@ func readHookRequirements(deps *Deps, sessionID string) (*mcpsdk.ReadResourceRes func readApprovalHabits(ctx context.Context, deps *Deps, sessionID string) (*mcpsdk.ReadResourceResult, error) { state, _ := deps.Sessions.Get(sessionID) + if state != nil && state.IsAnonymous() { + return jsonResource("evalops://agent/habits", map[string]any{ + "anonymous": true, + "available": true, + "habits": []any{}, + "message": "anonymous sandbox exposes workspace-default approval habits only; authenticate for learned workspace habits", + }) + } workspaceID := "" agentToken := "" if state != nil { @@ -220,6 +238,15 @@ func approvalHabitsResource(habits []*approvalsv1.ApprovalHabit) (*mcpsdk.ReadRe } func readOperatingRules(ctx context.Context, deps *Deps, sessionID string) (*mcpsdk.ReadResourceResult, error) { + state, _ := deps.Sessions.Get(sessionID) + if state != nil && state.IsAnonymous() { + return jsonResource("evalops://agent/operating-rules", map[string]any{ + "anonymous": true, + "available": true, + "message": "anonymous sandbox exposes public/default operating rules only; authenticate for organization-scoped rules", + "rules": []any{}, + }) + } if deps.Memory == nil || deps.Config.Memory.BaseURL == "" { return jsonResource("evalops://agent/operating-rules", map[string]any{ "available": false, @@ -227,7 +254,6 @@ func readOperatingRules(ctx context.Context, deps *Deps, sessionID string) (*mcp }) } - state, _ := deps.Sessions.Get(sessionID) agentToken := "" orgID := "" if state != nil { diff --git a/internal/agentmcp/session.go b/internal/agentmcp/session.go index f84a330..c318a3e 100644 --- a/internal/agentmcp/session.go +++ b/internal/agentmcp/session.go @@ -8,6 +8,7 @@ import ( // SessionState holds per-MCP-session agent state. type SessionState struct { + SessionType string `json:"session_type,omitempty"` AgentID string `json:"agent_id"` AgentToken string `json:"agent_token"` AgentType string `json:"agent_type"` @@ -19,6 +20,15 @@ type SessionState struct { WorkspaceID string `json:"workspace_id,omitempty"` } +const ( + SessionTypeAgent = "agent" + SessionTypeAnonymous = "anonymous" +) + +func (s *SessionState) IsAnonymous() bool { + return s != nil && s.SessionType == SessionTypeAnonymous +} + // SessionBackend is the interface for session persistence. // Implemented by MemorySessionStore and RedisSessionStore. type SessionBackend interface { diff --git a/internal/agentmcp/tools_api_keys.go b/internal/agentmcp/tools_api_keys.go index ccca770..11fe5d0 100644 --- a/internal/agentmcp/tools_api_keys.go +++ b/internal/agentmcp/tools_api_keys.go @@ -50,6 +50,9 @@ func (rc *requestContext) toolCreateAPIKey( _ *mcpsdk.CallToolRequest, input createAPIKeyInput, ) (*mcpsdk.CallToolResult, createAPIKeyOutput, error) { + if rc.isAnonymousRequest() { + return rc.authenticationRequiredResult("create an API key"), createAPIKeyOutput{}, nil + } bearerToken := rc.bearerToken() if bearerToken == "" { return nil, createAPIKeyOutput{}, fmt.Errorf("missing user bearer token") @@ -103,6 +106,9 @@ func (rc *requestContext) toolListAPIKeys( _ *mcpsdk.CallToolRequest, _ struct{}, ) (*mcpsdk.CallToolResult, listAPIKeysOutput, error) { + if rc.isAnonymousRequest() { + return rc.authenticationRequiredResult("list API keys"), listAPIKeysOutput{}, nil + } bearerToken := rc.bearerToken() if bearerToken == "" { return nil, listAPIKeysOutput{}, fmt.Errorf("missing user bearer token") diff --git a/internal/agentmcp/tools_governance.go b/internal/agentmcp/tools_governance.go index 7e04717..1c6a750 100644 --- a/internal/agentmcp/tools_governance.go +++ b/internal/agentmcp/tools_governance.go @@ -20,11 +20,14 @@ type checkActionInput struct { } type checkActionOutput struct { - Decision string `json:"decision"` - RiskLevel string `json:"risk_level"` - Reasons []string `json:"reasons,omitempty"` - ApprovalID string `json:"approval_id,omitempty"` - MatchedRules []string `json:"matched_rules,omitempty"` + Decision string `json:"decision"` + RiskLevel string `json:"risk_level"` + Reasons []string `json:"reasons,omitempty"` + ApprovalID string `json:"approval_id,omitempty"` + MatchedRules []string `json:"matched_rules,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + Message string `json:"message,omitempty"` + UpgradeOptions []map[string]any `json:"upgrade_options,omitempty"` } func (rc *requestContext) toolCheckAction( @@ -32,6 +35,9 @@ func (rc *requestContext) toolCheckAction( _ *mcpsdk.CallToolRequest, input checkActionInput, ) (*mcpsdk.CallToolResult, checkActionOutput, error) { + if rc.isAnonymousRequest() { + return nil, rc.anonymousCheckActionResult(input), nil + } if rc.deps.Governance == nil || rc.deps.Config.Governance.BaseURL == "" { return nil, checkActionOutput{Decision: "allow", RiskLevel: "low", Reasons: []string{"governance service not configured"}}, nil } @@ -182,6 +188,9 @@ func (rc *requestContext) toolCheckApproval( _ *mcpsdk.CallToolRequest, input checkApprovalInput, ) (*mcpsdk.CallToolResult, checkApprovalOutput, error) { + if rc.isAnonymousRequest() { + return rc.authenticationRequiredResult("check approval state"), checkApprovalOutput{}, nil + } if rc.deps.Approvals == nil || rc.deps.Config.Approvals.BaseURL == "" { return nil, checkApprovalOutput{}, fmt.Errorf("approvals service not configured") } diff --git a/internal/agentmcp/tools_lifecycle.go b/internal/agentmcp/tools_lifecycle.go index c273f7c..86c3381 100644 --- a/internal/agentmcp/tools_lifecycle.go +++ b/internal/agentmcp/tools_lifecycle.go @@ -42,6 +42,9 @@ func (rc *requestContext) toolRegister( _ *mcpsdk.CallToolRequest, input registerInput, ) (*mcpsdk.CallToolResult, registerOutput, error) { + if rc.isAnonymousRequest() { + return rc.authenticationRequiredResult("register this agent"), registerOutput{}, nil + } workspaceID := strings.TrimSpace(input.WorkspaceID) if workspaceID == "" { workspaceID = strings.TrimSpace(rc.deps.Config.Federation.DefaultWorkspaceID) @@ -69,6 +72,7 @@ func (rc *requestContext) toolRegister( sid := rc.mcpSessionID() if sid != "" { rc.deps.Sessions.Set(sid, &SessionState{ + SessionType: SessionTypeAgent, AgentID: session.AgentID, AgentToken: session.AgentToken, AgentType: input.AgentType, @@ -278,6 +282,9 @@ func (rc *requestContext) toolHeartbeat( _ *mcpsdk.CallToolRequest, input heartbeatInput, ) (*mcpsdk.CallToolResult, heartbeatOutput, error) { + if rc.isAnonymousRequest() { + return rc.authenticationRequiredResult("renew a registered agent session"), heartbeatOutput{}, nil + } sid := rc.mcpSessionID() state, ok := rc.deps.Sessions.Get(sid) if !ok { @@ -359,6 +366,9 @@ func (rc *requestContext) toolDeregister( _ *mcpsdk.CallToolRequest, _ deregisterInput, ) (*mcpsdk.CallToolResult, deregisterOutput, error) { + if rc.isAnonymousRequest() { + return rc.authenticationRequiredResult("deregister a registered agent"), deregisterOutput{}, nil + } sid := rc.mcpSessionID() state, ok := rc.deps.Sessions.Get(sid) if !ok { diff --git a/internal/agentmcp/tools_metering.go b/internal/agentmcp/tools_metering.go index 98f751e..c6f28f2 100644 --- a/internal/agentmcp/tools_metering.go +++ b/internal/agentmcp/tools_metering.go @@ -30,6 +30,9 @@ func (rc *requestContext) toolReportUsage( _ *mcpsdk.CallToolRequest, input reportUsageInput, ) (*mcpsdk.CallToolResult, reportUsageOutput, error) { + if rc.isAnonymousRequest() { + return rc.authenticationRequiredResult("report metered usage"), reportUsageOutput{Recorded: false}, nil + } if rc.deps.Meter == nil || rc.deps.Config.Meter.BaseURL == "" { return nil, reportUsageOutput{Recorded: false}, nil } diff --git a/internal/http/mcp_auth.go b/internal/http/mcp_auth.go index e8acc49..052a7ad 100644 --- a/internal/http/mcp_auth.go +++ b/internal/http/mcp_auth.go @@ -7,9 +7,13 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "strings" + "sync" + "time" + "github.com/evalops/agent-mcp/internal/agentmcp" "github.com/evalops/agent-mcp/internal/clients" "github.com/evalops/agent-mcp/internal/config" ) @@ -21,6 +25,7 @@ type mcpAuthContextKey struct{} type mcpRequestInfo struct { Method string ToolName string + AgentType string UserToken string } @@ -33,6 +38,7 @@ type mcpToolCallEnvelope struct { } type mcpRegisterArguments struct { + AgentType string `json:"agent_type"` UserToken string `json:"user_token"` } @@ -45,6 +51,24 @@ var mcpToolScopes = map[string]string{ "evalops_report_usage": "meter:record", } +const ( + anonymousSessionTTL = time.Hour + anonymousPerMinuteLimit = 10 + anonymousPerHourLimit = 100 +) + +type anonymousAccessLimiter struct { + mu sync.Mutex + entries map[string]*anonymousAccessWindow +} + +type anonymousAccessWindow struct { + hourCount int + hourWindowStart time.Time + minuteCount int + minuteWindowStart time.Time +} + func newProtectedResourceMetadataHandler(cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -66,10 +90,11 @@ func newProtectedResourceMetadataHandler(cfg config.Config) http.HandlerFunc { } } -func newMCPAuthMiddleware(cfg config.Config, identityClient *clients.IdentityClient, logger *slog.Logger) func(http.Handler) http.Handler { +func newMCPAuthMiddleware(cfg config.Config, identityClient *clients.IdentityClient, sessions agentmcp.SessionBackend, logger *slog.Logger) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() } + limiter := newAnonymousAccessLimiter() return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -86,9 +111,32 @@ func newMCPAuthMiddleware(cfg config.Config, identityClient *clients.IdentityCli return } + now := time.Now().UTC() + if state, ok := activeSessionState(sessions, strings.TrimSpace(r.Header.Get("Mcp-Session-Id")), now); ok { + if state.IsAnonymous() { + if !limiter.Allow(clientAddress(r), now) { + writeMCPRateLimited(w) + return + } + r.Header.Set(agentmcp.AnonymousSandboxHeader, "true") + } + next.ServeHTTP(w, r) + return + } + token := bearerTokenFromHeader(r.Header.Get("Authorization")) if token == "" { - writeMCPUnauthorized(w, r, cfg) + if info.Method == "tools/call" && info.ToolName == "evalops_register" && hasConfiguredFederationCredential(cfg.Federation, info.AgentType) { + next.ServeHTTP(w, r) + return + } + if !limiter.Allow(clientAddress(r), now) { + writeMCPRateLimited(w) + return + } + ensureAnonymousSession(sessions, strings.TrimSpace(r.Header.Get("Mcp-Session-Id")), now) + r.Header.Set(agentmcp.AnonymousSandboxHeader, "true") + next.ServeHTTP(w, r) return } @@ -152,11 +200,144 @@ func inspectMCPRequest(r *http.Request) (mcpRequestInfo, error) { if err := json.Unmarshal(envelope.Params.Arguments, &args); err != nil { return info, fmt.Errorf("decode evalops_register arguments: %w", err) } + info.AgentType = strings.TrimSpace(args.AgentType) info.UserToken = strings.TrimSpace(args.UserToken) } return info, nil } +func newAnonymousAccessLimiter() *anonymousAccessLimiter { + return &anonymousAccessLimiter{entries: make(map[string]*anonymousAccessWindow)} +} + +func (l *anonymousAccessLimiter) Allow(key string, now time.Time) bool { + key = strings.TrimSpace(key) + if key == "" { + key = "unknown" + } + minuteStart := now.UTC().Truncate(time.Minute) + hourStart := now.UTC().Truncate(time.Hour) + + l.mu.Lock() + defer l.mu.Unlock() + + entry, ok := l.entries[key] + if !ok { + entry = &anonymousAccessWindow{} + l.entries[key] = entry + } + if entry.minuteWindowStart != minuteStart { + entry.minuteWindowStart = minuteStart + entry.minuteCount = 0 + } + if entry.hourWindowStart != hourStart { + entry.hourWindowStart = hourStart + entry.hourCount = 0 + } + if entry.minuteCount >= anonymousPerMinuteLimit || entry.hourCount >= anonymousPerHourLimit { + return false + } + entry.minuteCount++ + entry.hourCount++ + return true +} + +func activeSessionState(sessions agentmcp.SessionBackend, sessionID string, now time.Time) (*agentmcp.SessionState, bool) { + if sessions == nil || strings.TrimSpace(sessionID) == "" { + return nil, false + } + state, ok := sessions.Get(sessionID) + if !ok || state == nil { + return nil, false + } + if !state.ExpiresAt.IsZero() && state.ExpiresAt.Before(now) { + sessions.Delete(sessionID) + return nil, false + } + return state, true +} + +func ensureAnonymousSession(sessions agentmcp.SessionBackend, sessionID string, now time.Time) { + if sessions == nil || strings.TrimSpace(sessionID) == "" { + return + } + if state, ok := activeSessionState(sessions, sessionID, now); ok && !state.IsAnonymous() { + return + } else if ok && state.IsAnonymous() { + return + } + sessions.Set(sessionID, &agentmcp.SessionState{ + ExpiresAt: now.Add(anonymousSessionTTL), + SessionType: agentmcp.SessionTypeAnonymous, + Surface: "mcp", + }) +} + +func hasConfiguredFederationCredential(federation config.FederationConfig, agentType string) bool { + providers := configuredFederationProviders(federation) + if len(providers) == 0 { + return false + } + if provider := inferFederationProvider(agentType); provider != "" { + _, ok := providers[provider] + return ok + } + return len(providers) == 1 +} + +func configuredFederationProviders(federation config.FederationConfig) map[string]string { + configured := make(map[string]string, 4) + if strings.TrimSpace(federation.AnthropicAPIKey) != "" { + configured["anthropic"] = federation.AnthropicAPIKey + } + if strings.TrimSpace(federation.OpenAIAPIKey) != "" { + configured["openai"] = federation.OpenAIAPIKey + } + if strings.TrimSpace(federation.GitHubToken) != "" { + configured["github"] = federation.GitHubToken + } + if strings.TrimSpace(federation.GoogleAccessToken) != "" { + configured["google"] = federation.GoogleAccessToken + } + return configured +} + +func inferFederationProvider(agentType string) string { + normalized := strings.ToLower(strings.TrimSpace(agentType)) + switch { + case strings.Contains(normalized, "claude"), strings.Contains(normalized, "anthropic"): + return "anthropic" + case strings.Contains(normalized, "codex"), strings.Contains(normalized, "openai"): + return "openai" + case strings.Contains(normalized, "copilot"), strings.Contains(normalized, "github"): + return "github" + case strings.Contains(normalized, "gemini"), strings.Contains(normalized, "google"): + return "google" + default: + return "" + } +} + +func clientAddress(r *http.Request) string { + if r == nil { + return "" + } + forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")) + if forwarded != "" { + if parts := strings.Split(forwarded, ","); len(parts) > 0 { + return strings.TrimSpace(parts[0]) + } + } + if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" { + return realIP + } + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err == nil { + return host + } + return strings.TrimSpace(r.RemoteAddr) +} + func requiredScopeForRequest(info mcpRequestInfo) string { if info.Method != "tools/call" { return "" @@ -251,6 +432,14 @@ func writeMCPInsufficientScope(w http.ResponseWriter, scope string) { }) } +func writeMCPRateLimited(w http.ResponseWriter) { + w.Header().Set("Retry-After", "60") + writeJSON(w, http.StatusTooManyRequests, map[string]string{ + "error": "rate_limited", + "message": "Anonymous sandbox rate limit exceeded. Retry in about a minute or authenticate for a full session.", + }) +} + func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/http/mcp_auth_test.go b/internal/http/mcp_auth_test.go index 5cd76f5..9c23ffd 100644 --- a/internal/http/mcp_auth_test.go +++ b/internal/http/mcp_auth_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/evalops/agent-mcp/internal/agentmcp" "github.com/evalops/agent-mcp/internal/clients" "github.com/evalops/agent-mcp/internal/config" ) @@ -42,25 +43,56 @@ func TestProtectedResourceMetadataHandler(t *testing.T) { } } -func TestMCPAuthMiddlewareReturns401WithProtectedResourceMetadata(t *testing.T) { +func TestMCPAuthMiddlewareCreatesAnonymousSessionForUnauthenticatedRequests(t *testing.T) { cfg := config.Config{ResourceURL: "https://mcp.evalops.dev"} identityClient := clients.NewIdentityClient("http://identity.invalid", http.DefaultClient, time.Second) - handler := newMCPAuthMiddleware(cfg, identityClient, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("next handler should not be called") + sessions := agentmcp.NewSessionStore() + nextCalled := false + handler := newMCPAuthMiddleware(cfg, identityClient, sessions, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusNoContent) })) rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"evalops_register","arguments":{"agent_type":"codex","surface":"cli"}}}`)) + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"evalops_check_action","arguments":{"action_type":"Bash"}}}`)) + req.Header.Set("Mcp-Session-Id", "sess_anon") handler.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rec.Code) + if !nextCalled { + t.Fatal("expected next handler to run") } - if got := rec.Header().Get("WWW-Authenticate"); got != `Bearer resource_metadata="https://mcp.evalops.dev/.well-known/oauth-protected-resource"` { - t.Fatalf("unexpected WWW-Authenticate header %q", got) + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", rec.Code) + } + state, ok := sessions.Get("sess_anon") + if !ok || state == nil || !state.IsAnonymous() { + t.Fatalf("expected anonymous session state, got %#v", state) + } +} + +func TestMCPAuthMiddlewareAllowsRegisterWhenFederationCredentialConfigured(t *testing.T) { + cfg := config.Config{ + Federation: config.FederationConfig{OpenAIAPIKey: "sk-openai"}, + ResourceURL: "https://mcp.evalops.dev", + } + identityClient := clients.NewIdentityClient("http://identity.invalid", http.DefaultClient, time.Second) + sessions := agentmcp.NewSessionStore() + nextCalled := false + handler := newMCPAuthMiddleware(cfg, identityClient, sessions, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusNoContent) + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"evalops_register","arguments":{"agent_type":"codex","surface":"cli"}}}`)) + req.Header.Set("Mcp-Session-Id", "sess_reg") + handler.ServeHTTP(rec, req) + + if !nextCalled { + t.Fatal("expected next handler to run") } - if !strings.Contains(rec.Body.String(), `"error":"unauthorized"`) { - t.Fatalf("expected unauthorized body, got %s", rec.Body.String()) + if _, ok := sessions.Get("sess_reg"); ok { + t.Fatal("did not expect anonymous session when register can use configured federation") } } @@ -73,7 +105,7 @@ func TestMCPAuthMiddlewareAllowsRegisterWithExplicitUserToken(t *testing.T) { identityClient := clients.NewIdentityClient(identitySrv.URL, identitySrv.Client(), time.Second) nextCalled := false - handler := newMCPAuthMiddleware(cfg, identityClient, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := newMCPAuthMiddleware(cfg, identityClient, agentmcp.NewSessionStore(), authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextCalled = true w.WriteHeader(http.StatusNoContent) })) @@ -102,7 +134,7 @@ func TestMCPAuthMiddlewareReturns403ForInsufficientScope(t *testing.T) { cfg := config.Config{ResourceURL: "https://mcp.evalops.dev"} identityClient := clients.NewIdentityClient(identitySrv.URL, identitySrv.Client(), time.Second) - handler := newMCPAuthMiddleware(cfg, identityClient, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := newMCPAuthMiddleware(cfg, identityClient, agentmcp.NewSessionStore(), authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -128,7 +160,7 @@ func TestMCPAuthMiddlewareRequiresRegisterScopeForDeregister(t *testing.T) { cfg := config.Config{ResourceURL: "https://mcp.evalops.dev"} identityClient := clients.NewIdentityClient(identitySrv.URL, identitySrv.Client(), time.Second) - handler := newMCPAuthMiddleware(cfg, identityClient, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := newMCPAuthMiddleware(cfg, identityClient, agentmcp.NewSessionStore(), authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -154,7 +186,7 @@ func TestMCPAuthMiddlewareRejectsAudienceMismatch(t *testing.T) { cfg := config.Config{ResourceURL: "https://mcp.evalops.dev"} identityClient := clients.NewIdentityClient(identitySrv.URL, identitySrv.Client(), time.Second) - handler := newMCPAuthMiddleware(cfg, identityClient, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := newMCPAuthMiddleware(cfg, identityClient, agentmcp.NewSessionStore(), authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -178,7 +210,7 @@ func TestMCPAuthMiddlewareAllowsValidBearerToken(t *testing.T) { cfg := config.Config{ResourceURL: "https://mcp.evalops.dev"} identityClient := clients.NewIdentityClient(identitySrv.URL, identitySrv.Client(), time.Second) nextCalled := false - handler := newMCPAuthMiddleware(cfg, identityClient, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := newMCPAuthMiddleware(cfg, identityClient, agentmcp.NewSessionStore(), authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextCalled = true w.WriteHeader(http.StatusNoContent) })) @@ -204,7 +236,7 @@ func TestMCPAuthMiddlewareRejectsBearerTokensWithoutConfiguredResourceURL(t *tes defer identitySrv.Close() identityClient := clients.NewIdentityClient(identitySrv.URL, identitySrv.Client(), time.Second) - handler := newMCPAuthMiddleware(config.Config{}, identityClient, authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := newMCPAuthMiddleware(config.Config{}, identityClient, agentmcp.NewSessionStore(), authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) @@ -219,3 +251,29 @@ func TestMCPAuthMiddlewareRejectsBearerTokensWithoutConfiguredResourceURL(t *tes t.Fatalf("expected 401, got %d", rec.Code) } } + +func TestMCPAuthMiddlewareRateLimitsAnonymousRequests(t *testing.T) { + cfg := config.Config{ResourceURL: "https://mcp.evalops.dev"} + identityClient := clients.NewIdentityClient("http://identity.invalid", http.DefaultClient, time.Second) + handler := newMCPAuthMiddleware(cfg, identityClient, agentmcp.NewSessionStore(), authTestLogger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + for i := 0; i < anonymousPerMinuteLimit; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"evalops_check_action","arguments":{"action_type":"Bash"}}}`)) + req.RemoteAddr = "203.0.113.10:54321" + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("request %d: expected 204, got %d body=%s", i+1, rec.Code, rec.Body.String()) + } + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"evalops_check_action","arguments":{"action_type":"Bash"}}}`)) + req.RemoteAddr = "203.0.113.10:54321" + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429, got %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/http/router.go b/internal/http/router.go index 7b6e7ed..093fa48 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -161,7 +161,7 @@ func BuildRouter(ctx context.Context, cfg config.Config, logger *slog.Logger) (* mux.Handle("/readyz", checker.Handler(5*time.Second)) mux.Handle("/metrics", httpkit.MetricsHandler()) mux.Handle("/.well-known/oauth-protected-resource", newProtectedResourceMetadataHandler(cfg)) - mux.Handle("/mcp", newMCPAuthMiddleware(cfg, identityClient, logger)(agentmcp.NewHandler(deps))) + mux.Handle("/mcp", newMCPAuthMiddleware(cfg, identityClient, sessionStore, logger)(agentmcp.NewHandler(deps))) handler := httpkit.WithRequestID(httpkit.WithRequestLogging(logger)(mux)) @@ -183,6 +183,9 @@ func BuildRouter(ctx context.Context, cfg config.Config, logger *slog.Logger) (* if len(sessions) > 0 { logger.Info("graceful shutdown: deregistering active sessions", "count", len(sessions)) for _, state := range sessions { + if state == nil || state.IsAnonymous() || state.AgentToken == "" { + continue + } if err := identityClient.DeregisterAgent(ctx, state.AgentToken); err != nil { logger.Warn("shutdown deregister failed", "agent_id", state.AgentID, "error", err) } else {