diff --git a/CONTEXT.md b/CONTEXT.md index 20879b79..db43aaae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -79,3 +79,9 @@ The agent that turns a published design into an ordered, dependency-aware task breakdown (plan) and expands each task into an issue brief (detail). Guided by the `task-breakdown` Skill; typed rows are minted by the platform, never by the agent. _Avoid_: tech lead. + +**Playground token**: +A short-lived MCP token minted for a human driving the playground locally, +via an endpoint that exists only when explicitly enabled in a local deployment. +Scoped to one org; minted fresh per turn. Never part of the production +authentication story (which remains an open decision). diff --git a/deployments/docker-compose.yml b/deployments/docker-compose.yml index 492dc24b..5e78e884 100644 --- a/deployments/docker-compose.yml +++ b/deployments/docker-compose.yml @@ -53,6 +53,12 @@ services: # Local-only by design — cloud release bindings never set this. # See deployments/scripts/repair-secrets.sh. LOCAL_OPENBAO_REPAIR: "true" + # Gates POST /internal/v1/mcp/playground-token — mints a short-lived MCP + # token with NO caller auth so services/agents' playground CLI can drive + # dependency discovery against this aep-api locally (AEP_MCP_URL et al. + # in services/agents/.env.example). Local-only by design — never set in + # helm release bindings. + PLAYGROUND_TOKEN_ENABLED: "true" # ── OpenChoreo platform API (reached via k3d loadbalancer + Host header) PLATFORM_API_SERVICE_BASE_URL: "${PLATFORM_API_SERVICE_BASE_URL}" diff --git a/go.work.sum b/go.work.sum index eafb9f31..35758b01 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,6 +1,136 @@ -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= +github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= +github.com/danielgtaylor/mexpr v1.9.1/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8= +github.com/danielgtaylor/shorthand/v2 v2.2.0/go.mod h1:t5QfaNf7DPru9ZLIIhPQSO7Gyvajm3euw7LxB/MTUqE= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kataras/blocks v0.0.7/go.mod h1:UJIU97CluDo0f+zEjbnbkeMRlvYORtmc1304EeyXf4I= +github.com/kataras/golog v0.1.9/go.mod h1:jlpk/bOaYCyqDqH18pgDHdaJab72yBE6i0O3s30hpWY= +github.com/kataras/iris/v12 v12.2.6-0.20230908161203-24ba4e8933b9/go.mod h1:ldkoR3iXABBeqlTibQ3MYaviA1oSlPvim6f55biwBh4= +github.com/kataras/pio v0.0.12/go.mod h1:ODK/8XBhhQ5WqrAhKy+9lTPS7sBf6O3KcLhc9klfRcY= +github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= +github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= +github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/tdewolff/minify/v2 v2.12.9/go.mod h1:qOqdlDfL+7v0/fyymB+OP497nIxJYSvX4MQWA8OoiXU= +github.com/tdewolff/parse/v2 v2.6.8/go.mod h1:XHDhaU6IBgsryfdnpzUXBlT6leW/l25yrFBTEb4eIyM= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/uptrace/bunrouter v1.0.23/go.mod h1:O3jAcl+5qgnF+ejhgkmbceEk0E/mqaK+ADOocdNpY8M= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +k8s.io/apiserver v0.36.0/go.mod h1:mHvwdHf+qKEm+1/hYm756SV+oREOKSPnsjagOpx6Vho= +k8s.io/component-base v0.36.0/go.mod h1:JZvIfcNHk+uck+8LhJzhSBtydWXaZNQwX2OdL+Mnwsk= +k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= +k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= diff --git a/services/aep-api/.env.example b/services/aep-api/.env.example index 673cfe0b..7cd724e5 100644 --- a/services/aep-api/.env.example +++ b/services/aep-api/.env.example @@ -13,6 +13,12 @@ DATABASE_URL=postgres://aep:aep@localhost:5432/aep?sslmode=disable # Test mode — enables test-only endpoints TEST_MODE=false +# Gates POST /internal/v1/mcp/playground-token — mints a short-lived MCP +# token with NO caller auth, for a human driving services/agents' playground +# CLI locally. Off by default (route absent, 404); only +# deployments/docker-compose.yml sets this true. +PLAYGROUND_TOKEN_ENABLED=false + # Git host provider (clients/ behind gitrepo's ports). Only "github" # is supported today; boot fails on any other value. GIT_PROVIDER=github diff --git a/services/aep-api/internal/api/playground_token_surface_test.go b/services/aep-api/internal/api/playground_token_surface_test.go new file mode 100644 index 00000000..97fe4e0e --- /dev/null +++ b/services/aep-api/internal/api/playground_token_surface_test.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/wso2/aep/aep-api/internal/config" + "github.com/wso2/aep/aep-api/internal/platform/auth" +) + +// Component test for the playground-token mint route (POST +// /internal/v1/mcp/playground-token): the real mounted mux (NewHandler → +// mountSurfaces) over a real TaskTokenManager. Proves the route is entirely +// ABSENT (404 by omission) unless Config.PlaygroundTokenEnabled is set; when +// enabled, the minted token actually verifies via the real +// AgentsScopedVerifier — the same middleware guarding /internal/v1/mcp — with +// audience aep-api-mcp and the right org, both the "default" default and an +// explicit orgHandle; and the MCP JSON-RPC mount itself behaves identically +// regardless of the flag. + +func newPlaygroundTokenTestServer(t *testing.T, enabled bool) (*httptest.Server, *auth.TaskTokenManager) { + t.Helper() + priv := mustGenerateRSAKey(t) + mgr, err := auth.NewTaskTokenManager(auth.TaskTokenConfig{ + PrivateKey: string(encodePKCS1(t, priv)), + Issuer: "aep-bff", + Audience: "git-service", + TTL: time.Hour, + }) + if err != nil { + t.Fatalf("NewTaskTokenManager: %v", err) + } + handler := NewHandler(AppParams{ + Config: config.Config{PlaygroundTokenEnabled: enabled}, + HumaDeps: HumaDeps{TaskTokens: mgr}, + }) + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return srv, mgr +} + +func postPlaygroundToken(t *testing.T, srv *httptest.Server, body string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, srv.URL+"/internal/v1/mcp/playground-token", bytes.NewReader([]byte(body))) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST playground-token: %v", err) + } + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +// assertVerifiesAsMCPToken drives token through the REAL AgentsScopedVerifier +// (the same middleware guarding /internal/v1/mcp) and asserts it resolves to +// wantOrg — proving the minted token is a genuine aud-aep-api-mcp identity +// JWT scoped to the right org, not just any signed blob. +func assertVerifiesAsMCPToken(t *testing.T, mgr *auth.TaskTokenManager, token, wantOrg string) { + t.Helper() + verifier := auth.NewAgentsScopedVerifier(mgr) + var gotOrg string + var ok bool + h := verifier.Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + gotOrg, ok = auth.MCPOrgFromContext(r.Context()) + })) + req := httptest.NewRequest(http.MethodPost, "/internal/v1/mcp", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if !ok { + t.Fatalf("token did not verify via AgentsScopedVerifier (status %d)", w.Code) + } + if gotOrg != wantOrg { + t.Fatalf("verified org = %q, want %q", gotOrg, wantOrg) + } +} + +// TestPlaygroundToken_DisabledByDefault404 proves the route is absent (not +// merely unauthorized) when the flag is off — the zero-value posture every +// non-compose environment gets. +func TestPlaygroundToken_DisabledByDefault404(t *testing.T) { + srv, _ := newPlaygroundTokenTestServer(t, false) + resp := postPlaygroundToken(t, srv, `{}`) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (route must not be mounted when disabled)", resp.StatusCode) + } +} + +// TestPlaygroundToken_Enabled_DefaultOrg proves the happy path with an empty +// body: 200, a non-empty token that verifies for org "default", and the +// documented 5-minute (300s) TTL in the response. +func TestPlaygroundToken_Enabled_DefaultOrg(t *testing.T) { + srv, mgr := newPlaygroundTokenTestServer(t, true) + resp := postPlaygroundToken(t, srv, `{}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Token string `json:"token"` + ExpiresInSeconds int `json:"expiresInSeconds"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Token == "" { + t.Fatalf("token is empty") + } + if body.ExpiresInSeconds != 300 { + t.Errorf("expiresInSeconds = %d, want 300", body.ExpiresInSeconds) + } + assertVerifiesAsMCPToken(t, mgr, body.Token, "default") +} + +// TestPlaygroundToken_Enabled_ExplicitOrgHandle proves an explicit orgHandle +// in the body is what the minted token gets scoped to. +func TestPlaygroundToken_Enabled_ExplicitOrgHandle(t *testing.T) { + srv, mgr := newPlaygroundTokenTestServer(t, true) + resp := postPlaygroundToken(t, srv, `{"orgHandle":"acme"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + assertVerifiesAsMCPToken(t, mgr, body.Token, "acme") +} + +// TestPlaygroundToken_MCPMountUnaffectedByFlag proves the JSON-RPC mount's own +// auth posture (401 with no bearer) is identical whether the playground-token +// flag is on or off — the new route is additive, not a side effect. +func TestPlaygroundToken_MCPMountUnaffectedByFlag(t *testing.T) { + for _, enabled := range []bool{true, false} { + srv, _ := newPlaygroundTokenTestServer(t, enabled) + resp, err := http.Post(srv.URL+"/internal/v1/mcp", "application/json", + bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`))) + if err != nil { + t.Fatalf("POST mcp: %v", err) + } + resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("enabled=%v: mcp mount status = %d, want 401 (no bearer) regardless of the playground-token flag", enabled, resp.StatusCode) + } + } +} diff --git a/services/aep-api/internal/api/surfaces.go b/services/aep-api/internal/api/surfaces.go index 8e48560c..bb11288e 100644 --- a/services/aep-api/internal/api/surfaces.go +++ b/services/aep-api/internal/api/surfaces.go @@ -39,6 +39,9 @@ import ( // (per-op resolver) (dual-token verify + INT-6 fence) → api/internal-openapi.yaml (non-public) // internal MCP /internal/v1/mcp BFF-signed JWT, aud aep-api-mcp dependencies/mcp_server.go · // (POST, JSON-RPC) (org from ocOrgId claim, never input) auth.AgentsScopedVerifier (no spec — JSON-RPC) +// /mcp/playground-token NONE — flag-gated only dependencies/playground_token.go +// (POST, local dev) (PLAYGROUND_TOKEN_ENABLED, off by (mounted only when the flag is true — +// default; docker-compose sets it) 404 by absence otherwise) // external /api/v1/webhooks, per-route bespoke: GitHub HMAC / webhook_routes.go · org_github_routes.go // .../github/connect signed connect-state (org from payload) (no generated spec; paths kept — Q4) // dev/test /_dev/v1 none — registration-gated to dev tier dev.go · RegisterAllDev @@ -137,6 +140,19 @@ func mountSurfaces(params AppParams) *http.ServeMux { mcpHandler := dependencies.NewMCPHandler( params.MCPExternalResources, params.MCPOrgEndpoints, params.MCPResourceTypes) mux.Handle("POST "+internalV1+"/mcp", mcpVerifier.Middleware(mcpHandler)) + + // ── playground-token mint (POST /internal/v1/mcp/playground-token) ──── + // LOCAL DEV ONLY, and only when explicitly opted in via + // PlaygroundTokenEnabled (docker-compose sets it; nothing else does). + // Lets a developer drive the services/agents playground CLI against a + // live aep-api without a caller-auth story for this route — production + // agent→BFF authentication remains an open decision this endpoint + // deliberately does not prejudge. Disabled ⇒ not mounted at all (404 by + // absence, matching the MCP mount's own conditional-mount posture). + if params.Config.PlaygroundTokenEnabled { + mux.Handle("POST "+internalV1+"/mcp/playground-token", + dependencies.NewPlaygroundTokenHandler(params.HumaDeps.TaskTokens)) + } } // ── /api/ user-JWT wrapper ─────────────────────────────────────────────── diff --git a/services/aep-api/internal/config/config.go b/services/aep-api/internal/config/config.go index 60565d73..eb5ca249 100644 --- a/services/aep-api/internal/config/config.go +++ b/services/aep-api/internal/config/config.go @@ -49,6 +49,15 @@ type Config struct { // The destructive BFF migrations refuse to run unless tier=dev. DeploymentTier string + // PlaygroundTokenEnabled gates POST /internal/v1/mcp/playground-token — a + // caller-auth-free endpoint that mints a short-lived MCP token so a human + // can drive the services/agents playground CLI against a live aep-api + // without a caller-auth story (an open decision this endpoint deliberately + // does not prejudge). Defaults false, so the route is ABSENT (404, not + // 403) everywhere except deployments/docker-compose.yml, which opts in for + // local dev. Read from PLAYGROUND_TOKEN_ENABLED. + PlaygroundTokenEnabled bool + // TenantGateMode controls the central per-route tenant gate (§6.1b). // ENFORCE BY DEFAULT (zero-config): "enforce" 404s a path-vs-JWT org // mismatch (closes IDOR-1..5). Set TENANT_GATE_MODE=log to downgrade to diff --git a/services/aep-api/internal/config/config_loader.go b/services/aep-api/internal/config/config_loader.go index 35f12fec..b14c4d4e 100644 --- a/services/aep-api/internal/config/config_loader.go +++ b/services/aep-api/internal/config/config_loader.go @@ -62,6 +62,7 @@ func Load() (Config, error) { TestMode: r.readOptionalBool("TEST_MODE", false), LocalOpenBaoRepairEnabled: r.readOptionalBool("LOCAL_OPENBAO_REPAIR", false), DeploymentTier: r.readOptionalString("DEPLOYMENT_TIER", "dev"), + PlaygroundTokenEnabled: r.readOptionalBool("PLAYGROUND_TOKEN_ENABLED", false), TenantGateMode: r.readOptionalString("TENANT_GATE_MODE", "enforce"), OAuthStateSigningKey: r.readOptionalString("OAUTH_STATE_SIGNING_KEY", ""), BFFPublicURL: r.readOptionalString("BFF_PUBLIC_URL", "http://localhost:8090"), diff --git a/services/aep-api/internal/feature/dependencies/playground_token.go b/services/aep-api/internal/feature/dependencies/playground_token.go new file mode 100644 index 00000000..704600f5 --- /dev/null +++ b/services/aep-api/internal/feature/dependencies/playground_token.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package dependencies + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/wso2/aep/aep-api/internal/platform/auth" +) + +// playgroundTokenTTLSeconds mirrors TaskTokenManager's 5-minute MCP token TTL. +// The manager does not expose that TTL as a named constant, so this is the +// "known 5-min TTL" restated here for the response body; if the manager's TTL +// ever changes, this must change with it. +const playgroundTokenTTLSeconds = 300 + +// playgroundTokenRequest is the optional JSON body for +// POST /internal/v1/mcp/playground-token. An absent/empty body is valid — the +// org defaults to "default". +type playgroundTokenRequest struct { + OrgHandle string `json:"orgHandle"` +} + +// playgroundTokenResponse mirrors what services/agents/playground mints +// against: a bearer token plus its lifetime, so the caller knows when it must +// re-mint (it always does, every turn — see playground.ts). +type playgroundTokenResponse struct { + Token string `json:"token"` + ExpiresInSeconds int `json:"expiresInSeconds"` +} + +// NewPlaygroundTokenHandler mints a short-lived BFF-signed MCP token (aud +// auth.AudienceMCP, scoped to one org) for a human driving the +// services/agents playground CLI locally against a real aep-api. +// +// LOCAL DEV ONLY. The route this handler backs is mounted ONLY when +// PLAYGROUND_TOKEN_ENABLED=true (surfaces.go), a flag only +// deployments/docker-compose.yml sets — everywhere else the route is simply +// ABSENT (404 by omission, never present-but-403). There is deliberately NO +// caller authentication here: the flag itself, off by default, is the whole +// gate. Production agent→BFF authentication remains an OPEN DECISION this +// endpoint does not prejudge — it exists solely so a developer can obtain a +// working token for manual playground runs without that decision being +// settled first. A token is minted FRESH on every call; the playground CLI +// calls this once per turn and never caches the result. +func NewPlaygroundTokenHandler(tokens *auth.TaskTokenManager) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req playgroundTokenRequest + r.Body = http.MaxBytesReader(w, r.Body, 4<<10) // tiny contract; cap the buffer + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + orgHandle := req.OrgHandle + if orgHandle == "" { + orgHandle = "default" + } + + token, err := tokens.IssueMCPToken(orgHandle) + if err != nil { + http.Error(w, "failed to mint playground token", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(playgroundTokenResponse{ + Token: token, + ExpiresInSeconds: playgroundTokenTTLSeconds, + }) + }) +} diff --git a/services/agents/.env.example b/services/agents/.env.example index 50ea84cd..39ec73af 100644 --- a/services/agents/.env.example +++ b/services/agents/.env.example @@ -13,11 +13,20 @@ LOG_LEVEL=info AGENT_BODY_LIMIT=10mb # MCP dependency-discovery (Task E2, playground only). The SERVICE never reads -# these — a caller (aep-api in production; this pair in the playground) pushes -# an `mcp: { url, token }` bundle in the turn request. Point these at aep-api's -# `/internal/v1/mcp` and a matching org-bound token to let the playground agent -# call list_external_resources / get_external_resource_schema / -# list_org_endpoints / list_platform_resource_types. Leave both unset to run -# without discovery tools (byte-identical to today). +# these — a caller (aep-api in production; the playground here) pushes an +# `mcp: { url, token }` bundle in the turn request. Point AEP_MCP_URL at +# aep-api's `/internal/v1/mcp` (e.g. http://localhost:9090/internal/v1/mcp — +# port 9090 is host-published by deployments/docker-compose.yml) to let the +# playground agent call list_external_resources / get_external_resource_schema +# / list_org_endpoints / list_platform_resource_types. Leave AEP_MCP_URL unset +# to run without discovery tools (byte-identical to today). +# +# With AEP_MCP_URL set and AEP_MCP_TOKEN left empty, the playground auto-mints +# a fresh token every turn via that aep-api's `POST {AEP_MCP_URL}/playground- +# token` (Task: playground-token) — requires PLAYGROUND_TOKEN_ENABLED=true on +# that aep-api (the repo's docker-compose.yml already sets it; local dev only). +# Set AEP_MCP_TOKEN to use a token verbatim instead (skips auto-minting). AEP_MCP_URL= AEP_MCP_TOKEN= +# Org the auto-minted token is scoped to. Optional — defaults to "default". +AEP_MCP_ORG= diff --git a/services/agents/AGENTS.md b/services/agents/AGENTS.md index 6fb51ff8..bd9be5cf 100644 --- a/services/agents/AGENTS.md +++ b/services/agents/AGENTS.md @@ -26,6 +26,12 @@ ADR-0002. - `pnpm --filter @aep/agents dev` — SSE server, watch/reload. `start` — run once. - Endpoints: `POST /conversations/:id/turns` (SSE) · `GET /conversations/:id`. - Needs `ANTHROPIC_API_KEY` (export, or `deployments/.env` — see `.env.example`). +- **Playground MCP discovery against a local aep-api**: on by default — `AEP_MCP_URL` defaults to + `http://localhost:9090/internal/v1/mcp` (port 9090 is host-published by `deployments/docker-compose.yml`); + set `AEP_MCP_URL=""` to disable discovery. The playground auto-mints a fresh token + per turn via that aep-api's `playground-token` endpoint — this requires `PLAYGROUND_TOKEN_ENABLED=true`, + which the repo's compose file already sets. `AEP_MCP_ORG` optionally selects the org (defaults `"default"`); + setting `AEP_MCP_TOKEN` overrides auto-minting and is used verbatim. ## Test diff --git a/services/agents/playground/mcp.test.ts b/services/agents/playground/mcp.test.ts new file mode 100644 index 00000000..c2b7ce1a --- /dev/null +++ b/services/agents/playground/mcp.test.ts @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createMcpResolver, readMcpEnv, DEFAULT_MCP_URL } from "./mcp.js"; +import { listen0 } from "../src/shared/listen.js"; + +type Handler = (req: IncomingMessage, res: ServerResponse, body: unknown) => void; + +/** A tiny http server driven by a per-test handler — mirrors src/shared/mcp-client.test.ts. */ +async function fakeServer(handle: Handler) { + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (c: Buffer) => (raw += c)); + req.on("end", () => { + let body: unknown; + try { + body = raw ? JSON.parse(raw) : {}; + } catch { + body = undefined; + } + handle(req, res, body); + }); + }); + return listen0(server.listen(0)); +} + +function jsonOk(res: ServerResponse, payload: unknown): void { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +test("neither AEP_MCP_URL nor AEP_MCP_TOKEN set → resolves undefined, no fetch attempted", async () => { + const resolver = createMcpResolver({}, () => assert.fail("must not warn")); + assert.equal(await resolver.resolve(), undefined); +}); + +test("readMcpEnv: AEP_MCP_URL unset → defaults to the local stack; empty string disables", () => { + assert.equal(readMcpEnv({}).url, DEFAULT_MCP_URL); + assert.equal(readMcpEnv({ AEP_MCP_URL: "" }).url, ""); + assert.equal(readMcpEnv({ AEP_MCP_URL: "http://other:1/mcp" }).url, "http://other:1/mcp"); +}); + +test("AEP_MCP_TOKEN set wins verbatim — never mints, even when a mint endpoint is reachable", async () => { + const hits: unknown[] = []; + const { baseUrl, close } = await fakeServer((_req, res, body) => { + hits.push(body); + jsonOk(res, { token: "should-not-be-used" }); + }); + try { + const resolver = createMcpResolver({ url: baseUrl, token: "operator-token" }, () => assert.fail("must not warn")); + const mcp = await resolver.resolve(); + assert.deepEqual(mcp, { url: baseUrl, token: "operator-token" }); + assert.equal(hits.length, 0, "token-env must win without ever calling the mint endpoint"); + } finally { + await close(); + } +}); + +test("AEP_MCP_TOKEN unset, AEP_MCP_URL set → auto-mints against {url}/playground-token with the org body", async () => { + const seen: { url?: string | undefined; body?: unknown } = {}; + const { baseUrl, close } = await fakeServer((req, res, body) => { + seen.url = req.url; + seen.body = body; + jsonOk(res, { token: "minted-abc", expiresInSeconds: 300 }); + }); + try { + const resolver = createMcpResolver({ url: baseUrl, org: "acme" }, () => assert.fail("must not warn")); + const mcp = await resolver.resolve(); + assert.deepEqual(mcp, { url: baseUrl, token: "minted-abc" }); + assert.equal(seen.url, "/playground-token", "must POST to {AEP_MCP_URL}/playground-token"); + assert.deepEqual(seen.body, { orgHandle: "acme" }); + } finally { + await close(); + } +}); + +test("org defaults to \"default\" when AEP_MCP_ORG is unset", async () => { + const seen: { body?: unknown } = {}; + const { baseUrl, close } = await fakeServer((_req, res, body) => { + seen.body = body; + jsonOk(res, { token: "minted-xyz" }); + }); + try { + const resolver = createMcpResolver({ url: baseUrl }, () => assert.fail("must not warn")); + await resolver.resolve(); + assert.deepEqual(seen.body, { orgHandle: "default" }); + } finally { + await close(); + } +}); + +test("mints fresh on every call — two resolve() calls hit the mint endpoint twice, no caching", async () => { + let count = 0; + const { baseUrl, close } = await fakeServer((_req, res) => { + count += 1; + jsonOk(res, { token: `minted-${count}` }); + }); + try { + const resolver = createMcpResolver({ url: baseUrl }, () => assert.fail("must not warn")); + const first = await resolver.resolve(); + const second = await resolver.resolve(); + assert.equal(count, 2, "each resolve() call must re-mint, never reuse a cached token"); + assert.equal(first?.token, "minted-1"); + assert.equal(second?.token, "minted-2"); + } finally { + await close(); + } +}); + +test("mint failure (non-2xx) degrades to undefined and warns exactly once across repeated calls", async () => { + const { baseUrl, close } = await fakeServer((_req, res) => { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "boom" })); + }); + const warnings: string[] = []; + try { + const resolver = createMcpResolver({ url: baseUrl }, (msg) => warnings.push(msg)); + const first = await resolver.resolve(); + const second = await resolver.resolve(); + assert.equal(first, undefined); + assert.equal(second, undefined); + assert.equal(warnings.length, 1, `expected exactly one warning, got ${warnings.length}: ${warnings.join(" | ")}`); + } finally { + await close(); + } +}); + +test("mint failure (network error — server unreachable) degrades to undefined and warns once", async () => { + // Port 1 is never listening — fetch rejects (ECONNREFUSED-equivalent). + const warnings: string[] = []; + const resolver = createMcpResolver({ url: "http://127.0.0.1:1" }, (msg) => warnings.push(msg)); + const mcp = await resolver.resolve(); + assert.equal(mcp, undefined); + assert.equal(warnings.length, 1); +}); diff --git a/services/agents/playground/mcp.ts b/services/agents/playground/mcp.ts new file mode 100644 index 00000000..a860bb57 --- /dev/null +++ b/services/agents/playground/mcp.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Playground MCP token resolution (Task: playground-token). Bridges the + * AEP_MCP_URL / AEP_MCP_TOKEN / AEP_MCP_ORG env vars into a `TurnRequest.mcp` + * bundle: + * + * - AEP_MCP_TOKEN set → used verbatim, forever. An operator-supplied token is + * trusted as-is; the resolver never mints when one is present. + * - AEP_MCP_TOKEN unset, AEP_MCP_URL set → mint a FRESH token on every + * `resolve()` call against aep-api's `POST {AEP_MCP_URL}/playground-token` + * (requires `PLAYGROUND_TOKEN_ENABLED=true` on that aep-api — local dev + * only; see deployments/docker-compose.yml). No caching: the minted + * token's ~5-minute TTL is shorter than a chat session, so every turn + * re-mints rather than risking a mid-turn 401. + * - Mint failure (network error or non-2xx) degrades to no MCP tools for + * that call and prints exactly ONE warning for the resolver's lifetime — + * a dead/misconfigured aep-api shouldn't spam the TUI on every turn. + * - Neither var set → undefined, unconditionally (today's behavior). + */ + +import type { TurnRequest } from "../src/contracts/sse-events.js"; + +export interface McpEnv { + url?: string | undefined; + token?: string | undefined; + org?: string | undefined; +} + +export interface McpResolver { + /** Resolves the mcp bundle for one turn. Never throws. */ + resolve(): Promise; +} + +/** + * Reads the AEP_MCP_* vars — the only inputs this module reads from the + * environment. AEP_MCP_URL defaults to the local compose stack's aep-api + * (the playground is a local-dev tool); set it to an empty string to run + * without MCP discovery entirely. + */ +export const DEFAULT_MCP_URL = "http://localhost:9090/internal/v1/mcp"; + +export function readMcpEnv(env: NodeJS.ProcessEnv = process.env): McpEnv { + return { + url: env.AEP_MCP_URL ?? DEFAULT_MCP_URL, + token: env.AEP_MCP_TOKEN, + org: env.AEP_MCP_ORG, + }; +} + +/** + * Builds a resolver over a fixed `McpEnv` snapshot. `warn` is invoked at most + * once — the first time a mint attempt fails — so a single resolver (one per + * playground session, shared across every thread/turn) never nags twice. + */ +export function createMcpResolver(env: McpEnv, warn: (message: string) => void): McpResolver { + let warned = false; + return { + async resolve() { + if (!env.url) return undefined; + if (env.token) return { url: env.url, token: env.token }; + try { + const res = await fetch(`${env.url}/playground-token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orgHandle: env.org ?? "default" }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { token?: string }; + if (!body.token) throw new Error("response carried no token"); + return { url: env.url, token: body.token }; + } catch (e) { + if (!warned) { + warned = true; + const reason = e instanceof Error ? e.message : String(e); + warn(`mcp: failed to mint a playground token from ${env.url} (${reason}) — continuing without MCP tools`); + } + return undefined; + } + }, + }; +} diff --git a/services/agents/playground/playground.ts b/services/agents/playground/playground.ts index c304d4d8..4e8d2464 100644 --- a/services/agents/playground/playground.ts +++ b/services/agents/playground/playground.ts @@ -51,6 +51,7 @@ import { ensureThread, isValidThreadName, listThreads, readSnapshot, reconcile, import { renderPart, renderSummary } from "./render.js"; import { materializeDerived } from "./derived.js"; import { runTasksCommand } from "./tasks.js"; +import { createMcpResolver, readMcpEnv, type McpResolver } from "./mcp.js"; // Repo-root `skills/` (services/agents/playground → up 3). The whole library is // pushed every turn (ADR-0002); the service still reads none. @@ -63,8 +64,14 @@ interface ChatCtx { dryRun: boolean; /** The in-process model — the /tasks command drives the task-planner planner directly. */ model: LanguageModel; - /** Caller-supplied MCP discovery endpoint (Task E2), from AEP_MCP_URL/AEP_MCP_TOKEN. */ - mcp?: TurnRequest["mcp"]; + /** + * Resolves the MCP discovery bundle (Task E2 / Task: playground-token) — + * one instance per playground session, shared across every thread and the + * `/tasks` command path, so its "warn once" state and env snapshot are + * consistent no matter which command reaches for it first. `resolve()` + * mints fresh every call when auto-minting; see mcp.ts. + */ + mcpResolver: McpResolver; } const NEW = "\0new"; @@ -98,11 +105,14 @@ async function pickThread(): Promise { /** Run one turn end to end: snapshot → stream → reconstruct → write. */ async function runTurn(ctx: ChatCtx, instruction: string): Promise { const before = readSnapshot(ctx.thread); + // Resolved fresh for THIS turn — never cached across turns (a mint's ~5min + // TTL is shorter than a chat session; see mcp.ts). + const mcp = await ctx.mcpResolver.resolve(); const body: TurnRequest = { instruction, files: before, ...(ctx.skills.length > 0 ? { skills: ctx.skills } : {}), - ...(ctx.mcp ? { mcp: ctx.mcp } : {}), + ...(mcp ? { mcp } : {}), }; const toolCalls: StreamPart[] = []; @@ -202,16 +212,21 @@ async function main(): Promise { const app = createApp({ store, model }); const { baseUrl, close } = await listen0(app.listen(0)); - // MCP discovery (Task E2): both env vars set → push { url, token } on every - // turn (mirrors how the caller resolves skills). Either unset → no `mcp` - // field at all, same as a checkout with no dependency-discovery server. - const mcpUrl = process.env.AEP_MCP_URL; - const mcpToken = process.env.AEP_MCP_TOKEN; - const mcp = mcpUrl && mcpToken ? { url: mcpUrl, token: mcpToken } : undefined; + // MCP discovery (Task E2 / Task: playground-token): one resolver for the + // whole session (every thread + turn shares its "warn once" state). Built + // from env, never eagerly minted here — resolve() runs fresh per turn (see + // mcp.ts and runTurn above), so a session that never sends a turn never + // hits the network for it. + const mcpEnv = readMcpEnv(); + const mcpResolver = createMcpResolver(mcpEnv, (msg) => clack.log.warn(msg)); clack.intro("AEP spec-agent playground"); if (skills.length > 0) clack.log.info(`skills: ${skills.map((s) => s.name).join(", ")}`); - if (mcp) clack.log.info(`mcp discovery: ${mcp.url}`); + if (mcpEnv.url) { + clack.log.info( + `mcp discovery: ${mcpEnv.url} (${mcpEnv.token ? "static token" : "auto-mint per turn"})`, + ); + } if (dryRun) clack.log.warn("dry-run: changes are shown but NOT written to disk"); try { @@ -228,7 +243,7 @@ async function main(): Promise { if (!picked) break; thread = picked; } - const action = await chatLoop({ thread, baseUrl, skills, dryRun, model, ...(mcp ? { mcp } : {}) }); + const action = await chatLoop({ thread, baseUrl, skills, dryRun, model, mcpResolver }); if (action === "quit") break; thread = undefined; // /threads → back to the picker } diff --git a/services/agents/playground/render.ts b/services/agents/playground/render.ts index 0fe21f77..107985b8 100644 --- a/services/agents/playground/render.ts +++ b/services/agents/playground/render.ts @@ -58,8 +58,14 @@ export function renderPart(part: StreamPart): void { stdout.write(`\n${dim("→")} ${part.toolName ?? "?"} ${dim(inputLabel(part.input))}\n`); break; case "tool-result": { - const r = part.output as ResultShape | undefined; - if (r?.ok) stdout.write(` ${green("✓")} ${r.op ?? "loaded"} ${dim(r.status ?? "")}\n`); + const r = part.output as ResultShape | string | undefined; + // MCP discovery tools return bare strings, not the file tools' + // {ok, op, status} shape — render them as the successes they are + // (a thrown MCP failure arrives as a tool-error part, not here). + if (typeof r === "string") { + const preview = r.replace(/\s+/g, " ").trim(); + stdout.write(` ${green("✓")} ${dim(preview.length > 80 ? `${preview.slice(0, 80)}…` : preview)}\n`); + } else if (r?.ok) stdout.write(` ${green("✓")} ${r.op ?? "loaded"} ${dim(r.status ?? "")}\n`); else if (r) stdout.write(` ${red("✗")} ${r.code ?? "error"}${r.message ? `: ${r.message}` : ""}\n`); break; }