From 3a1187ad5a190713b9216cf6d9d52d54cdb3e4da Mon Sep 17 00:00:00 2001 From: Umesh Kaul Date: Fri, 18 Jul 2025 10:14:54 -0400 Subject: [PATCH] feat: add streamable-http support for MCP server (#1546) * use mcp library to support streamable http and fix resourceResponse Signed-off-by: Umesh Kaul * added name and version for mcp server Signed-off-by: Umesh Kaul * added tests Signed-off-by: Umesh Kaul * chore: fixed linter Signed-off-by: AlexsJones * fixed linter issues in server_test.go Signed-off-by: Umesh Kaul --------- Signed-off-by: Umesh Kaul Signed-off-by: AlexsJones Co-authored-by: AlexsJones --- go.mod | 16 ++++ go.sum | 33 +++++++ pkg/server/mcp.go | 115 ++++++++---------------- pkg/server/server_test.go | 178 +++++++++++++++++++++++++++++++++++++- 4 files changed, 258 insertions(+), 84 deletions(-) diff --git a/go.mod b/go.mod index a32e8ab..71d61cf 100644 --- a/go.mod +++ b/go.mod @@ -97,7 +97,11 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect github.com/containerd/console v1.0.4 // indirect github.com/containerd/continuity v0.4.3 // indirect @@ -113,6 +117,13 @@ require ( github.com/expr-lang/expr v1.17.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.22.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -126,7 +137,9 @@ require ( github.com/invopop/jsonschema v0.12.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/moby/sys/mountinfo v0.7.1 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect @@ -145,6 +158,8 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -156,6 +171,7 @@ require ( go.opentelemetry.io/otel/metric v1.34.0 // indirect go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect + golang.org/x/arch v0.8.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect diff --git a/go.sum b/go.sum index 2748fad..fd848b8 100644 --- a/go.sum +++ b/go.sum @@ -789,6 +789,10 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembj github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= @@ -806,6 +810,10 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -918,7 +926,13 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= @@ -951,6 +965,12 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= +github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-resty/resty/v2 v2.16.3 h1:zacNT7lt4b8M/io2Ahj6yPypL7bqx9n1iprfQuodV+E= github.com/go-resty/resty/v2 v2.16.3/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= @@ -963,6 +983,8 @@ github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= @@ -1208,6 +1230,7 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -1230,6 +1253,8 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= @@ -1473,6 +1498,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs= github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= @@ -1545,6 +1574,9 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -2294,6 +2326,7 @@ modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/pkg/server/mcp.go b/pkg/server/mcp.go index 141546e..3256b49 100644 --- a/pkg/server/mcp.go +++ b/pkg/server/mcp.go @@ -17,7 +17,6 @@ import ( "context" "encoding/json" "fmt" - "net/http" schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1" "github.com/k8sgpt-ai/k8sgpt/pkg/ai" @@ -25,6 +24,7 @@ import ( "github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes" "github.com/k8sgpt-ai/k8sgpt/pkg/server/config" mcp_golang "github.com/metoro-io/mcp-golang" + mcp_http "github.com/metoro-io/mcp-golang/transport/http" "github.com/metoro-io/mcp-golang/transport/stdio" "github.com/spf13/viper" "go.uber.org/zap" @@ -41,10 +41,19 @@ type MCPServer struct { // NewMCPServer creates a new MCP server func NewMCPServer(port string, aiProvider *ai.AIProvider, useHTTP bool, logger *zap.Logger) (*MCPServer, error) { - // Create MCP server with stdio transport - transport := stdio.NewStdioServerTransport() + opts := []mcp_golang.ServerOptions{ + mcp_golang.WithName("k8sgpt"), + mcp_golang.WithVersion("1.0.0"), + } - server := mcp_golang.NewServer(transport) + var server *mcp_golang.Server + if useHTTP { + logger.Info("starting MCP server with http transport on port", zap.String("port", port)) + httpTransport := mcp_http.NewHTTPTransport("/mcp").WithAddr(":" + port) + server = mcp_golang.NewServer(httpTransport, opts...) + } else { + server = mcp_golang.NewServer(stdio.NewStdioServerTransport(), opts...) + } return &MCPServer{ server: server, @@ -86,20 +95,11 @@ func (s *MCPServer) Start() error { return fmt.Errorf("failed to register prompts: %v", err) } - if s.useHTTP { - // Start HTTP server - go func() { - http.HandleFunc("/mcp/analyze", s.handleAnalyzeHTTP) - http.HandleFunc("/mcp", s.handleSSE) - s.logger.Info("Starting MCP server on port", zap.String("port", s.port)) - if err := http.ListenAndServe(fmt.Sprintf(":%s", s.port), nil); err != nil { - s.logger.Error("Error starting HTTP server", zap.Error(err)) - } - }() + // Start the server (this will block) + if err := s.server.Serve(); err != nil { + s.logger.Error("Error starting MCP server", zap.Error(err)) } - - // Start the server - return s.server.Serve() + return nil } // AnalyzeRequest represents the input parameters for the analyze tool @@ -327,7 +327,7 @@ func (s *MCPServer) registerResources() error { return nil } -func (s *MCPServer) getClusterInfo(ctx context.Context) (interface{}, error) { +func (s *MCPServer) getClusterInfo(ctx context.Context) (*mcp_golang.ResourceResponse, error) { // Create a new Kubernetes client client, err := kubernetes.NewClient("", "") if err != nil { @@ -340,74 +340,27 @@ func (s *MCPServer) getClusterInfo(ctx context.Context) (interface{}, error) { return nil, fmt.Errorf("failed to get cluster version: %v", err) } - return map[string]string{ + data, err := json.Marshal(map[string]string{ "version": version.String(), "platform": version.Platform, "gitVersion": version.GitVersion, - }, nil -} - -// handleSSE handles Server-Sent Events for MCP -func (s *MCPServer) handleSSE(w http.ResponseWriter, r *http.Request) { - // Set headers for SSE - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Create a channel to receive messages - msgChan := make(chan string) - defer close(msgChan) - - // Start a goroutine to handle the stdio transport - go func() { - // TODO: Implement message handling between HTTP and stdio transport - // This would require implementing a custom transport that bridges HTTP and stdio - - }() - - // Send messages to the client - for msg := range msgChan { - if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil { - s.logger.Error("Failed to write SSE message", zap.Error(err)) - return - } - w.(http.Flusher).Flush() - } -} - -// handleAnalyzeHTTP handles HTTP requests for the analyze endpoint -func (s *MCPServer) handleAnalyzeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Parse the request body - var req AnalyzeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("Failed to decode request: %v", err), http.StatusBadRequest) - return - } - - // Validate MaxConcurrency to prevent excessive memory allocation - req.MaxConcurrency = validateMaxConcurrency(req.MaxConcurrency) - - // Call the analyze handler - resp, err := s.handleAnalyze(r.Context(), &req) + }) if err != nil { - http.Error(w, fmt.Sprintf("Failed to analyze: %v", err), http.StatusInternalServerError) - return - } - - // Set response headers - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - - // Write the response - if err := json.NewEncoder(w).Encode(resp); err != nil { - s.logger.Error("Failed to encode response", zap.Error(err)) + return mcp_golang.NewResourceResponse( + mcp_golang.NewTextEmbeddedResource( + "cluster-info", + "Failed to marshal cluster info", + "text/plain", + ), + ), nil } + return mcp_golang.NewResourceResponse( + mcp_golang.NewTextEmbeddedResource( + "cluster-info", + string(data), + "application/json", + ), + ), nil } // Close closes the MCP server and releases resources diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index ace155a..795e23b 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -1,11 +1,14 @@ package server import ( + "bytes" "context" "net" + "net/http" "testing" "time" + "github.com/k8sgpt-ai/k8sgpt/pkg/ai" "github.com/stretchr/testify/assert" "go.uber.org/zap" "google.golang.org/grpc" @@ -14,7 +17,12 @@ import ( func TestServe(t *testing.T) { logger, _ := zap.NewDevelopment() - defer logger.Sync() + defer func() { + err := logger.Sync() + if err != nil { + t.Logf("logger.Sync() error: %v", err) + } + }() s := &Config{ Port: "50059", @@ -34,7 +42,11 @@ func TestServe(t *testing.T) { conn, err := grpc.Dial("localhost:50059", grpc.WithInsecure()) assert.NoError(t, err, "Should be able to dial the server") - defer conn.Close() + defer func() { + if err := conn.Close(); err != nil { + t.Logf("failed to close connection: %v", err) + } + }() // Test a simple gRPC reflection request cli := grpc_reflection_v1alpha.NewServerReflectionClient(conn) @@ -49,12 +61,172 @@ func TestServe(t *testing.T) { assert.NoError(t, err, "Shutdown should not return an error") } +// TestMCPServerCreation tests the creation of an MCP server +func TestMCPServerCreation(t *testing.T) { + logger, _ := zap.NewDevelopment() + defer func() { + err := logger.Sync() + if err != nil { + t.Logf("logger.Sync() error: %v", err) + } + }() + + aiProvider := &ai.AIProvider{ + Name: "test-provider", + Password: "test-password", + Model: "test-model", + } + + // Test HTTP mode + mcpServer, err := NewMCPServer("8089", aiProvider, true, logger) + assert.NoError(t, err, "Should be able to create MCP server with HTTP transport") + assert.NotNil(t, mcpServer, "MCP server should not be nil") + assert.True(t, mcpServer.useHTTP, "MCP server should be in HTTP mode") + assert.Equal(t, "8089", mcpServer.port, "Port should be set correctly") + + // Test stdio mode + mcpServerStdio, err := NewMCPServer("8089", aiProvider, false, logger) + assert.NoError(t, err, "Should be able to create MCP server with stdio transport") + assert.NotNil(t, mcpServerStdio, "MCP server should not be nil") + assert.False(t, mcpServerStdio.useHTTP, "MCP server should be in stdio mode") +} + +// TestMCPServerBasicHTTP tests basic HTTP connectivity to the MCP server +func TestMCPServerBasicHTTP(t *testing.T) { + logger, _ := zap.NewDevelopment() + defer func() { + err := logger.Sync() + if err != nil { + t.Logf("logger.Sync() error: %v", err) + } + }() + + aiProvider := &ai.AIProvider{ + Name: "test-provider", + Password: "test-password", + Model: "test-model", + } + + mcpServer, err := NewMCPServer("8089", aiProvider, true, logger) + assert.NoError(t, err, "Should be able to create MCP server") + + // Start the MCP server in a goroutine + go func() { + err := mcpServer.Start() + // Note: Start() might return an error when the server is stopped, which is expected + if err != nil { + logger.Info("MCP server stopped", zap.Error(err)) + } + }() + + // Wait for the server to start + err = waitForPort("localhost:8089", 10*time.Second) + if err != nil { + t.Skipf("MCP server did not start within timeout: %v", err) + } + + // Test basic connectivity to the MCP endpoint + // The MCP HTTP transport uses a single POST endpoint for all requests + resp, err := http.Post("http://localhost:8089/mcp", "application/json", bytes.NewBufferString(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) + if err != nil { + t.Logf("MCP endpoint test skipped (server might not be fully ready): %v", err) + return + } + defer func() { + err := resp.Body.Close() + if err != nil { + t.Logf("resp.Body.Close() error: %v", err) + } + }() + + // Accept both 200 and 404 as valid responses (404 means endpoint not implemented) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { + t.Errorf("MCP endpoint returned unexpected status: %d", resp.StatusCode) + } + + // Cleanup + err = mcpServer.Close() + assert.NoError(t, err, "MCP server should close without error") +} + +// TestMCPServerToolCall tests calling a specific tool (analyze) through the MCP server +func TestMCPServerToolCall(t *testing.T) { + logger, _ := zap.NewDevelopment() + defer func() { + err := logger.Sync() + if err != nil { + t.Logf("logger.Sync() error: %v", err) + } + }() + + aiProvider := &ai.AIProvider{ + Name: "test-provider", + Password: "test-password", + Model: "test-model", + } + + mcpServer, err := NewMCPServer("8090", aiProvider, true, logger) + assert.NoError(t, err, "Should be able to create MCP server") + + // Start the MCP server in a goroutine + go func() { + err := mcpServer.Start() + if err != nil { + logger.Info("MCP server stopped", zap.Error(err)) + } + }() + + // Wait for the server to start + err = waitForPort("localhost:8090", 10*time.Second) + if err != nil { + t.Skipf("MCP server did not start within timeout: %v", err) + } + + // Test calling the analyze tool with proper JSON-RPC format + analyzeRequest := `{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "analyze", + "arguments": { + "namespace": "default", + "backend": "openai", + "language": "english", + "explain": true, + "maxConcurrency": 10 + } + } + }` + + resp, err := http.Post("http://localhost:8090/mcp", "application/json", bytes.NewBufferString(analyzeRequest)) + if err != nil { + t.Logf("Analyze tool call test skipped (server might not be fully ready): %v", err) + return + } + defer func() { + err := resp.Body.Close() + if err != nil { + t.Logf("resp.Body.Close() error: %v", err) + } + }() + + // Accept both 200 and 404 as valid responses + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { + t.Errorf("Analyze tool call returned unexpected status: %d", resp.StatusCode) + } + + // Cleanup + err = mcpServer.Close() + assert.NoError(t, err, "MCP server should close without error") +} + func waitForPort(address string, timeout time.Duration) error { start := time.Now() for { conn, err := net.Dial("tcp", address) if err == nil { - conn.Close() + _ = conn.Close() return nil } if time.Since(start) > timeout {