From 7c6dc4b13fbd7d3bbcf058c93cec4da852e1fbe9 Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 21:25:46 +0200 Subject: [PATCH 01/11] feat(fxmcpserver): Provided module --- .github/workflows/coverage.yml | 1 + .github/workflows/fxmcpserver-ci.yml | 32 +++ fxmcpserver/.golangci.yml | 65 ++++++ fxmcpserver/go.mod | 80 +++++++ fxmcpserver/go.sum | 255 ++++++++++++++++++++ fxmcpserver/info.go | 59 +++++ fxmcpserver/info_test.go | 95 ++++++++ fxmcpserver/module.go | 232 ++++++++++++++++++ fxmcpserver/module_test.go | 164 +++++++++++++ fxmcpserver/register.go | 94 ++++++++ fxmcpserver/register_test.go | 85 +++++++ fxmcpserver/server/context/context.go | 69 ++++++ fxmcpserver/server/context/context_test.go | 105 +++++++++ fxmcpserver/server/factory.go | 57 +++++ fxmcpserver/server/factory_test.go | 25 ++ fxmcpserver/server/healthcheck.go | 65 ++++++ fxmcpserver/server/healthcheck_test.go | 34 +++ fxmcpserver/server/provider.go | 258 +++++++++++++++++++++ fxmcpserver/server/provider_test.go | 28 +++ fxmcpserver/server/registry.go | 182 +++++++++++++++ fxmcpserver/server/registry_test.go | 70 ++++++ fxmcpserver/server/sse/context.go | 94 ++++++++ fxmcpserver/server/sse/context_test.go | 139 +++++++++++ fxmcpserver/server/sse/factory.go | 97 ++++++++ fxmcpserver/server/sse/factory_test.go | 37 +++ fxmcpserver/server/sse/server.go | 111 +++++++++ fxmcpserver/server/sse/server_test.go | 82 +++++++ fxmcpserver/server/stdio/context.go | 81 +++++++ fxmcpserver/server/stdio/context_test.go | 70 ++++++ fxmcpserver/server/stdio/factory.go | 32 +++ fxmcpserver/server/stdio/factory_test.go | 27 +++ fxmcpserver/server/stdio/server.go | 85 +++++++ fxmcpserver/server/stdio/server_test.go | 53 +++++ fxmcpserver/server/util.go | 36 +++ fxmcpserver/server/util_test.go | 49 ++++ fxmcpserver/testdata/config/config.yaml | 41 ++++ fxmcpserver/testdata/prompt/prompt.go | 45 ++++ fxmcpserver/testdata/resource/resource.go | 47 ++++ fxmcpserver/testdata/resource/template.go | 47 ++++ fxmcpserver/testdata/tool/tool.go | 37 +++ release-please-config.json | 5 + 41 files changed, 3270 insertions(+) create mode 100644 .github/workflows/fxmcpserver-ci.yml create mode 100644 fxmcpserver/.golangci.yml create mode 100644 fxmcpserver/go.mod create mode 100644 fxmcpserver/go.sum create mode 100644 fxmcpserver/info.go create mode 100644 fxmcpserver/info_test.go create mode 100644 fxmcpserver/module.go create mode 100644 fxmcpserver/module_test.go create mode 100644 fxmcpserver/register.go create mode 100644 fxmcpserver/register_test.go create mode 100644 fxmcpserver/server/context/context.go create mode 100644 fxmcpserver/server/context/context_test.go create mode 100644 fxmcpserver/server/factory.go create mode 100644 fxmcpserver/server/factory_test.go create mode 100644 fxmcpserver/server/healthcheck.go create mode 100644 fxmcpserver/server/healthcheck_test.go create mode 100644 fxmcpserver/server/provider.go create mode 100644 fxmcpserver/server/provider_test.go create mode 100644 fxmcpserver/server/registry.go create mode 100644 fxmcpserver/server/registry_test.go create mode 100644 fxmcpserver/server/sse/context.go create mode 100644 fxmcpserver/server/sse/context_test.go create mode 100644 fxmcpserver/server/sse/factory.go create mode 100644 fxmcpserver/server/sse/factory_test.go create mode 100644 fxmcpserver/server/sse/server.go create mode 100644 fxmcpserver/server/sse/server_test.go create mode 100644 fxmcpserver/server/stdio/context.go create mode 100644 fxmcpserver/server/stdio/context_test.go create mode 100644 fxmcpserver/server/stdio/factory.go create mode 100644 fxmcpserver/server/stdio/factory_test.go create mode 100644 fxmcpserver/server/stdio/server.go create mode 100644 fxmcpserver/server/stdio/server_test.go create mode 100644 fxmcpserver/server/util.go create mode 100644 fxmcpserver/server/util_test.go create mode 100644 fxmcpserver/testdata/config/config.yaml create mode 100644 fxmcpserver/testdata/prompt/prompt.go create mode 100644 fxmcpserver/testdata/resource/resource.go create mode 100644 fxmcpserver/testdata/resource/template.go create mode 100644 fxmcpserver/testdata/tool/tool.go diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f473afff..c2d4fa82 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -42,6 +42,7 @@ jobs: - "fxhttpclient" - "fxhttpserver" - "fxlog" + - "fxmcpserver" - "fxmetrics" - "fxorm" - "fxsql" diff --git a/.github/workflows/fxmcpserver-ci.yml b/.github/workflows/fxmcpserver-ci.yml new file mode 100644 index 00000000..cd70cb29 --- /dev/null +++ b/.github/workflows/fxmcpserver-ci.yml @@ -0,0 +1,32 @@ +name: "fxmcpserver-ci" + +on: + push: + branches: + - "feat**" + - "fix**" + - "hotfix**" + - "chore**" + paths: + - "fxmcpserver/**.go" + - "fxmcpserver/go.mod" + - "fxmcpserver/go.sum" + pull_request: + types: + - opened + - synchronize + - reopened + branches: + - main + paths: + - "fxmcpserver/**.go" + - "fxmcpserver/go.mod" + - "fxmcpserver/go.sum" + +jobs: + ci: + uses: ./.github/workflows/common-ci.yml + secrets: inherit + with: + module: "fxmcpserver" + go_version: "1.23" diff --git a/fxmcpserver/.golangci.yml b/fxmcpserver/.golangci.yml new file mode 100644 index 00000000..beb045a6 --- /dev/null +++ b/fxmcpserver/.golangci.yml @@ -0,0 +1,65 @@ +run: + timeout: 5m + concurrency: 8 + +linters: + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - containedctx + - contextcheck + - cyclop + - decorder + - dogsled + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - forbidigo + - forcetypeassert + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - gofmt + - goheader + - gomoddirectives + - gomodguard + - goprintffuncname + - gosec + - gosimple + - govet + - grouper + - importas + - ineffassign + - interfacebloat + - logrlint + - maintidx + - makezero + - misspell + - nestif + - nilerr + - nilnil + - nlreturn + - nolintlint + - nosprintfhostport + - prealloc + - predeclared + - promlinter + - reassign + - staticcheck + - tenv + - thelper + - tparallel + - typecheck + - unconvert + - unparam + - unused + - usestdlibvars + - whitespace diff --git a/fxmcpserver/go.mod b/fxmcpserver/go.mod new file mode 100644 index 00000000..6918958e --- /dev/null +++ b/fxmcpserver/go.mod @@ -0,0 +1,80 @@ +module github.com/ankorstore/yokai/fxmcpserver + +go 1.23 + +require ( + github.com/ankorstore/yokai/config v1.5.0 + github.com/ankorstore/yokai/fxconfig v1.3.0 + github.com/ankorstore/yokai/fxgenerate v1.3.0 + github.com/ankorstore/yokai/fxlog v1.1.0 + github.com/ankorstore/yokai/fxmetrics v1.2.0 + github.com/ankorstore/yokai/fxtrace v1.2.0 + github.com/ankorstore/yokai/generate v1.3.0 + github.com/ankorstore/yokai/healthcheck v1.1.0 + github.com/ankorstore/yokai/log v1.2.0 + github.com/ankorstore/yokai/trace v1.4.0 + github.com/mark3labs/mcp-go v0.25.0 + github.com/prometheus/client_golang v1.22.0 + github.com/stretchr/testify v1.10.0 + go.opencensus.io v0.24.0 + go.opentelemetry.io/otel v1.24.0 + go.opentelemetry.io/otel/sdk v1.24.0 + go.opentelemetry.io/otel/trace v1.24.0 + go.uber.org/fx v1.23.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/rs/zerolog v1.32.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/proto/otlp v1.1.0 // indirect + go.uber.org/dig v1.18.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/fxmcpserver/go.sum b/fxmcpserver/go.sum new file mode 100644 index 00000000..c8cd0a4c --- /dev/null +++ b/fxmcpserver/go.sum @@ -0,0 +1,255 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ankorstore/yokai/config v1.5.0 h1:vL/l0dcnq34FtxE+Up1NvzgcRB0G/vI4Yo/H5PccfN0= +github.com/ankorstore/yokai/config v1.5.0/go.mod h1:C8ggYvcrG+J0Ra2vTtcDCANa8HMf3FdrC0Ek8o3tTEw= +github.com/ankorstore/yokai/fxconfig v1.3.0 h1:kk+RkpgECjZYciN2E3lnVj1dpewRy54JN7k8zErpX88= +github.com/ankorstore/yokai/fxconfig v1.3.0/go.mod h1:NTF2TbT+xZNEzI/iTCQLtY+oS/AJSDAPAqouPgAYzbE= +github.com/ankorstore/yokai/fxgenerate v1.3.0 h1:+opuO9YWn71CVtGAR4+c9K07XyyhUHilGsPHqTFGO5c= +github.com/ankorstore/yokai/fxgenerate v1.3.0/go.mod h1:Ts66FYH0ItnlMmz1YhCjfsOoVpnx8u6mrHuyoa9War4= +github.com/ankorstore/yokai/fxlog v1.1.0 h1:vLI8Qd9KfCzAH9IvzGJTvFYmlE1jtMnjvA4z/vxJpYg= +github.com/ankorstore/yokai/fxlog v1.1.0/go.mod h1:VHlj/FNGAuLNqTyRCCx3iGUi9IZXv7qVNrDLUQng1cE= +github.com/ankorstore/yokai/fxmetrics v1.2.0 h1:B4vwfOxsUeFXC5rn0bDHsFnOhEFhRq9aUEWpEayEOCY= +github.com/ankorstore/yokai/fxmetrics v1.2.0/go.mod h1:WBr76IIdlSZIpBsjKSdXCAJBWF0HCp46bwFX8bt0tFk= +github.com/ankorstore/yokai/fxtrace v1.2.0 h1:SXlWbjKSsb2wVH+hXSE9OD2VwyqkznwwW+kiQcNvEAU= +github.com/ankorstore/yokai/fxtrace v1.2.0/go.mod h1:ch72eVTlIedETOApK7SXk2NEWpn3yYeM018dNRccocg= +github.com/ankorstore/yokai/generate v1.3.0 h1:Fgu3vjjA9pThOqG9GPkWIB30LufSVCLPzGUel5zcPcY= +github.com/ankorstore/yokai/generate v1.3.0/go.mod h1:gqS/i20wnvCOhcXydYdiGcASzBaeuW7GK6YYg/kkuY4= +github.com/ankorstore/yokai/healthcheck v1.1.0 h1:PXkEccym7iaVnQltpM5UFi0Xl0n+5rZDzlQju6HmGms= +github.com/ankorstore/yokai/healthcheck v1.1.0/go.mod h1:IiYgjRa4G3OLZMwAuacuryZZAfDHsBH8PQoK4PgRdZ4= +github.com/ankorstore/yokai/log v1.2.0 h1:jiuDiC0dtqIGIOsFQslUHYoFJ1qjI+rOMa6dI1LBf2Y= +github.com/ankorstore/yokai/log v1.2.0/go.mod h1:MVvUcms1AYGo0BT6l88B9KJdvtK6/qGKdgyKVXfbmyc= +github.com/ankorstore/yokai/trace v1.4.0 h1:AdEQs/4TEuqOJ9p/EfsQmrtmkSG3pcmE7r/l+FQFxY8= +github.com/ankorstore/yokai/trace v1.4.0/go.mod h1:m7EL2MRBilgCtrly5gA4F0jkGSXR2EbG6LsotbTJ4nA= +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/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +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.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mark3labs/mcp-go v0.25.0 h1:UUpcMT3L5hIhuDy7aifj4Bphw4Pfx1Rf8mzMXDe8RQw= +github.com/mark3labs/mcp-go v0.25.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0/go.mod h1:CQNu9bj7o7mC6U7+CA/schKEYakYXWr79ucDHTMGhCM= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDOpt2M8OTG92cWqUESvzh2MxiR5xY8= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= +go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw= +go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= +go.uber.org/fx v1.23.0/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7 h1:oqta3O3AnlWbmIE3bFnWbu4bRxZjfbWCp0cKSuZh01E= +google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7/go.mod h1:VQW3tUculP/D4B+xVCo+VgSq8As6wA9ZjHl//pmk+6s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1:lfpJ/2rWPa/kJgxyyXM8PrNnfCzcmxJ265mADgwmvLI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/fxmcpserver/info.go b/fxmcpserver/info.go new file mode 100644 index 00000000..cf1c0e71 --- /dev/null +++ b/fxmcpserver/info.go @@ -0,0 +1,59 @@ +package fxmcpserver + +import ( + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/fxmcpserver/server/stdio" +) + +type MCPServerModuleInfo struct { + config *config.Config + registry *server.MCPServerRegistry + sseServer *sse.MCPSSEServer + stdioServer *stdio.MCPStdioServer +} + +func NewMCPServerModuleInfo( + config *config.Config, + registry *server.MCPServerRegistry, + sseServer *sse.MCPSSEServer, + stdioServer *stdio.MCPStdioServer, +) *MCPServerModuleInfo { + return &MCPServerModuleInfo{ + config: config, + registry: registry, + sseServer: sseServer, + stdioServer: stdioServer, + } +} + +// Name return the name of the module info. +func (i *MCPServerModuleInfo) Name() string { + return ModuleName +} + +// Data return the data of the module info. +func (i *MCPServerModuleInfo) Data() map[string]any { + sseServerInfo := i.sseServer.Info() + stdioServerInfo := i.stdioServer.Info() + mcpRegistryInfo := i.registry.Info() + + return map[string]any{ + "transports": map[string]any{ + "sse": sseServerInfo, + "stdio": stdioServerInfo, + }, + "capabilities": map[string]any{ + "tools": mcpRegistryInfo.Capabilities.Tools, + "prompts": mcpRegistryInfo.Capabilities.Prompts, + "resources": mcpRegistryInfo.Capabilities.Resources, + }, + "registrations": map[string]any{ + "tools": mcpRegistryInfo.Registrations.Tools, + "prompts": mcpRegistryInfo.Registrations.Prompts, + "resources": mcpRegistryInfo.Registrations.Resources, + "resourceTemplates": mcpRegistryInfo.Registrations.ResourceTemplates, + }, + } +} diff --git a/fxmcpserver/info_test.go b/fxmcpserver/info_test.go new file mode 100644 index 00000000..bbdffbbf --- /dev/null +++ b/fxmcpserver/info_test.go @@ -0,0 +1,95 @@ +package fxmcpserver_test + +import ( + "testing" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver" + fs "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/fxmcpserver/server/stdio" + "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" + "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestMCPServerModuleInfo(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("testdata/config"), + ) + assert.NoError(t, err) + + reg := fs.NewMCPServerRegistry( + cfg, + []fs.MCPServerTool{ + tool.NewTestTool(), + }, + []fs.MCPServerPrompt{ + prompt.NewTestPrompt(), + }, + []fs.MCPServerResource{ + resource.NewTestResource(), + }, + []fs.MCPServerResourceTemplate{ + resource.NewTestResourceTemplate(), + }, + ) + + mcpSrv := server.NewMCPServer("test-server", "1.0.0") + + sseSrv := sse.NewDefaultMCPSSEServerFactory(cfg).Create(mcpSrv) + stdioSrv := stdio.NewDefaultMCPStdioServerFactory().Create(mcpSrv) + + info := fxmcpserver.NewMCPServerModuleInfo(cfg, reg, sseSrv, stdioSrv) + + assert.Equal(t, info.Name(), fxmcpserver.ModuleName) + + expectedData := map[string]any{ + "transports": map[string]any{ + "sse": map[string]any{ + "config": map[string]any{ + "address": ":0", + "base_url": sse.DefaultBaseURL, + "base_path": sse.DefaultBasePath, + "sse_endpoint": sse.DefaultSSEEndpoint, + "message_endpoint": sse.DefaultMessageEndpoint, + "keep_alive": true, + "keep_alive_interval": sse.DefaultKeepAliveInterval.Seconds(), + }, + "status": map[string]any{ + "running": false, + }, + }, + "stdio": map[string]any{ + "status": map[string]any{ + "running": false, + }, + }, + }, + "capabilities": map[string]any{ + "tools": true, + "prompts": true, + "resources": true, + }, + "registrations": map[string]any{ + "tools": map[string]string{ + "test-tool": "github.com/ankorstore/yokai/fxmcpserver/testdata/tool.(*TestTool).Handle.func1", + }, + "prompts": map[string]string{ + "test-prompt": "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt.(*TestPrompt).Handle.func1", + }, + "resources": map[string]string{ + "test-resource": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*TestResource).Handle.func1", + }, + "resourceTemplates": map[string]string{ + "test-template": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*TestResourceTemplate).Handle.func1", + }, + }, + } + + assert.Equal(t, expectedData, info.Data()) +} diff --git a/fxmcpserver/module.go b/fxmcpserver/module.go new file mode 100644 index 00000000..049ce91a --- /dev/null +++ b/fxmcpserver/module.go @@ -0,0 +1,232 @@ +package fxmcpserver + +import ( + "context" + + "github.com/ankorstore/yokai/config" + fs "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/fxmcpserver/server/stdio" + "github.com/ankorstore/yokai/generate/uuid" + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/server" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/trace" + "go.uber.org/fx" +) + +const ModuleName = "mcpserver" + +// FxMCPServerModule is the MCP server module. +var FxMCPServerModule = fx.Module( + ModuleName, + fx.Provide( + // module fixed dependencies + ProvideMCPServerRegistry, + ProvideMCPServer, + ProvideMCPSSEServer, + ProvideMCPStdioServer, + // module overridable dependencies + fx.Annotate( + ProvideDefaultMCPServerHooksProvider, + fx.As(new(fs.MCPServerHooksProvider)), + ), + fx.Annotate( + ProvideDefaultMCPServerFactory, + fx.As(new(fs.MCPServerFactory)), + ), + fx.Annotate( + ProvideDefaultMCPSSEServerContextHandler, + fx.As(new(sse.MCPSSEServerContextHandler)), + ), + fx.Annotate( + ProvideDefaultMCPSSEServerFactory, + fx.As(new(sse.MCPSSEServerFactory)), + ), + fx.Annotate( + ProvideDefaultMCPStdioServerContextHandler, + fx.As(new(stdio.MCPStdioServerContextHandler)), + ), + fx.Annotate( + ProvideDefaultMCPStdioServerFactory, + fx.As(new(stdio.MCPStdioServerFactory)), + ), + // module info + fx.Annotate( + NewMCPServerModuleInfo, + fx.As(new(any)), + fx.ResultTags(`group:"core-module-infos"`), + ), + ), +) + +// ProvideDefaultMCPServerHooksProviderParams allows injection of the required dependencies in ProvideDefaultMCPServerHooksProvider. +type ProvideDefaultMCPServerHooksProviderParams struct { + fx.In + Registry *prometheus.Registry + Config *config.Config +} + +// ProvideDefaultMCPServerHooksProvider provides the default server.MCPServerHooksProvider instance. +func ProvideDefaultMCPServerHooksProvider(p ProvideDefaultMCPServerHooksProviderParams) *fs.DefaultMCPServerHooksProvider { + return fs.NewDefaultMCPServerHooksProvider(p.Registry, p.Config) +} + +// ProvideDefaultMCPServerFactoryParams allows injection of the required dependencies in ProvideDefaultMCPServerFactory. +type ProvideDefaultMCPServerFactoryParams struct { + fx.In + Config *config.Config +} + +// ProvideDefaultMCPServerFactory provides the default server.MCPServerFactory instance. +func ProvideDefaultMCPServerFactory(p ProvideDefaultMCPServerFactoryParams) *fs.DefaultMCPServerFactory { + return fs.NewDefaultMCPServerFactory(p.Config) +} + +// ProvideMCPServerRegistryParams allows injection of the required dependencies in ProvideMCPServerRegistry. +type ProvideMCPServerRegistryParams struct { + fx.In + Config *config.Config + Tools []fs.MCPServerTool `group:"mcp-server-tools"` + Prompts []fs.MCPServerPrompt `group:"mcp-server-prompts"` + Resources []fs.MCPServerResource `group:"mcp-server-resources"` + ResourceTemplates []fs.MCPServerResourceTemplate `group:"mcp-server-resource-templates"` +} + +// ProvideMCPServerRegistry provides the server.MCPServerRegistry. +func ProvideMCPServerRegistry(p ProvideMCPServerRegistryParams) *fs.MCPServerRegistry { + return fs.NewMCPServerRegistry( + p.Config, + p.Tools, + p.Prompts, + p.Resources, + p.ResourceTemplates, + ) +} + +// ProvideMCPServerParam allows injection of the required dependencies in ProvideMCPServer. +type ProvideMCPServerParam struct { + fx.In + Config *config.Config + Provider fs.MCPServerHooksProvider + Factory fs.MCPServerFactory + Registry *fs.MCPServerRegistry +} + +// ProvideMCPServer provides the server.MCPServer. +func ProvideMCPServer(p ProvideMCPServerParam) *server.MCPServer { + srv := p.Factory.Create(server.WithHooks(p.Provider.Provide())) + + p.Registry.Register(srv) + + return srv +} + +// ProvideDefaultMCPSSEContextHandlerParam allows injection of the required dependencies in ProvideDefaultMCPSSEServerContextHandler. +type ProvideDefaultMCPSSEContextHandlerParam struct { + fx.In + Generator uuid.UuidGenerator + TracerProvider trace.TracerProvider + Logger *log.Logger +} + +// ProvideDefaultMCPSSEServerContextHandler provides the default sse.MCPSSEServerContextHandler instance. +func ProvideDefaultMCPSSEServerContextHandler(p ProvideDefaultMCPSSEContextHandlerParam) *sse.DefaultMCPSSEServerContextHandler { + return sse.NewDefaultMCPSSEServerContextHandler(p.Generator, p.TracerProvider, p.Logger) +} + +// ProvideDefaultMCPSSEServerFactoryParams allows injection of the required dependencies in ProvideDefaultMCPSSEServerFactory. +type ProvideDefaultMCPSSEServerFactoryParams struct { + fx.In + Config *config.Config +} + +// ProvideDefaultMCPSSEServerFactory provides the default sse.MCPSSEServerFactory instance. +func ProvideDefaultMCPSSEServerFactory(p ProvideDefaultMCPServerFactoryParams) *sse.DefaultMCPSSEServerFactory { + return sse.NewDefaultMCPSSEServerFactory(p.Config) +} + +// ProvideMCPSSEServerParam allows injection of the required dependencies in ProvideMCPSSEServer. +type ProvideMCPSSEServerParam struct { + fx.In + LifeCycle fx.Lifecycle + Context context.Context + Logger *log.Logger + Config *config.Config + MCPServer *server.MCPServer + MCPSSEServerFactory sse.MCPSSEServerFactory + MCPSSEServerContextHandler sse.MCPSSEServerContextHandler +} + +// ProvideMCPSSEServer provides the sse.MCPSSEServer. +func ProvideMCPSSEServer(p ProvideMCPSSEServerParam) *sse.MCPSSEServer { + sseServer := p.MCPSSEServerFactory.Create( + p.MCPServer, + server.WithSSEContextFunc(p.MCPSSEServerContextHandler.Handle()), + ) + + if p.Config.GetBool("modules.mcp.server.transport.sse.expose") { + p.LifeCycle.Append(fx.Hook{ + OnStart: func(context.Context) error { + go sseServer.Start(p.Context) + + return nil + }, + OnStop: func(ctx context.Context) error { + return sseServer.Stop(ctx) + }, + }) + } + + return sseServer +} + +// ProvideDefaultMCPStdioContextHandlerParam allows injection of the required dependencies in ProvideDefaultMCPStdioServerContextHandler. +type ProvideDefaultMCPStdioContextHandlerParam struct { + fx.In + Generator uuid.UuidGenerator + TracerProvider trace.TracerProvider + Logger *log.Logger +} + +// ProvideDefaultMCPStdioServerContextHandler provides the default stdio.MCPStdioServerContextHandler instance. +func ProvideDefaultMCPStdioServerContextHandler(p ProvideDefaultMCPStdioContextHandlerParam) *stdio.DefaultMCPStdioServerContextHandler { + return stdio.NewDefaultMCPStdioServerContextHandler(p.Generator, p.TracerProvider, p.Logger) +} + +// ProvideDefaultMCPStdioServerFactory provides the default stdio.MCPStdioServerFactory instance. +func ProvideDefaultMCPStdioServerFactory() *stdio.DefaultMCPStdioServerFactory { + return stdio.NewDefaultMCPStdioServerFactory() +} + +// ProvideMCPStdioServerParam allows injection of the required dependencies in ProvideMCPStdioServer. +type ProvideMCPStdioServerParam struct { + fx.In + LifeCycle fx.Lifecycle + Context context.Context + Logger *log.Logger + Config *config.Config + MCPServer *server.MCPServer + MCPStdioServerFactory stdio.MCPStdioServerFactory + MCPStdioServerContextHandler stdio.MCPStdioServerContextHandler +} + +// ProvideMCPStdioServer provides the stdio.MCPStdioServer. +func ProvideMCPStdioServer(p ProvideMCPStdioServerParam) *stdio.MCPStdioServer { + stdioServer := p.MCPStdioServerFactory.Create( + p.MCPServer, + server.WithStdioContextFunc(p.MCPStdioServerContextHandler.Handle()), + ) + + if p.Config.GetBool("modules.mcp.server.transport.stdio.expose") { + p.LifeCycle.Append(fx.Hook{ + OnStart: func(context.Context) error { + go stdioServer.Start(p.Context) + + return nil + }, + }) + } + + return stdioServer +} diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go new file mode 100644 index 00000000..8b58bbaa --- /dev/null +++ b/fxmcpserver/module_test.go @@ -0,0 +1,164 @@ +package fxmcpserver_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ankorstore/yokai/fxconfig" + "github.com/ankorstore/yokai/fxgenerate" + "github.com/ankorstore/yokai/fxlog" + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" + "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" + "github.com/ankorstore/yokai/fxmetrics" + "github.com/ankorstore/yokai/fxtrace" + "github.com/ankorstore/yokai/log/logtest" + "github.com/ankorstore/yokai/trace/tracetest" + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/attribute" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" +) + +func TestMCPServerModule(t *testing.T) { + t.Setenv("APP_CONFIG_PATH", "testdata/config") + + var mcpServer *server.MCPServer + var handler sse.MCPSSEServerContextHandler + var logBuffer logtest.TestLogBuffer + var traceExporter tracetest.TestTraceExporter + var metricsRegistry *prometheus.Registry + + fxtest.New( + t, + fx.NopLogger, + fxconfig.FxConfigModule, + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxgenerate.FxGenerateModule, + fxmetrics.FxMetricsModule, + fxmcpserver.FxMCPServerModule, + fx.Options( + fxmcpserver.AsMCPServerTools(tool.NewTestTool), + fxmcpserver.AsMCPServerPrompts(prompt.NewTestPrompt), + fxmcpserver.AsMCPServerResources(resource.NewTestResource), + fxmcpserver.AsMCPServerResourceTemplates(resource.NewTestResourceTemplate), + ), + fx.Populate(&mcpServer, &handler, &logBuffer, &traceExporter, &metricsRegistry), + ).RequireStart().RequireStop() + + // test server + testServer := server.NewTestServer(mcpServer, server.WithSSEContextFunc(handler.Handle())) + defer testServer.Close() + + // test client + testClient, err := client.NewSSEMCPClient(testServer.URL + "/sse") + assert.NoError(t, err) + + // start the client + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + err = testClient.Start(ctx) + assert.NoError(t, err) + defer testClient.Close() + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "info", + "message": "MCP session registered", + }) + + // send initialize request + expectedRequest := `{"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}` + expectedResponse := `{"protocolVersion":"2024-11-05","capabilities":{"logging":{},"prompts":{},"resources":{},"tools":{}},"serverInfo":{"name":"test-server","version":"1.0.0"}}` + + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + } + + initResult, err := testClient.Initialize(ctx, initRequest) + assert.NoError(t, err) + assert.Equal(t, "test-server", initResult.ServerInfo.Name) + assert.Equal(t, "1.0.0", initResult.ServerInfo.Version) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "info", + "mcpMethod": "initialize", + "mcpRequest": expectedRequest, + "mcpResponse": expectedResponse, + "message": "MCP request success", + }) + + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP initialize", + attribute.String("mcp.method", "initialize"), + attribute.String("mcp.request", expectedRequest), + attribute.String("mcp.response", expectedResponse), + ) + + expectedMetric := ` + # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests + # TYPE foo_bar_mcp_server_requests_total counter + foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 + ` + err = testutil.GatherAndCompare( + metricsRegistry, + strings.NewReader(expectedMetric), + "foo_bar_mcp_server_requests_total", + ) + assert.NoError(t, err) + + // send tool/list request + expectedRequest = `{"method":"tools/list","params":{}}` + expectedResponse = `{"tools":[{"annotations":{"destructiveHint":true,"openWorldHint":true},"description":"Test tool.","inputSchema":{"properties":{},"type":"object"},"name":"test-tool"}]}` + + toolsListRequest := mcp.ListToolsRequest{} + + toolsListResult, err := testClient.ListTools(ctx, toolsListRequest) + assert.NoError(t, err) + assert.Len(t, toolsListResult.Tools, 1) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "info", + "mcpMethod": "tools/list", + "mcpRequest": expectedRequest, + "mcpResponse": expectedResponse, + "message": "MCP request success", + }) + + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP tools/list", + attribute.String("mcp.method", "tools/list"), + attribute.String("mcp.request", expectedRequest), + attribute.String("mcp.response", expectedResponse), + ) + + expectedMetric = ` + # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests + # TYPE foo_bar_mcp_server_requests_total counter + foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 + foo_bar_mcp_server_requests_total{method="tools/list",status="success",target=""} 1 + ` + err = testutil.GatherAndCompare( + metricsRegistry, + strings.NewReader(expectedMetric), + "foo_bar_mcp_server_requests_total", + ) + assert.NoError(t, err) +} diff --git a/fxmcpserver/register.go b/fxmcpserver/register.go new file mode 100644 index 00000000..2f86efab --- /dev/null +++ b/fxmcpserver/register.go @@ -0,0 +1,94 @@ +package fxmcpserver + +import ( + "github.com/ankorstore/yokai/fxmcpserver/server" + "go.uber.org/fx" +) + +// AsMCPServerTool registers an MCP tool. +func AsMCPServerTool(constructor any) fx.Option { + return fx.Provide( + fx.Annotate( + constructor, + fx.As(new(server.MCPServerTool)), + fx.ResultTags(`group:"mcp-server-tools"`), + ), + ) +} + +// AsMCPServerTools registers several MCP tools. +func AsMCPServerTools(constructors ...any) fx.Option { + options := []fx.Option{} + + for _, constructor := range constructors { + options = append(options, AsMCPServerTool(constructor)) + } + + return fx.Options(options...) +} + +// AsMCPServerPrompt registers an MCP prompt. +func AsMCPServerPrompt(constructor any) fx.Option { + return fx.Provide( + fx.Annotate( + constructor, + fx.As(new(server.MCPServerPrompt)), + fx.ResultTags(`group:"mcp-server-prompts"`), + ), + ) +} + +// AsMCPServerPrompts registers several MCP prompts. +func AsMCPServerPrompts(constructors ...any) fx.Option { + options := []fx.Option{} + + for _, constructor := range constructors { + options = append(options, AsMCPServerPrompt(constructor)) + } + + return fx.Options(options...) +} + +// AsMCPServerResource registers an MCP resource. +func AsMCPServerResource(constructor any) fx.Option { + return fx.Provide( + fx.Annotate( + constructor, + fx.As(new(server.MCPServerResource)), + fx.ResultTags(`group:"mcp-server-resources"`), + ), + ) +} + +// AsMCPServerResources registers several MCP resources. +func AsMCPServerResources(constructors ...any) fx.Option { + options := []fx.Option{} + + for _, constructor := range constructors { + options = append(options, AsMCPServerResource(constructor)) + } + + return fx.Options(options...) +} + +// AsMCPServerResourceTemplate registers an MCP resource template. +func AsMCPServerResourceTemplate(constructor any) fx.Option { + return fx.Provide( + fx.Annotate( + constructor, + fx.As(new(server.MCPServerResourceTemplate)), + fx.ResultTags(`group:"mcp-server-resource-templates"`), + ), + ) +} + +// AsMCPServerResourceTemplates registers several MCP resource templates. +func AsMCPServerResourceTemplates(constructors ...any) fx.Option { + options := []fx.Option{} + + for _, constructor := range constructors { + options = append(options, AsMCPServerResourceTemplate(constructor)) + } + + return fx.Options(options...) +} diff --git a/fxmcpserver/register_test.go b/fxmcpserver/register_test.go new file mode 100644 index 00000000..2e420d4e --- /dev/null +++ b/fxmcpserver/register_test.go @@ -0,0 +1,85 @@ +package fxmcpserver_test + +import ( + "fmt" + "testing" + + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" + "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" + "github.com/stretchr/testify/assert" + "go.uber.org/fx" +) + +func TestAsMCPServerTool(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerTool(tool.NewTestTool) + + assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPServerTools(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerTools(tool.NewTestTool) + + assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPServerPrompt(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerPrompt(prompt.NewTestPrompt) + + assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPServerPrompts(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerPrompts(prompt.NewTestPrompt) + + assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPServerResource(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerResource(resource.NewTestResource) + + assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPServerResources(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerResources(resource.NewTestResource) + + assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPServerResourceTemplate(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerResourceTemplate(resource.NewTestResourceTemplate) + + assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPServerResourceTemplates(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerResourceTemplates(resource.NewTestResourceTemplate) + + assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} diff --git a/fxmcpserver/server/context/context.go b/fxmcpserver/server/context/context.go new file mode 100644 index 00000000..513c69c1 --- /dev/null +++ b/fxmcpserver/server/context/context.go @@ -0,0 +1,69 @@ +package context + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/trace" +) + +type CtxRequestIdKey struct{} +type CtxSessionIdKey struct{} +type CtxRootSpanKey struct{} +type CtxStartTimeKey struct{} + +// WithRequestID adds a given request id to a given context. +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, CtxRequestIdKey{}, requestID) +} + +// CtxRequestId returns the request id from a given context. +func CtxRequestId(ctx context.Context) string { + if rid, ok := ctx.Value(CtxRequestIdKey{}).(string); ok { + return rid + } + + return "" +} + +// WithSessionID adds a given session id to a given context. +func WithSessionID(ctx context.Context, sessionID string) context.Context { + return context.WithValue(ctx, CtxSessionIdKey{}, sessionID) +} + +// CtxSessionID returns the session id from a given context. +func CtxSessionID(ctx context.Context) string { + if sid, ok := ctx.Value(CtxSessionIdKey{}).(string); ok { + return sid + } + + return "" +} + +// WithRootSpan adds a root span to a given context. +func WithRootSpan(ctx context.Context, span trace.Span) context.Context { + return context.WithValue(ctx, CtxRootSpanKey{}, span) +} + +// CtxRootSpan returns the root span from a given context. +func CtxRootSpan(ctx context.Context) trace.Span { + if span, ok := ctx.Value(CtxRootSpanKey{}).(trace.Span); ok { + return span + } + + return trace.SpanFromContext(ctx) +} + +// WithStartTime adds a start time to a given context. +func WithStartTime(ctx context.Context, t time.Time) context.Context { + return context.WithValue(ctx, CtxStartTimeKey{}, t) +} + +// CtxStartTime returns the start time from a given context. +func CtxStartTime(ctx context.Context) time.Time { + if t, ok := ctx.Value(CtxStartTimeKey{}).(time.Time); ok { + return t + } + + return time.Now() +} diff --git a/fxmcpserver/server/context/context_test.go b/fxmcpserver/server/context/context_test.go new file mode 100644 index 00000000..fa711a03 --- /dev/null +++ b/fxmcpserver/server/context/context_test.go @@ -0,0 +1,105 @@ +package context_test + +import ( + "context" + "fmt" + "testing" + "time" + + servercontext "github.com/ankorstore/yokai/fxmcpserver/server/context" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/sdk/trace" +) + +func TestCtxRequestId(t *testing.T) { + t.Parallel() + + t.Run("with existing context entry", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + ctx = servercontext.WithRequestID(ctx, "test-request-id") + + assert.Equal(t, "test-request-id", servercontext.CtxRequestId(ctx)) + }) + + t.Run("without existing context entry", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + assert.Equal(t, "", servercontext.CtxRequestId(ctx)) + }) +} + +func TestCtxSessionId(t *testing.T) { + t.Parallel() + + t.Run("with existing context entry", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + ctx = servercontext.WithSessionID(ctx, "test-session-id") + + assert.Equal(t, "test-session-id", servercontext.CtxSessionID(ctx)) + }) + + t.Run("without existing context entry", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + assert.Equal(t, "", servercontext.CtxSessionID(ctx)) + }) +} + +func TestCtxRootSpan(t *testing.T) { + t.Parallel() + + t.Run("with existing context entry", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + _, span := trace.NewTracerProvider().Tracer("test-tracer").Start(ctx, "test-span") + + ctx = servercontext.WithRootSpan(ctx, span) + + assert.Equal(t, "*trace.recordingSpan", fmt.Sprintf("%T", servercontext.CtxRootSpan(ctx))) + }) + + t.Run("without existing context entry", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + assert.Equal(t, "trace.noopSpan", fmt.Sprintf("%T", servercontext.CtxRootSpan(ctx))) + }) +} + +func TestCtxStartTime(t *testing.T) { + t.Parallel() + + startTime, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") + assert.NoError(t, err) + + t.Run("with existing context entry", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + ctx = servercontext.WithStartTime(ctx, startTime) + + assert.Equal(t, startTime, servercontext.CtxStartTime(ctx)) + }) + + t.Run("without existing context entry", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + assert.NotEqual(t, startTime, servercontext.CtxStartTime(ctx)) + }) +} diff --git a/fxmcpserver/server/factory.go b/fxmcpserver/server/factory.go new file mode 100644 index 00000000..d0d61ade --- /dev/null +++ b/fxmcpserver/server/factory.go @@ -0,0 +1,57 @@ +package server + +import ( + "github.com/ankorstore/yokai/config" + "github.com/mark3labs/mcp-go/server" +) + +const ( + DefaultServerName = "MCP Server" + DefaultServerVersion = "1.0.0" +) + +var _ MCPServerFactory = (*DefaultMCPServerFactory)(nil) + +// MCPServerFactory is the interface for server.MCPServer factories. +type MCPServerFactory interface { + Create(options ...server.ServerOption) *server.MCPServer +} + +// DefaultMCPServerFactory is the default MCPServerFactory implementation. +type DefaultMCPServerFactory struct { + config *config.Config +} + +// NewDefaultMCPServerFactory returns a new DefaultMCPServerFactory instance. +func NewDefaultMCPServerFactory(config *config.Config) *DefaultMCPServerFactory { + return &DefaultMCPServerFactory{ + config: config, + } +} + +// Create creates a new server.MCPServer instance. +func (f *DefaultMCPServerFactory) Create(options ...server.ServerOption) *server.MCPServer { + name := f.config.GetString("modules.mcp.server.name") + if name == "" { + name = DefaultServerName + } + + version := f.config.GetString("modules.mcp.server.version") + if version == "" { + version = DefaultServerVersion + } + + srvOptions := []server.ServerOption{ + server.WithLogging(), + server.WithRecovery(), + } + + instructions := f.config.GetString("modules.mcp.server.instructions") + if instructions != "" { + srvOptions = append(srvOptions, server.WithInstructions(instructions)) + } + + srvOptions = append(srvOptions, options...) + + return server.NewMCPServer(name, version, srvOptions...) +} diff --git a/fxmcpserver/server/factory_test.go b/fxmcpserver/server/factory_test.go new file mode 100644 index 00000000..084e727c --- /dev/null +++ b/fxmcpserver/server/factory_test.go @@ -0,0 +1,25 @@ +package server_test + +import ( + "testing" + + "github.com/ankorstore/yokai/config" + fs "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestDefaultMCPServerFactory_Create(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../testdata/config"), + ) + assert.NoError(t, err) + + fac := fs.NewDefaultMCPServerFactory(cfg) + + srv := fac.Create() + + assert.IsType(t, (*server.MCPServer)(nil), srv) +} diff --git a/fxmcpserver/server/healthcheck.go b/fxmcpserver/server/healthcheck.go new file mode 100644 index 00000000..8bb49f7b --- /dev/null +++ b/fxmcpserver/server/healthcheck.go @@ -0,0 +1,65 @@ +package server + +import ( + "context" + "strings" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/fxmcpserver/server/stdio" + "github.com/ankorstore/yokai/healthcheck" +) + +// MCPServerProbe is a probe compatible with the healthcheck module. +type MCPServerProbe struct { + config *config.Config + sseServer *sse.MCPSSEServer + stdioServer *stdio.MCPStdioServer +} + +// NewMCPServerProbe returns a new MCPServerProbe. +func NewMCPServerProbe( + config *config.Config, + sseServer *sse.MCPSSEServer, + stdioServer *stdio.MCPStdioServer, +) *MCPServerProbe { + return &MCPServerProbe{ + config: config, + sseServer: sseServer, + stdioServer: stdioServer, + } +} + +// Name returns the name of the MCPServerProbe. +func (p *MCPServerProbe) Name() string { + return "mcpserver" +} + +// Check returns a successful healthcheck.CheckerProbeResult if the exposed MCP servers are running. +func (p *MCPServerProbe) Check(context.Context) *healthcheck.CheckerProbeResult { + success := true + var messages []string + + if p.config.GetBool("modules.mcp.server.transport.sse.expose") { + if p.sseServer.Running() { + messages = append(messages, "MCP SSE server is running") + } else { + success = false + messages = append(messages, "MCP SSE server is not running") + } + } + + if p.config.GetBool("modules.mcp.server.transport.stdio.expose") { + if p.stdioServer.Running() { + messages = append(messages, "MCP Stdio server is running") + } else { + success = false + messages = append(messages, "MCP Stdio server is not running") + } + } + + return &healthcheck.CheckerProbeResult{ + Success: success, + Message: strings.Join(messages, ", "), + } +} diff --git a/fxmcpserver/server/healthcheck_test.go b/fxmcpserver/server/healthcheck_test.go new file mode 100644 index 00000000..f3cb5080 --- /dev/null +++ b/fxmcpserver/server/healthcheck_test.go @@ -0,0 +1,34 @@ +package server_test + +import ( + "context" + "testing" + + "github.com/ankorstore/yokai/config" + fs "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/fxmcpserver/server/stdio" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestMCPServerProbe(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../testdata/config"), + ) + assert.NoError(t, err) + + mcpSrv := server.NewMCPServer("test-server", "1.0.0") + + sseSrv := sse.NewDefaultMCPSSEServerFactory(cfg).Create(mcpSrv) + stdioSrv := stdio.NewDefaultMCPStdioServerFactory().Create(mcpSrv) + + probe := fs.NewMCPServerProbe(cfg, sseSrv, stdioSrv) + + res := probe.Check(context.Background()) + + assert.False(t, res.Success) + assert.Equal(t, "MCP SSE server is not running, MCP Stdio server is not running", res.Message) +} diff --git a/fxmcpserver/server/provider.go b/fxmcpserver/server/provider.go new file mode 100644 index 00000000..14757657 --- /dev/null +++ b/fxmcpserver/server/provider.go @@ -0,0 +1,258 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/ankorstore/yokai/config" + fsc "github.com/ankorstore/yokai/fxmcpserver/server/context" + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + otelsdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +var _ MCPServerHooksProvider = (*DefaultMCPServerHooksProvider)(nil) + +// MCPServerHooksProvider is the interface for the MCP server hooks provider. +type MCPServerHooksProvider interface { + Provide() *server.Hooks +} + +// DefaultMCPServerHooksProvider is the default MCPServerHooksProvider implementation. +type DefaultMCPServerHooksProvider struct { + config *config.Config + requestsCounter *prometheus.CounterVec + requestsDuration *prometheus.HistogramVec +} + +// NewDefaultMCPServerHooksProvider returns a new DefaultMCPServerHooksProvider instance. +func NewDefaultMCPServerHooksProvider(registry prometheus.Registerer, config *config.Config) *DefaultMCPServerHooksProvider { + namespace := Sanitize(config.GetString("modules.mcp.server.metrics.collect.namespace")) + subsystem := Sanitize(config.GetString("modules.mcp.server.metrics.collect.subsystem")) + + buckets := prometheus.DefBuckets + if bucketsConfig := config.GetString("modules.mcp.server.metrics.buckets"); bucketsConfig != "" { + for _, s := range Split(bucketsConfig) { + f, err := strconv.ParseFloat(s, 64) + if err == nil { + buckets = append(buckets, f) + } + } + } + + requestsCounter := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "mcp_server_requests_total", + Help: "Number of processed MCP requests", + }, + []string{ + "method", + "target", + "status", + }, + ) + + requestsDuration := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "mcp_server_requests_duration_seconds", + Help: "Time spent processing MCP requests", + Buckets: buckets, + }, + []string{ + "method", + "target", + }, + ) + + registry.MustRegister(requestsCounter, requestsDuration) + + return &DefaultMCPServerHooksProvider{ + config: config, + requestsCounter: requestsCounter, + requestsDuration: requestsDuration, + } +} + +// Provide provides the MCP server hooks. +func (p *DefaultMCPServerHooksProvider) Provide() *server.Hooks { + hooks := &server.Hooks{} + + traceRequest := p.config.GetBool("modules.mcp.server.trace.request") + traceResponse := p.config.GetBool("modules.mcp.server.trace.response") + + logRequest := p.config.GetBool("modules.mcp.server.log.request") + logResponse := p.config.GetBool("modules.mcp.server.log.response") + + metricsEnabled := p.config.GetBool("modules.mcp.server.metrics.collect.enabled") + + hooks.AddOnRegisterSession(func(ctx context.Context, session server.ClientSession) { + log.CtxLogger(ctx).Info().Str("mcpSessionID", session.SessionID()).Msg("MCP session registered") + }) + + hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) { + latency := time.Since(fsc.CtxStartTime(ctx)) + + mcpMethod := string(method) + + spanNameSuffix := mcpMethod + + spanAttributes := []attribute.KeyValue{ + attribute.String("mcp.latency", latency.String()), + attribute.String("mcp.method", mcpMethod), + } + + logFields := map[string]any{ + "mcpLatency": latency.String(), + "mcpMethod": mcpMethod, + } + + metricTarget := "" + + jsonMessage, err := json.Marshal(message) + if err == nil { + if traceRequest { + spanAttributes = append(spanAttributes, attribute.String("mcp.request", string(jsonMessage))) + } + + if logRequest { + logFields["mcpRequest"] = string(jsonMessage) + } + } + + jsonResult, err := json.Marshal(result) + if err == nil { + if traceResponse { + spanAttributes = append(spanAttributes, attribute.String("mcp.response", string(jsonResult))) + } + + if logResponse { + logFields["mcpResponse"] = string(jsonResult) + } + } + + switch method { + case mcp.MethodResourcesRead: + if req, ok := message.(*mcp.ReadResourceRequest); ok { + spanNameSuffix = fmt.Sprintf("%s %s", spanNameSuffix, req.Params.URI) + spanAttributes = append(spanAttributes, attribute.String("mcp.resource", req.Params.URI)) + logFields["mcpResourceURI"] = req.Params.URI + metricTarget = req.Params.URI + } + case mcp.MethodPromptsGet: + if req, ok := message.(*mcp.GetPromptRequest); ok { + spanNameSuffix = fmt.Sprintf("%s %s", spanNameSuffix, req.Params.Name) + spanAttributes = append(spanAttributes, attribute.String("mcp.prompt", req.Params.Name)) + logFields["mcpPrompt"] = req.Params.Name + metricTarget = req.Params.Name + } + case mcp.MethodToolsCall: + if req, ok := message.(*mcp.CallToolRequest); ok { + spanNameSuffix = fmt.Sprintf("%s %s", spanNameSuffix, req.Params.Name) + spanAttributes = append(spanAttributes, attribute.String("mcp.tool", req.Params.Name)) + logFields["mcpTool"] = req.Params.Name + metricTarget = req.Params.Name + } + } + + if rwSpan, ok := fsc.CtxRootSpan(ctx).(otelsdktrace.ReadWriteSpan); ok { + rwSpan.SetName(fmt.Sprintf("%s %s", rwSpan.Name(), spanNameSuffix)) + rwSpan.SetStatus(codes.Ok, "MCP request success") + rwSpan.SetAttributes(spanAttributes...) + rwSpan.End() + } + + log.CtxLogger(ctx).Info().Fields(logFields).Msg("MCP request success") + + if metricsEnabled { + p.requestsCounter.WithLabelValues(mcpMethod, metricTarget, "success").Inc() + p.requestsDuration.WithLabelValues(mcpMethod, metricTarget).Observe(latency.Seconds()) + } + }) + + hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) { + latency := time.Since(fsc.CtxStartTime(ctx)) + + mcpMethod := string(method) + + errMessage := fmt.Sprintf("%v", err) + + spanNameSuffix := mcpMethod + + spanAttributes := []attribute.KeyValue{ + attribute.String("mcp.latency", latency.String()), + attribute.String("mcp.method", mcpMethod), + attribute.String("mcp.error", errMessage), + } + + logFields := map[string]any{ + "mcpLatency": latency.String(), + "mcpMethod": mcpMethod, + "mcpError": errMessage, + } + + metricTarget := "" + + jsonMessage, err := json.Marshal(message) + if err == nil { + if traceRequest { + spanAttributes = append(spanAttributes, attribute.String("mcp.request", string(jsonMessage))) + } + + if logRequest { + logFields["mcpRequest"] = string(jsonMessage) + } + } + + switch method { + case mcp.MethodResourcesRead: + if req, ok := message.(*mcp.ReadResourceRequest); ok { + spanNameSuffix = fmt.Sprintf("%s %s", spanNameSuffix, req.Params.URI) + spanAttributes = append(spanAttributes, attribute.String("mcp.resource", req.Params.URI)) + logFields["mcpResourceURI"] = req.Params.URI + metricTarget = req.Params.URI + } + case mcp.MethodPromptsGet: + if req, ok := message.(*mcp.GetPromptRequest); ok { + spanNameSuffix = fmt.Sprintf("%s %s", spanNameSuffix, req.Params.Name) + spanAttributes = append(spanAttributes, attribute.String("mcp.prompt", req.Params.Name)) + logFields["mcpPrompt"] = req.Params.Name + metricTarget = req.Params.Name + } + case mcp.MethodToolsCall: + if req, ok := message.(*mcp.CallToolRequest); ok { + spanNameSuffix = fmt.Sprintf("%s %s", spanNameSuffix, req.Params.Name) + spanAttributes = append(spanAttributes, attribute.String("mcp.tool", req.Params.Name)) + logFields["mcpTool"] = req.Params.Name + metricTarget = req.Params.Name + } + } + + if rwSpan, ok := fsc.CtxRootSpan(ctx).(otelsdktrace.ReadWriteSpan); ok { + rwSpan.SetName(fmt.Sprintf("%s %s", rwSpan.Name(), spanNameSuffix)) + rwSpan.RecordError(err) + rwSpan.SetStatus(codes.Error, errMessage) + rwSpan.SetAttributes(spanAttributes...) + rwSpan.End() + } + + log.CtxLogger(ctx).Error().Fields(logFields).Msg("MCP request error") + + if metricsEnabled { + p.requestsCounter.WithLabelValues(mcpMethod, metricTarget, "error").Inc() + p.requestsDuration.WithLabelValues(mcpMethod, metricTarget).Observe(latency.Seconds()) + } + }) + + return hooks +} diff --git a/fxmcpserver/server/provider_test.go b/fxmcpserver/server/provider_test.go new file mode 100644 index 00000000..e50a4114 --- /dev/null +++ b/fxmcpserver/server/provider_test.go @@ -0,0 +1,28 @@ +package server_test + +import ( + "testing" + + "github.com/ankorstore/yokai/config" + fs "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/mark3labs/mcp-go/server" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" +) + +func TestDefaultMCPServerHooksProvider_Provide(t *testing.T) { + t.Parallel() + + reg := prometheus.NewRegistry() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../testdata/config"), + ) + assert.NoError(t, err) + + pro := fs.NewDefaultMCPServerHooksProvider(reg, cfg) + + hooks := pro.Provide() + + assert.IsType(t, (*server.Hooks)(nil), hooks) +} diff --git a/fxmcpserver/server/registry.go b/fxmcpserver/server/registry.go new file mode 100644 index 00000000..209de5f1 --- /dev/null +++ b/fxmcpserver/server/registry.go @@ -0,0 +1,182 @@ +package server + +import ( + "github.com/ankorstore/yokai/config" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// MCPServerTool is the interface for MCP server tools. +type MCPServerTool interface { + Name() string + Options() []mcp.ToolOption + Handle() server.ToolHandlerFunc +} + +// MCPServerPrompt is the interface for MCP server prompts. +type MCPServerPrompt interface { + Name() string + Options() []mcp.PromptOption + Handle() server.PromptHandlerFunc +} + +// MCPServerResource is the interface for MCP server resources. +type MCPServerResource interface { + Name() string + URI() string + Options() []mcp.ResourceOption + Handle() server.ResourceHandlerFunc +} + +// MCPServerResourceTemplate is the interface for MCP server resource templates. +type MCPServerResourceTemplate interface { + Name() string + URI() string + Options() []mcp.ResourceTemplateOption + Handle() server.ResourceTemplateHandlerFunc +} + +// MCPServerRegistryInfo is the information of the MCPServerRegistry. +type MCPServerRegistryInfo struct { + Capabilities struct { + Tools bool + Prompts bool + Resources bool + } + Registrations struct { + Tools map[string]string + Prompts map[string]string + Resources map[string]string + ResourceTemplates map[string]string + } +} + +// MCPServerRegistry is the registry for MCP tools, prompts, resources and resource templates. +type MCPServerRegistry struct { + config *config.Config + tools map[string]MCPServerTool + prompts map[string]MCPServerPrompt + resources map[string]MCPServerResource + resourceTemplates map[string]MCPServerResourceTemplate +} + +// NewMCPServerRegistry returns a new MCPServerRegistry instance. +func NewMCPServerRegistry( + config *config.Config, + tools []MCPServerTool, + prompts []MCPServerPrompt, + resources []MCPServerResource, + resourceTemplates []MCPServerResourceTemplate, +) *MCPServerRegistry { + toolsMap := make(map[string]MCPServerTool, len(tools)) + promptsMap := make(map[string]MCPServerPrompt, len(prompts)) + resourcesMap := make(map[string]MCPServerResource, len(resources)) + resourceTemplatesMap := make(map[string]MCPServerResourceTemplate, len(resourceTemplates)) + + for _, tool := range tools { + toolsMap[tool.Name()] = tool + } + + for _, prompt := range prompts { + promptsMap[prompt.Name()] = prompt + } + + for _, resource := range resources { + resourcesMap[resource.Name()] = resource + } + + for _, resourceTemplate := range resourceTemplates { + resourceTemplatesMap[resourceTemplate.Name()] = resourceTemplate + } + + return &MCPServerRegistry{ + config: config, + tools: toolsMap, + prompts: promptsMap, + resources: resourcesMap, + resourceTemplates: resourceTemplatesMap, + } +} + +// Register registers MCP tools, prompts, resources and resource templates on a provided MCPServer instance. +func (r *MCPServerRegistry) Register(mcpServer *server.MCPServer) { + if r.config.GetBool("modules.mcp.server.capabilities.tools") { + for _, tool := range r.tools { + mcpServer.AddTool( + mcp.NewTool(tool.Name(), tool.Options()...), + tool.Handle(), + ) + } + } + + if r.config.GetBool("modules.mcp.server.capabilities.prompts") { + for _, prompt := range r.prompts { + mcpServer.AddPrompt( + mcp.NewPrompt(prompt.Name(), prompt.Options()...), + prompt.Handle(), + ) + } + } + + if r.config.GetBool("modules.mcp.server.capabilities.resources") { + for _, resource := range r.resources { + mcpServer.AddResource( + mcp.NewResource(resource.URI(), resource.Name(), resource.Options()...), + resource.Handle(), + ) + } + + for _, resourceTemplate := range r.resourceTemplates { + mcpServer.AddResourceTemplate( + mcp.NewResourceTemplate(resourceTemplate.URI(), resourceTemplate.Name(), resourceTemplate.Options()...), + resourceTemplate.Handle(), + ) + } + } +} + +// Info returns information about the capabilities and the registered MCP tools, prompts, resources and resource templates. +func (r *MCPServerRegistry) Info() MCPServerRegistryInfo { + toolsInfo := make(map[string]string, len(r.tools)) + for _, tool := range r.tools { + toolsInfo[tool.Name()] = FuncName(tool.Handle()) + } + + promptsInfo := make(map[string]string, len(r.prompts)) + for _, prompt := range r.prompts { + promptsInfo[prompt.Name()] = FuncName(prompt.Handle()) + } + + resourcesInfo := make(map[string]string, len(r.resources)) + for _, resource := range r.resources { + resourcesInfo[resource.Name()] = FuncName(resource.Handle()) + } + + resourceTemplatesInfo := make(map[string]string, len(r.resourceTemplates)) + for _, resourceTemplate := range r.resourceTemplates { + resourceTemplatesInfo[resourceTemplate.Name()] = FuncName(resourceTemplate.Handle()) + } + + return MCPServerRegistryInfo{ + Capabilities: struct { + Tools bool + Prompts bool + Resources bool + }{ + Tools: r.config.GetBool("modules.mcp.server.capabilities.tools"), + Prompts: r.config.GetBool("modules.mcp.server.capabilities.prompts"), + Resources: r.config.GetBool("modules.mcp.server.capabilities.resources"), + }, + Registrations: struct { + Tools map[string]string + Prompts map[string]string + Resources map[string]string + ResourceTemplates map[string]string + }{ + Tools: toolsInfo, + Prompts: promptsInfo, + Resources: resourcesInfo, + ResourceTemplates: resourceTemplatesInfo, + }, + } +} diff --git a/fxmcpserver/server/registry_test.go b/fxmcpserver/server/registry_test.go new file mode 100644 index 00000000..7df796e1 --- /dev/null +++ b/fxmcpserver/server/registry_test.go @@ -0,0 +1,70 @@ +package server_test + +import ( + "testing" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" + "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" + "github.com/stretchr/testify/assert" +) + +func TestMCPServerRegistry_Info(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../testdata/config"), + ) + assert.NoError(t, err) + + reg := server.NewMCPServerRegistry( + cfg, + []server.MCPServerTool{ + tool.NewTestTool(), + }, + []server.MCPServerPrompt{ + prompt.NewTestPrompt(), + }, + []server.MCPServerResource{ + resource.NewTestResource(), + }, + []server.MCPServerResourceTemplate{ + resource.NewTestResourceTemplate(), + }, + ) + + expectedInfo := server.MCPServerRegistryInfo{ + Capabilities: struct { + Tools bool + Prompts bool + Resources bool + }{ + Tools: true, + Prompts: true, + Resources: true, + }, + Registrations: struct { + Tools map[string]string + Prompts map[string]string + Resources map[string]string + ResourceTemplates map[string]string + }{ + Tools: map[string]string{ + "test-tool": "github.com/ankorstore/yokai/fxmcpserver/testdata/tool.(*TestTool).Handle.func1", + }, + Prompts: map[string]string{ + "test-prompt": "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt.(*TestPrompt).Handle.func1", + }, + Resources: map[string]string{ + "test-resource": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*TestResource).Handle.func1", + }, + ResourceTemplates: map[string]string{ + "test-template": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*TestResourceTemplate).Handle.func1", + }, + }, + } + + assert.Equal(t, expectedInfo, reg.Info()) +} diff --git a/fxmcpserver/server/sse/context.go b/fxmcpserver/server/sse/context.go new file mode 100644 index 00000000..eb3afe59 --- /dev/null +++ b/fxmcpserver/server/sse/context.go @@ -0,0 +1,94 @@ +package sse + +import ( + "context" + "net/http" + "time" + + fsc "github.com/ankorstore/yokai/fxmcpserver/server/context" + "github.com/ankorstore/yokai/generate/uuid" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/trace" + "github.com/mark3labs/mcp-go/server" + "go.opentelemetry.io/otel/attribute" + ot "go.opentelemetry.io/otel/trace" +) + +var _ MCPSSEServerContextHandler = (*DefaultMCPSSEServerContextHandler)(nil) + +// MCPSSEServerContextHandler is the interface for MCP SSE server context handlers. +type MCPSSEServerContextHandler interface { + Handle() server.SSEContextFunc +} + +// DefaultMCPSSEServerContextHandler is the default MCPSSEServerContextHandler implementation. +type DefaultMCPSSEServerContextHandler struct { + generator uuid.UuidGenerator + tracerProvider ot.TracerProvider + logger *log.Logger +} + +// NewDefaultMCPSSEServerContextHandler returns a new DefaultMCPSSEServerContextHandler instance. +func NewDefaultMCPSSEServerContextHandler( + generator uuid.UuidGenerator, + tracerProvider ot.TracerProvider, + logger *log.Logger, +) *DefaultMCPSSEServerContextHandler { + return &DefaultMCPSSEServerContextHandler{ + generator: generator, + tracerProvider: tracerProvider, + logger: logger, + } +} + +// Handle returns the handler func. +func (h *DefaultMCPSSEServerContextHandler) Handle() server.SSEContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + // start time propagation + ctx = fsc.WithStartTime(ctx, time.Now()) + + // sessionId propagation + sID := r.URL.Query().Get("sessionId") + + ctx = fsc.WithSessionID(ctx, sID) + + // requestId propagation + rID := r.Header.Get("X-Request-Id") + + if rID == "" { + rID = h.generator.Generate() + r.Header.Set("X-Request-Id", rID) + } + + ctx = fsc.WithRequestID(ctx, rID) + + // tracer propagation + ctx = trace.WithContext(ctx, h.tracerProvider) + + ctx, span := trace.CtxTracer(ctx).Start( + ctx, + "MCP", + ot.WithNewRoot(), + ot.WithSpanKind(ot.SpanKindServer), + ot.WithAttributes( + attribute.String("system", "mcpserver"), + attribute.String("mcp.transport", "sse"), + attribute.String("mcp.sessionID", sID), + attribute.String("mcp.requestID", rID), + ), + ) + + ctx = fsc.WithRootSpan(ctx, span) + + // logger propagation + logger := h.logger. + With(). + Str("system", "mcpserver"). + Str("mcpTransport", "sse"). + Str("mcpSessionID", sID). + Str("mcpRequestID", rID). + Logger() + + return logger.WithContext(ctx) + } +} diff --git a/fxmcpserver/server/sse/context_test.go b/fxmcpserver/server/sse/context_test.go new file mode 100644 index 00000000..0dcab3e0 --- /dev/null +++ b/fxmcpserver/server/sse/context_test.go @@ -0,0 +1,139 @@ +package sse_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + servercontext "github.com/ankorstore/yokai/fxmcpserver/server/context" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/log/logtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "go.opentelemetry.io/otel/sdk/trace" +) + +type generatorMock struct { + mock.Mock +} + +func (m *generatorMock) Generate() string { + return m.Called().String(0) +} + +func TestDefaultMCPSSEServerContextHandler_Handle(t *testing.T) { + t.Parallel() + + t.Run("with provided session id and request id", func(t *testing.T) { + t.Parallel() + + gm := new(generatorMock) + gm.AssertNotCalled(t, "Generate") + + tp := trace.NewTracerProvider() + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + handler := sse.NewDefaultMCPSSEServerContextHandler(gm, tp, lg) + + req := httptest.NewRequest(http.MethodGet, "/sse?sessionId=test-session-id", nil) + req.Header.Set("X-Request-Id", "test-request-id") + + ctx := handler.Handle()(context.Background(), req) + + assert.Equal(t, "test-session-id", servercontext.CtxSessionID(ctx)) + assert.Equal(t, "test-request-id", servercontext.CtxRequestId(ctx)) + + span, ok := servercontext.CtxRootSpan(ctx).(trace.ReadWriteSpan) + assert.True(t, ok) + + assert.Equal(t, "MCP", span.Name()) + + for _, attr := range span.Attributes() { + if attr.Key == "system" { + assert.Equal(t, "mcpserver", attr.Value.AsString()) + } + if attr.Key == "mcp.transport" { + assert.Equal(t, "sse", attr.Value.AsString()) + } + if attr.Key == "mcp.sessionID" { + assert.Equal(t, "test-session-id", attr.Value.AsString()) + } + if attr.Key == "mcp.requestID" { + assert.Equal(t, "test-request-id", attr.Value.AsString()) + } + } + + log.CtxLogger(ctx).Info().Msg("test log") + + logtest.AssertHasLogRecord(t, lb, map[string]any{ + "level": "info", + "system": "mcpserver", + "mcpTransport": "sse", + "mcpSessionID": "test-session-id", + "mcpRequestID": "test-request-id", + "message": "test log", + }) + + gm.AssertExpectations(t) + }) + + t.Run("without provided session id and request id", func(t *testing.T) { + t.Parallel() + + gm := new(generatorMock) + gm.On("Generate").Return("test-request-id") + + tp := trace.NewTracerProvider() + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + handler := sse.NewDefaultMCPSSEServerContextHandler(gm, tp, lg) + + req := httptest.NewRequest(http.MethodGet, "/sse", nil) + + ctx := handler.Handle()(context.Background(), req) + + assert.Equal(t, "", servercontext.CtxSessionID(ctx)) + assert.Equal(t, "test-request-id", servercontext.CtxRequestId(ctx)) + + span, ok := servercontext.CtxRootSpan(ctx).(trace.ReadWriteSpan) + assert.True(t, ok) + + assert.Equal(t, "MCP", span.Name()) + + for _, attr := range span.Attributes() { + if attr.Key == "system" { + assert.Equal(t, "mcpserver", attr.Value.AsString()) + } + if attr.Key == "mcp.transport" { + assert.Equal(t, "sse", attr.Value.AsString()) + } + if attr.Key == "mcp.sessionID" { + assert.Equal(t, "", attr.Value.AsString()) + } + if attr.Key == "mcp.requestID" { + assert.Equal(t, "test-request-id", attr.Value.AsString()) + } + } + + log.CtxLogger(ctx).Info().Msg("test log") + + logtest.AssertHasLogRecord(t, lb, map[string]any{ + "level": "info", + "system": "mcpserver", + "mcpTransport": "sse", + "mcpSessionID": "", + "mcpRequestID": "test-request-id", + "message": "test log", + }) + + gm.AssertExpectations(t) + }) +} diff --git a/fxmcpserver/server/sse/factory.go b/fxmcpserver/server/sse/factory.go new file mode 100644 index 00000000..beccbac2 --- /dev/null +++ b/fxmcpserver/server/sse/factory.go @@ -0,0 +1,97 @@ +package sse + +import ( + "time" + + "github.com/ankorstore/yokai/config" + "github.com/mark3labs/mcp-go/server" +) + +const ( + DefaultAddr = ":8082" + DefaultBaseURL = "" + DefaultBasePath = "" + DefaultSSEEndpoint = "/sse" + DefaultMessageEndpoint = "/message" + DefaultKeepAliveInterval = 10 * time.Second +) + +var _ MCPSSEServerFactory = (*DefaultMCPSSEServerFactory)(nil) + +// MCPSSEServerFactory is the interface for MCP SSE server factories. +type MCPSSEServerFactory interface { + Create(mcpServer *server.MCPServer, options ...server.SSEOption) *MCPSSEServer +} + +// DefaultMCPSSEServerFactory is the default MCPSSEServerFactory implementation. +type DefaultMCPSSEServerFactory struct { + config *config.Config +} + +// NewDefaultMCPSSEServerFactory returns a new DefaultMCPSSEServerFactory instance. +func NewDefaultMCPSSEServerFactory(config *config.Config) *DefaultMCPSSEServerFactory { + return &DefaultMCPSSEServerFactory{ + config: config, + } +} + +// Create returns a new MCPSSEServer instance. +func (f *DefaultMCPSSEServerFactory) Create(mcpServer *server.MCPServer, options ...server.SSEOption) *MCPSSEServer { + addr := f.config.GetString("modules.mcp.server.transport.sse.address") + if addr == "" { + addr = DefaultAddr + } + + baseURL := f.config.GetString("modules.mcp.server.transport.sse.base_url") + if baseURL == "" { + baseURL = DefaultBaseURL + } + + basePath := f.config.GetString("modules.mcp.server.transport.sse.base_path") + if basePath == "" { + basePath = DefaultBasePath + } + + sseEndpoint := f.config.GetString("modules.mcp.server.transport.sse.sse_endpoint") + if sseEndpoint == "" { + sseEndpoint = DefaultSSEEndpoint + } + + messageEndpoint := f.config.GetString("modules.mcp.server.transport.sse.message_endpoint") + if messageEndpoint == "" { + messageEndpoint = DefaultMessageEndpoint + } + + keepAlive := f.config.GetBool("modules.mcp.server.transport.sse.keep_alive") + + keepAliveInterval := DefaultKeepAliveInterval + keepAliveIntervalConfig := f.config.GetInt("modules.mcp.server.transport.sse.keep_alive_interval") + if keepAliveIntervalConfig != 0 { + keepAliveInterval = time.Duration(keepAliveIntervalConfig) * time.Second + } + + srvConfig := MCPSSEServerConfig{ + Address: addr, + BaseURL: baseURL, + BasePath: basePath, + SSEEndpoint: sseEndpoint, + MessageEndpoint: messageEndpoint, + KeepAlive: keepAlive, + KeepAliveInterval: keepAliveInterval, + } + + srvOptions := []server.SSEOption{ + server.WithBaseURL(srvConfig.BaseURL), + server.WithBasePath(srvConfig.BasePath), + server.WithSSEEndpoint(srvConfig.SSEEndpoint), + server.WithMessageEndpoint(srvConfig.MessageEndpoint), + } + + if srvConfig.KeepAlive { + srvOptions = append(srvOptions, server.WithKeepAliveInterval(srvConfig.KeepAliveInterval)) + } + + srvOptions = append(srvOptions, options...) + + return NewMCPSSEServer(mcpServer, srvConfig, srvOptions...) +} diff --git a/fxmcpserver/server/sse/factory_test.go b/fxmcpserver/server/sse/factory_test.go new file mode 100644 index 00000000..b4d2b587 --- /dev/null +++ b/fxmcpserver/server/sse/factory_test.go @@ -0,0 +1,37 @@ +package sse_test + +import ( + "testing" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestDefaultMCPSSEServerFactory_Create(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../../testdata/config"), + ) + assert.NoError(t, err) + + mcpSrv := &server.MCPServer{} + + fac := sse.NewDefaultMCPSSEServerFactory(cfg) + + srv := fac.Create(mcpSrv) + + assert.IsType(t, (*server.SSEServer)(nil), srv.Server()) + + assert.Equal(t, ":0", srv.Config().Address) + assert.Equal(t, sse.DefaultBaseURL, srv.Config().BaseURL) + assert.Equal(t, sse.DefaultBasePath, srv.Config().BasePath) + assert.Equal(t, sse.DefaultSSEEndpoint, srv.Config().SSEEndpoint) + assert.Equal(t, sse.DefaultMessageEndpoint, srv.Config().MessageEndpoint) + assert.True(t, srv.Config().KeepAlive) + assert.Equal(t, sse.DefaultKeepAliveInterval, srv.Config().KeepAliveInterval) + + assert.False(t, srv.Running()) +} diff --git a/fxmcpserver/server/sse/server.go b/fxmcpserver/server/sse/server.go new file mode 100644 index 00000000..c419d419 --- /dev/null +++ b/fxmcpserver/server/sse/server.go @@ -0,0 +1,111 @@ +package sse + +import ( + "context" + "sync" + "time" + + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/server" +) + +// MCPSSEServerConfig is the MCP SSE server configuration. +type MCPSSEServerConfig struct { + Address string + BaseURL string + BasePath string + SSEEndpoint string + MessageEndpoint string + KeepAlive bool + KeepAliveInterval time.Duration +} + +// MCPSSEServer is the MCP SSE server. +type MCPSSEServer struct { + server *server.SSEServer + config MCPSSEServerConfig + mutex sync.RWMutex + running bool +} + +// NewMCPSSEServer returns a new MCPSSEServer instance. +func NewMCPSSEServer(mcpServer *server.MCPServer, config MCPSSEServerConfig, opts ...server.SSEOption) *MCPSSEServer { + return &MCPSSEServer{ + server: server.NewSSEServer(mcpServer, opts...), + config: config, + } +} + +// Server returns the MCPSSEServer underlying server. +func (s *MCPSSEServer) Server() *server.SSEServer { + return s.server +} + +// Config returns the MCPSSEServer config. +func (s *MCPSSEServer) Config() MCPSSEServerConfig { + return s.config +} + +// Start starts the MCPSSEServer. +func (s *MCPSSEServer) Start(ctx context.Context) error { + logger := log.CtxLogger(ctx) + + logger.Info().Msgf("starting MCP SSE server on %s", s.config.Address) + + s.mutex.Lock() + s.running = true + s.mutex.Unlock() + + err := s.server.Start(s.config.Address) + if err != nil { + logger.Error().Err(err).Msgf("failed to start MCP SSE server") + + s.running = false + } + + return err +} + +// Stop stops the MCPSSEServer. +func (s *MCPSSEServer) Stop(ctx context.Context) error { + logger := log.CtxLogger(ctx) + + logger.Info().Msg("stopping MCP SSE server") + + s.mutex.Lock() + s.running = false + s.mutex.Unlock() + + err := s.server.Shutdown(ctx) + if err != nil { + logger.Error().Err(err).Msgf("failed to stop MCP SSE server") + } + + return err +} + +// Running returns true if the MCPSSEServer is running. +func (s *MCPSSEServer) Running() bool { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.running +} + +// Info returns the MCPSSEServer information. +func (s *MCPSSEServer) Info() map[string]any { + return map[string]any{ + "config": map[string]any{ + "address": s.config.Address, + "base_url": s.config.BaseURL, + "base_path": s.config.BasePath, + "sse_endpoint": s.config.SSEEndpoint, + "message_endpoint": s.config.MessageEndpoint, + "keep_alive": s.config.KeepAlive, + "keep_alive_interval": s.config.KeepAliveInterval.Seconds(), + }, + "status": map[string]any{ + "running": s.running, + }, + } +} diff --git a/fxmcpserver/server/sse/server_test.go b/fxmcpserver/server/sse/server_test.go new file mode 100644 index 00000000..66473afd --- /dev/null +++ b/fxmcpserver/server/sse/server_test.go @@ -0,0 +1,82 @@ +package sse_test + +import ( + "context" + "testing" + "time" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/log/logtest" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestMCPSSEServer(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../../testdata/config"), + ) + assert.NoError(t, err) + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + mcpSrv := server.NewMCPServer("test-server", "1.0.0") + + srv := sse.NewDefaultMCPSSEServerFactory(cfg).Create(mcpSrv) + + assert.False(t, srv.Running()) + + assert.Equal( + t, + map[string]any{ + "config": map[string]any{ + "address": ":0", + "base_url": sse.DefaultBaseURL, + "base_path": sse.DefaultBasePath, + "sse_endpoint": sse.DefaultSSEEndpoint, + "message_endpoint": sse.DefaultMessageEndpoint, + "keep_alive": true, + "keep_alive_interval": sse.DefaultKeepAliveInterval.Seconds(), + }, + "status": map[string]any{ + "running": false, + }, + }, + srv.Info(), + ) + + ctx := lg.WithContext(context.Background()) + + go func(fCtx context.Context) { + fErr := srv.Start(fCtx) + assert.NoError(t, fErr) + }(ctx) + + time.Sleep(1 * time.Millisecond) + + assert.True(t, srv.Running()) + + logtest.AssertHasLogRecord(t, lb, map[string]any{ + "level": "info", + "message": "starting MCP SSE server on :0", + }) + + go func(fCtx context.Context) { + fErr := srv.Stop(fCtx) + assert.NoError(t, fErr) + }(ctx) + + time.Sleep(1 * time.Millisecond) + + assert.False(t, srv.Running()) + + logtest.AssertHasLogRecord(t, lb, map[string]any{ + "level": "info", + "message": "stopping MCP SSE server", + }) +} diff --git a/fxmcpserver/server/stdio/context.go b/fxmcpserver/server/stdio/context.go new file mode 100644 index 00000000..aef37e02 --- /dev/null +++ b/fxmcpserver/server/stdio/context.go @@ -0,0 +1,81 @@ +package stdio + +import ( + "context" + "time" + + fsc "github.com/ankorstore/yokai/fxmcpserver/server/context" + "github.com/ankorstore/yokai/generate/uuid" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/trace" + "github.com/mark3labs/mcp-go/server" + "go.opentelemetry.io/otel/attribute" + ot "go.opentelemetry.io/otel/trace" +) + +var _ MCPStdioServerContextHandler = (*DefaultMCPStdioServerContextHandler)(nil) + +// MCPStdioServerContextHandler is the interface for MCP Stdio server context handlers. +type MCPStdioServerContextHandler interface { + Handle() server.StdioContextFunc +} + +// DefaultMCPStdioServerContextHandler is the default MCPStdioServerContextHandler implementation. +type DefaultMCPStdioServerContextHandler struct { + generator uuid.UuidGenerator + tracerProvider ot.TracerProvider + logger *log.Logger +} + +// NewDefaultMCPStdioServerContextHandler returns a new DefaultMCPStdioServerContextHandler instance. +func NewDefaultMCPStdioServerContextHandler( + generator uuid.UuidGenerator, + tracerProvider ot.TracerProvider, + logger *log.Logger, +) *DefaultMCPStdioServerContextHandler { + return &DefaultMCPStdioServerContextHandler{ + generator: generator, + tracerProvider: tracerProvider, + logger: logger, + } +} + +// Handle returns the handler func. +func (h *DefaultMCPStdioServerContextHandler) Handle() server.StdioContextFunc { + return func(ctx context.Context) context.Context { + // start time propagation + ctx = fsc.WithStartTime(ctx, time.Now()) + + // requestId propagation + rID := h.generator.Generate() + + ctx = fsc.WithRequestID(ctx, rID) + + // tracer propagation + ctx = trace.WithContext(ctx, h.tracerProvider) + + ctx, span := trace.CtxTracer(ctx).Start( + ctx, + "MCP", + ot.WithNewRoot(), + ot.WithSpanKind(ot.SpanKindServer), + ot.WithAttributes( + attribute.String("system", "mcpserver"), + attribute.String("mcp.transport", "stdio"), + attribute.String("mcp.requestID", rID), + ), + ) + + ctx = fsc.WithRootSpan(ctx, span) + + // logger propagation + logger := h.logger. + With(). + Str("system", "mcpserver"). + Str("mcpTransport", "stdio"). + Str("mcpRequestID", rID). + Logger() + + return logger.WithContext(ctx) + } +} diff --git a/fxmcpserver/server/stdio/context_test.go b/fxmcpserver/server/stdio/context_test.go new file mode 100644 index 00000000..7b3a2c48 --- /dev/null +++ b/fxmcpserver/server/stdio/context_test.go @@ -0,0 +1,70 @@ +package stdio_test + +import ( + "context" + "testing" + + servercontext "github.com/ankorstore/yokai/fxmcpserver/server/context" + "github.com/ankorstore/yokai/fxmcpserver/server/stdio" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/log/logtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "go.opentelemetry.io/otel/sdk/trace" +) + +type generatorMock struct { + mock.Mock +} + +func (m *generatorMock) Generate() string { + return m.Called().String(0) +} + +func TestDefaultMCPStdioServerContextHandler_Handle(t *testing.T) { + t.Parallel() + + gm := new(generatorMock) + gm.On("Generate").Return("test-request-id") + + tp := trace.NewTracerProvider() + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + handler := stdio.NewDefaultMCPStdioServerContextHandler(gm, tp, lg) + + ctx := handler.Handle()(context.Background()) + + assert.Equal(t, "test-request-id", servercontext.CtxRequestId(ctx)) + + span, ok := servercontext.CtxRootSpan(ctx).(trace.ReadWriteSpan) + assert.True(t, ok) + + assert.Equal(t, "MCP", span.Name()) + + for _, attr := range span.Attributes() { + if attr.Key == "system" { + assert.Equal(t, "mcpserver", attr.Value.AsString()) + } + if attr.Key == "mcp.transport" { + assert.Equal(t, "stdio", attr.Value.AsString()) + } + if attr.Key == "mcp.requestID" { + assert.Equal(t, "test-request-id", attr.Value.AsString()) + } + } + + log.CtxLogger(ctx).Info().Msg("test log") + + logtest.AssertHasLogRecord(t, lb, map[string]any{ + "level": "info", + "system": "mcpserver", + "mcpTransport": "stdio", + "mcpRequestID": "test-request-id", + "message": "test log", + }) + + gm.AssertExpectations(t) +} diff --git a/fxmcpserver/server/stdio/factory.go b/fxmcpserver/server/stdio/factory.go new file mode 100644 index 00000000..509b0f5a --- /dev/null +++ b/fxmcpserver/server/stdio/factory.go @@ -0,0 +1,32 @@ +package stdio + +import ( + "os" + + "github.com/mark3labs/mcp-go/server" +) + +var _ MCPStdioServerFactory = (*DefaultMCPStdioServerFactory)(nil) + +// MCPStdioServerFactory is the interface for MCP Stdio server factories. +type MCPStdioServerFactory interface { + Create(mcpServer *server.MCPServer, options ...server.StdioOption) *MCPStdioServer +} + +// DefaultMCPStdioServerFactory is the default MCPStdioServerFactory implementation. +type DefaultMCPStdioServerFactory struct{} + +// NewDefaultMCPStdioServerFactory returns a new DefaultMCPStdioServerFactory instance. +func NewDefaultMCPStdioServerFactory() *DefaultMCPStdioServerFactory { + return &DefaultMCPStdioServerFactory{} +} + +// Create returns a new MCPStdioServer instance. +func (f *DefaultMCPStdioServerFactory) Create(mcpServer *server.MCPServer, options ...server.StdioOption) *MCPStdioServer { + srvConfig := MCPStdioServerConfig{ + In: os.Stdin, + Out: os.Stdout, + } + + return NewMCPStdioServer(mcpServer, srvConfig, options...) +} diff --git a/fxmcpserver/server/stdio/factory_test.go b/fxmcpserver/server/stdio/factory_test.go new file mode 100644 index 00000000..d8bdc13c --- /dev/null +++ b/fxmcpserver/server/stdio/factory_test.go @@ -0,0 +1,27 @@ +package stdio_test + +import ( + "os" + "testing" + + "github.com/ankorstore/yokai/fxmcpserver/server/stdio" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestDefaultMCPStdioServerFactory_Create(t *testing.T) { + t.Parallel() + + mcpSrv := &server.MCPServer{} + + fac := stdio.NewDefaultMCPStdioServerFactory() + + srv := fac.Create(mcpSrv) + + assert.IsType(t, (*server.StdioServer)(nil), srv.Server()) + + assert.Equal(t, os.Stdin, srv.Config().In) + assert.Equal(t, os.Stdout, srv.Config().Out) + + assert.False(t, srv.Running()) +} diff --git a/fxmcpserver/server/stdio/server.go b/fxmcpserver/server/stdio/server.go new file mode 100644 index 00000000..852e8125 --- /dev/null +++ b/fxmcpserver/server/stdio/server.go @@ -0,0 +1,85 @@ +package stdio + +import ( + "context" + "io" + "sync" + + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/server" +) + +// MCPStdioServerConfig is the MCP Stdio server configuration. +type MCPStdioServerConfig struct { + In io.Reader + Out io.Writer +} + +// MCPStdioServer is the MCP Stdio server. +type MCPStdioServer struct { + server *server.StdioServer + config MCPStdioServerConfig + mutex sync.RWMutex + running bool +} + +// NewMCPStdioServer returns a new MCPStdioServer instance. +func NewMCPStdioServer(mcpServer *server.MCPServer, config MCPStdioServerConfig, opts ...server.StdioOption) *MCPStdioServer { + stdioServer := server.NewStdioServer(mcpServer) + + for _, opt := range opts { + opt(stdioServer) + } + + return &MCPStdioServer{ + server: stdioServer, + config: config, + } +} + +// Server returns the MCPStdioServer underlying server. +func (s *MCPStdioServer) Server() *server.StdioServer { + return s.server +} + +// Config returns the MCPStdioServer config. +func (s *MCPStdioServer) Config() MCPStdioServerConfig { + return s.config +} + +// Start starts the MCPStdioServer. +func (s *MCPStdioServer) Start(ctx context.Context) error { + logger := log.CtxLogger(ctx) + + logger.Info().Msg("starting MCP Stdio server") + + s.mutex.Lock() + s.running = true + s.mutex.Unlock() + + err := s.server.Listen(ctx, s.config.In, s.config.Out) + if err != nil { + logger.Error().Err(err).Msgf("failed to start MCP Stdio server") + + s.running = false + } + + return err +} + +// Running returns true if the MCPStdioServer is running. +func (s *MCPStdioServer) Running() bool { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.running +} + +// Info returns the MCPStdioServer information. +func (s *MCPStdioServer) Info() map[string]any { + return map[string]any{ + "status": map[string]any{ + "running": s.running, + }, + } +} diff --git a/fxmcpserver/server/stdio/server_test.go b/fxmcpserver/server/stdio/server_test.go new file mode 100644 index 00000000..e3e56069 --- /dev/null +++ b/fxmcpserver/server/stdio/server_test.go @@ -0,0 +1,53 @@ +package stdio_test + +import ( + "context" + "testing" + "time" + + "github.com/ankorstore/yokai/fxmcpserver/server/stdio" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/log/logtest" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestMCPStdioServer(t *testing.T) { + t.Parallel() + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + mcpSrv := server.NewMCPServer("test-server", "1.0.0") + + srv := stdio.NewDefaultMCPStdioServerFactory().Create(mcpSrv) + + assert.False(t, srv.Running()) + + assert.Equal( + t, + map[string]any{ + "status": map[string]any{ + "running": false, + }, + }, + srv.Info(), + ) + + ctx := lg.WithContext(context.Background()) + + go func(fCtx context.Context) { + fErr := srv.Start(fCtx) + assert.NoError(t, fErr) + }(ctx) + + time.Sleep(1 * time.Millisecond) + + assert.True(t, srv.Running()) + + logtest.AssertHasLogRecord(t, lb, map[string]any{ + "level": "info", + "message": "starting MCP Stdio server", + }) +} diff --git a/fxmcpserver/server/util.go b/fxmcpserver/server/util.go new file mode 100644 index 00000000..3defc884 --- /dev/null +++ b/fxmcpserver/server/util.go @@ -0,0 +1,36 @@ +package server + +import ( + "reflect" + "runtime" + "strings" +) + +// FuncName returns a readable func name for code browsing purposes +func FuncName(f any) string { + return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() +} + +// Sanitize transforms a given string to not contain spaces or dashes, and to be in lower case. +func Sanitize(str string) string { + san := strings.ReplaceAll(str, " ", "_") + san = strings.ReplaceAll(san, "-", "_") + + return strings.ToLower(san) +} + +// Split trims and splits a provided string by comma. +func Split(str string) []string { + return strings.Split(strings.ReplaceAll(str, " ", ""), ",") +} + +// Contain returns true if a given string can be found in a given slice of strings. +func Contain(list []string, item string) bool { + for _, i := range list { + if strings.ToLower(i) == strings.ToLower(item) { + return true + } + } + + return false +} diff --git a/fxmcpserver/server/util_test.go b/fxmcpserver/server/util_test.go new file mode 100644 index 00000000..456bb58b --- /dev/null +++ b/fxmcpserver/server/util_test.go @@ -0,0 +1,49 @@ +package server_test + +import ( + "testing" + + "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/stretchr/testify/assert" +) + +func TestFuncName(t *testing.T) { + t.Parallel() + + fn := func() {} + + assert.Equal(t, "github.com/ankorstore/yokai/fxmcpserver/server_test.TestFuncName.func1", server.FuncName(fn)) +} + +func TestSanitize(t *testing.T) { + t.Parallel() + + assert.Equal(t, "foo_bar", server.Sanitize("foo-bar")) + assert.Equal(t, "foo_bar", server.Sanitize("foo bar")) + assert.Equal(t, "foo_bar", server.Sanitize("Foo-Bar")) + assert.Equal(t, "foo_bar", server.Sanitize("Foo Bar")) +} + +func TestSplit(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"1", "2", "3"}, server.Split("1,2,3")) + assert.Equal(t, []string{"1", "2", "3"}, server.Split(" 1,2,3")) + assert.Equal(t, []string{"1", "2", "3"}, server.Split("1,2,3 ")) + assert.Equal(t, []string{"1", "2", "3"}, server.Split("1, 2, 3")) + assert.Equal(t, []string{"1", "2", "3"}, server.Split(" 1, 2, 3 ")) +} + +func TestContain(t *testing.T) { + t.Parallel() + + assert.True(t, server.Contain([]string{"foo", "bar"}, "foo")) + assert.True(t, server.Contain([]string{"foo", "bar"}, "bar")) + assert.True(t, server.Contain([]string{"FOO", "bar"}, "foo")) + assert.True(t, server.Contain([]string{"foo", "BAR"}, "bar")) + assert.True(t, server.Contain([]string{"foo", "bar"}, "FOO")) + assert.True(t, server.Contain([]string{"foo", "bar"}, "BAR")) + + assert.False(t, server.Contain([]string{"foo", "bar"}, "baz")) + assert.False(t, server.Contain([]string{"foo", "bar"}, "BAZ")) +} diff --git a/fxmcpserver/testdata/config/config.yaml b/fxmcpserver/testdata/config/config.yaml new file mode 100644 index 00000000..d4f4ef1b --- /dev/null +++ b/fxmcpserver/testdata/config/config.yaml @@ -0,0 +1,41 @@ +app: + name: test + version: 0.1.0 +modules: + log: + level: debug + output: test + trace: + processor: + type: test + mcp: + server: + name: "test-server" + version: 1.0.0 + capabilities: + resources: true + prompts: true + tools: true + transport: + sse: + expose: true + address: ":0" + base_url: "" + base_path: "" + sse_endpoint: "/sse" + message_endpoint: "/message" + keep_alive: true + keep_alive_interval: 10 + stdio: + expose: true + log: + request: true + response: true + trace: + request: true + response: true + metrics: + collect: + enabled: true + namespace: foo + subsystem: bar diff --git a/fxmcpserver/testdata/prompt/prompt.go b/fxmcpserver/testdata/prompt/prompt.go new file mode 100644 index 00000000..7f59b1ee --- /dev/null +++ b/fxmcpserver/testdata/prompt/prompt.go @@ -0,0 +1,45 @@ +package prompt + +import ( + "context" + + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.opencensus.io/trace" +) + +type TestPrompt struct{} + +func NewTestPrompt() *TestPrompt { + return &TestPrompt{} +} + +func (p *TestPrompt) Name() string { + return "test-prompt" +} + +func (p *TestPrompt) Options() []mcp.PromptOption { + return []mcp.PromptOption{ + mcp.WithPromptDescription("Test prompt."), + } +} + +func (p *TestPrompt) Handle() server.PromptHandlerFunc { + return func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + ctx, span := trace.StartSpan(ctx, "TestPrompt.Handle") + defer span.End() + + log.CtxLogger(ctx).Info().Msg("TestPrompt.Handle") + + return mcp.NewGetPromptResult( + "ok", + []mcp.PromptMessage{ + mcp.NewPromptMessage( + mcp.RoleAssistant, + mcp.NewTextContent("test content"), + ), + }, + ), nil + } +} diff --git a/fxmcpserver/testdata/resource/resource.go b/fxmcpserver/testdata/resource/resource.go new file mode 100644 index 00000000..c2b80587 --- /dev/null +++ b/fxmcpserver/testdata/resource/resource.go @@ -0,0 +1,47 @@ +package resource + +import ( + "context" + + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.opencensus.io/trace" +) + +type TestResource struct{} + +func NewTestResource() *TestResource { + return &TestResource{} +} + +func (r *TestResource) Name() string { + return "test-resource" +} + +func (r *TestResource) URI() string { + return "test://resources" +} + +func (r *TestResource) Options() []mcp.ResourceOption { + return []mcp.ResourceOption{ + mcp.WithResourceDescription("Test resource."), + } +} + +func (r *TestResource) Handle() server.ResourceHandlerFunc { + return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + ctx, span := trace.StartSpan(ctx, "TestResource.Handle") + defer span.End() + + log.CtxLogger(ctx).Info().Msg("TestResource.Handle") + + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: request.Params.URI, + MIMEType: "text/plain", + Text: "ok", + }, + }, nil + } +} diff --git a/fxmcpserver/testdata/resource/template.go b/fxmcpserver/testdata/resource/template.go new file mode 100644 index 00000000..6d0ccd7b --- /dev/null +++ b/fxmcpserver/testdata/resource/template.go @@ -0,0 +1,47 @@ +package resource + +import ( + "context" + + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.opencensus.io/trace" +) + +type TestResourceTemplate struct{} + +func NewTestResourceTemplate() *TestResourceTemplate { + return &TestResourceTemplate{} +} + +func (r *TestResourceTemplate) Name() string { + return "test-template" +} + +func (r *TestResourceTemplate) URI() string { + return "test://resources/{id}" +} + +func (r *TestResourceTemplate) Options() []mcp.ResourceTemplateOption { + return []mcp.ResourceTemplateOption{ + mcp.WithTemplateDescription("Test resource template."), + } +} + +func (r *TestResourceTemplate) Handle() server.ResourceTemplateHandlerFunc { + return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + ctx, span := trace.StartSpan(ctx, "TestResourceTemplate.Handle") + defer span.End() + + log.CtxLogger(ctx).Info().Msg("TestResourceTemplate.Handle") + + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: request.Params.URI, + MIMEType: "text/plain", + Text: "ok", + }, + }, nil + } +} diff --git a/fxmcpserver/testdata/tool/tool.go b/fxmcpserver/testdata/tool/tool.go new file mode 100644 index 00000000..83328c1c --- /dev/null +++ b/fxmcpserver/testdata/tool/tool.go @@ -0,0 +1,37 @@ +package tool + +import ( + "context" + + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.opencensus.io/trace" +) + +type TestTool struct{} + +func NewTestTool() *TestTool { + return &TestTool{} +} + +func (t *TestTool) Name() string { + return "test-tool" +} + +func (t *TestTool) Options() []mcp.ToolOption { + return []mcp.ToolOption{ + mcp.WithDescription("Test tool."), + } +} + +func (t *TestTool) Handle() server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, span := trace.StartSpan(ctx, "TestTool.Handle") + defer span.End() + + log.CtxLogger(ctx).Info().Msg("TestTool.Handle") + + return mcp.NewToolResultText("ok"), nil + } +} diff --git a/release-please-config.json b/release-please-config.json index e98422b0..cb819e3b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -107,6 +107,11 @@ "component": "fxlog", "tag-separator": "/" }, + "fxmcpserver": { + "release-type": "go", + "component": "fxmcpserver", + "tag-separator": "/" + }, "fxmetrics": { "release-type": "go", "component": "fxmetrics", From 9ed40d435559291e02e2e0e7629501e1b5949a1b Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 21:34:12 +0200 Subject: [PATCH 02/11] feat(fxmcpserver): Provided module --- fxmcpserver/.golangci.yml | 2 +- fxmcpserver/module.go | 6 ++++++ fxmcpserver/server/provider.go | 4 ++++ fxmcpserver/server/sse/context_test.go | 1 + fxmcpserver/server/util.go | 13 +------------ fxmcpserver/server/util_test.go | 14 -------------- 6 files changed, 13 insertions(+), 27 deletions(-) diff --git a/fxmcpserver/.golangci.yml b/fxmcpserver/.golangci.yml index beb045a6..60d036ad 100644 --- a/fxmcpserver/.golangci.yml +++ b/fxmcpserver/.golangci.yml @@ -39,7 +39,7 @@ linters: - importas - ineffassign - interfacebloat - - logrlint + - loggercheck - maintidx - makezero - misspell diff --git a/fxmcpserver/module.go b/fxmcpserver/module.go index 049ce91a..0aadf85a 100644 --- a/fxmcpserver/module.go +++ b/fxmcpserver/module.go @@ -147,6 +147,8 @@ func ProvideDefaultMCPSSEServerFactory(p ProvideDefaultMCPServerFactoryParams) * } // ProvideMCPSSEServerParam allows injection of the required dependencies in ProvideMCPSSEServer. +// +//nolint:containedctx type ProvideMCPSSEServerParam struct { fx.In LifeCycle fx.Lifecycle @@ -168,6 +170,7 @@ func ProvideMCPSSEServer(p ProvideMCPSSEServerParam) *sse.MCPSSEServer { if p.Config.GetBool("modules.mcp.server.transport.sse.expose") { p.LifeCycle.Append(fx.Hook{ OnStart: func(context.Context) error { + //nolint:contextcheck,errcheck go sseServer.Start(p.Context) return nil @@ -200,6 +203,8 @@ func ProvideDefaultMCPStdioServerFactory() *stdio.DefaultMCPStdioServerFactory { } // ProvideMCPStdioServerParam allows injection of the required dependencies in ProvideMCPStdioServer. +// +//nolint:containedctx type ProvideMCPStdioServerParam struct { fx.In LifeCycle fx.Lifecycle @@ -221,6 +226,7 @@ func ProvideMCPStdioServer(p ProvideMCPStdioServerParam) *stdio.MCPStdioServer { if p.Config.GetBool("modules.mcp.server.transport.stdio.expose") { p.LifeCycle.Append(fx.Hook{ OnStart: func(context.Context) error { + //nolint:contextcheck,errcheck go stdioServer.Start(p.Context) return nil diff --git a/fxmcpserver/server/provider.go b/fxmcpserver/server/provider.go index 14757657..1213fe1a 100644 --- a/fxmcpserver/server/provider.go +++ b/fxmcpserver/server/provider.go @@ -85,6 +85,8 @@ func NewDefaultMCPServerHooksProvider(registry prometheus.Registerer, config *co } // Provide provides the MCP server hooks. +// +//nolint:cyclop,gocognit func (p *DefaultMCPServerHooksProvider) Provide() *server.Hooks { hooks := &server.Hooks{} @@ -141,6 +143,7 @@ func (p *DefaultMCPServerHooksProvider) Provide() *server.Hooks { } } + //nolint:exhaustive switch method { case mcp.MethodResourcesRead: if req, ok := message.(*mcp.ReadResourceRequest); ok { @@ -214,6 +217,7 @@ func (p *DefaultMCPServerHooksProvider) Provide() *server.Hooks { } } + //nolint:exhaustive switch method { case mcp.MethodResourcesRead: if req, ok := message.(*mcp.ReadResourceRequest); ok { diff --git a/fxmcpserver/server/sse/context_test.go b/fxmcpserver/server/sse/context_test.go index 0dcab3e0..8194c6db 100644 --- a/fxmcpserver/server/sse/context_test.go +++ b/fxmcpserver/server/sse/context_test.go @@ -23,6 +23,7 @@ func (m *generatorMock) Generate() string { return m.Called().String(0) } +//nolint:cyclop func TestDefaultMCPSSEServerContextHandler_Handle(t *testing.T) { t.Parallel() diff --git a/fxmcpserver/server/util.go b/fxmcpserver/server/util.go index 3defc884..ea2637bb 100644 --- a/fxmcpserver/server/util.go +++ b/fxmcpserver/server/util.go @@ -6,7 +6,7 @@ import ( "strings" ) -// FuncName returns a readable func name for code browsing purposes +// FuncName returns a readable func name for code browsing purposes. func FuncName(f any) string { return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() } @@ -23,14 +23,3 @@ func Sanitize(str string) string { func Split(str string) []string { return strings.Split(strings.ReplaceAll(str, " ", ""), ",") } - -// Contain returns true if a given string can be found in a given slice of strings. -func Contain(list []string, item string) bool { - for _, i := range list { - if strings.ToLower(i) == strings.ToLower(item) { - return true - } - } - - return false -} diff --git a/fxmcpserver/server/util_test.go b/fxmcpserver/server/util_test.go index 456bb58b..50927626 100644 --- a/fxmcpserver/server/util_test.go +++ b/fxmcpserver/server/util_test.go @@ -33,17 +33,3 @@ func TestSplit(t *testing.T) { assert.Equal(t, []string{"1", "2", "3"}, server.Split("1, 2, 3")) assert.Equal(t, []string{"1", "2", "3"}, server.Split(" 1, 2, 3 ")) } - -func TestContain(t *testing.T) { - t.Parallel() - - assert.True(t, server.Contain([]string{"foo", "bar"}, "foo")) - assert.True(t, server.Contain([]string{"foo", "bar"}, "bar")) - assert.True(t, server.Contain([]string{"FOO", "bar"}, "foo")) - assert.True(t, server.Contain([]string{"foo", "BAR"}, "bar")) - assert.True(t, server.Contain([]string{"foo", "bar"}, "FOO")) - assert.True(t, server.Contain([]string{"foo", "bar"}, "BAR")) - - assert.False(t, server.Contain([]string{"foo", "bar"}, "baz")) - assert.False(t, server.Contain([]string{"foo", "bar"}, "BAZ")) -} From ddad8202820deba66e64801e7d265132d1916011 Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 21:38:35 +0200 Subject: [PATCH 03/11] feat(fxmcpserver): Provided module --- .github/workflows/common-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/common-ci.yml b/.github/workflows/common-ci.yml index 505f6bc7..e67d6b8c 100644 --- a/.github/workflows/common-ci.yml +++ b/.github/workflows/common-ci.yml @@ -63,7 +63,7 @@ jobs: go-version: ${{ inputs.go_version }} - name: Install lint working-directory: ${{ inputs.module }} - run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.60.2 - name: Run lint working-directory: ${{ inputs.module }} run: golangci-lint run From 018626ccd3badd3b90c3fa376c00f1533e10c58b Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 21:42:40 +0200 Subject: [PATCH 04/11] feat(fxmcpserver): Provided module --- fxmcpserver/module_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go index 8b58bbaa..8816a3ae 100644 --- a/fxmcpserver/module_test.go +++ b/fxmcpserver/module_test.go @@ -37,6 +37,7 @@ func TestMCPServerModule(t *testing.T) { var logBuffer logtest.TestLogBuffer var traceExporter tracetest.TestTraceExporter var metricsRegistry *prometheus.Registry + var info *fxmcpserver.MCPServerModuleInfo fxtest.New( t, @@ -53,7 +54,7 @@ func TestMCPServerModule(t *testing.T) { fxmcpserver.AsMCPServerResources(resource.NewTestResource), fxmcpserver.AsMCPServerResourceTemplates(resource.NewTestResourceTemplate), ), - fx.Populate(&mcpServer, &handler, &logBuffer, &traceExporter, &metricsRegistry), + fx.Populate(&mcpServer, &handler, &logBuffer, &traceExporter, &metricsRegistry, &info), ).RequireStart().RequireStop() // test server From 1b77dd8548afd7eb3f27524b2cfb3b92c1336257 Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 22:50:25 +0200 Subject: [PATCH 05/11] feat(fxmcpserver): Provided module --- fxmcpserver/go.mod | 1 + fxmcpserver/go.sum | 2 + fxmcpserver/info_test.go | 17 ++- fxmcpserver/module_test.go | 140 ++++++++++++------ fxmcpserver/register_test.go | 17 ++- fxmcpserver/server/healthcheck_test.go | 2 +- fxmcpserver/server/registry_test.go | 17 ++- fxmcpserver/testdata/config/config.yaml | 2 +- fxmcpserver/testdata/prompt/prompt.go | 45 ------ fxmcpserver/testdata/prompt/simple.go | 38 +++++ fxmcpserver/testdata/resource/resource.go | 47 ------ fxmcpserver/testdata/resource/simple.go | 40 +++++ fxmcpserver/testdata/resource/template.go | 47 ------ .../testdata/resourcetemplate/simple.go | 40 +++++ fxmcpserver/testdata/tool/advanced.go | 52 +++++++ fxmcpserver/testdata/tool/simple.go | 30 ++++ fxmcpserver/testdata/tool/tool.go | 37 ----- 17 files changed, 330 insertions(+), 244 deletions(-) delete mode 100644 fxmcpserver/testdata/prompt/prompt.go create mode 100644 fxmcpserver/testdata/prompt/simple.go delete mode 100644 fxmcpserver/testdata/resource/resource.go create mode 100644 fxmcpserver/testdata/resource/simple.go delete mode 100644 fxmcpserver/testdata/resource/template.go create mode 100644 fxmcpserver/testdata/resourcetemplate/simple.go create mode 100644 fxmcpserver/testdata/tool/advanced.go create mode 100644 fxmcpserver/testdata/tool/simple.go delete mode 100644 fxmcpserver/testdata/tool/tool.go diff --git a/fxmcpserver/go.mod b/fxmcpserver/go.mod index 6918958e..86c657c9 100644 --- a/fxmcpserver/go.mod +++ b/fxmcpserver/go.mod @@ -6,6 +6,7 @@ require ( github.com/ankorstore/yokai/config v1.5.0 github.com/ankorstore/yokai/fxconfig v1.3.0 github.com/ankorstore/yokai/fxgenerate v1.3.0 + github.com/ankorstore/yokai/fxhealthcheck v1.1.0 github.com/ankorstore/yokai/fxlog v1.1.0 github.com/ankorstore/yokai/fxmetrics v1.2.0 github.com/ankorstore/yokai/fxtrace v1.2.0 diff --git a/fxmcpserver/go.sum b/fxmcpserver/go.sum index c8cd0a4c..77e9fe9e 100644 --- a/fxmcpserver/go.sum +++ b/fxmcpserver/go.sum @@ -6,6 +6,8 @@ github.com/ankorstore/yokai/fxconfig v1.3.0 h1:kk+RkpgECjZYciN2E3lnVj1dpewRy54JN github.com/ankorstore/yokai/fxconfig v1.3.0/go.mod h1:NTF2TbT+xZNEzI/iTCQLtY+oS/AJSDAPAqouPgAYzbE= github.com/ankorstore/yokai/fxgenerate v1.3.0 h1:+opuO9YWn71CVtGAR4+c9K07XyyhUHilGsPHqTFGO5c= github.com/ankorstore/yokai/fxgenerate v1.3.0/go.mod h1:Ts66FYH0ItnlMmz1YhCjfsOoVpnx8u6mrHuyoa9War4= +github.com/ankorstore/yokai/fxhealthcheck v1.1.0 h1:E/ADes6EC49kPwQlOel5BUyWNv45R21GtCa2WmSmZCQ= +github.com/ankorstore/yokai/fxhealthcheck v1.1.0/go.mod h1:j8ki4ZHL/G5zaD3GwVX3j5/xFyuQNNvsZPnoSG7E/AY= github.com/ankorstore/yokai/fxlog v1.1.0 h1:vLI8Qd9KfCzAH9IvzGJTvFYmlE1jtMnjvA4z/vxJpYg= github.com/ankorstore/yokai/fxlog v1.1.0/go.mod h1:VHlj/FNGAuLNqTyRCCx3iGUi9IZXv7qVNrDLUQng1cE= github.com/ankorstore/yokai/fxmetrics v1.2.0 h1:B4vwfOxsUeFXC5rn0bDHsFnOhEFhRq9aUEWpEayEOCY= diff --git a/fxmcpserver/info_test.go b/fxmcpserver/info_test.go index bbdffbbf..634bcc68 100644 --- a/fxmcpserver/info_test.go +++ b/fxmcpserver/info_test.go @@ -10,6 +10,7 @@ import ( "github.com/ankorstore/yokai/fxmcpserver/server/stdio" "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate" "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" @@ -26,16 +27,16 @@ func TestMCPServerModuleInfo(t *testing.T) { reg := fs.NewMCPServerRegistry( cfg, []fs.MCPServerTool{ - tool.NewTestTool(), + tool.NewSimpleTestTool(), }, []fs.MCPServerPrompt{ - prompt.NewTestPrompt(), + prompt.NewSimpleTestPrompt(), }, []fs.MCPServerResource{ - resource.NewTestResource(), + resource.NewSimpleTestResource(), }, []fs.MCPServerResourceTemplate{ - resource.NewTestResourceTemplate(), + resourcetemplate.NewSimpleTestResourceTemplate(), }, ) @@ -77,16 +78,16 @@ func TestMCPServerModuleInfo(t *testing.T) { }, "registrations": map[string]any{ "tools": map[string]string{ - "test-tool": "github.com/ankorstore/yokai/fxmcpserver/testdata/tool.(*TestTool).Handle.func1", + "simple-test-tool": "github.com/ankorstore/yokai/fxmcpserver/testdata/tool.(*SimpleTestTool).Handle.func1", }, "prompts": map[string]string{ - "test-prompt": "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt.(*TestPrompt).Handle.func1", + "simple-test-prompt": "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt.(*SimpleTestPrompt).Handle.func1", }, "resources": map[string]string{ - "test-resource": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*TestResource).Handle.func1", + "simple-test-resource": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*SimpleTestResource).Handle.func1", }, "resourceTemplates": map[string]string{ - "test-template": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*TestResourceTemplate).Handle.func1", + "simple-test-resource-template": "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate.(*SimpleTestResourceTemplate).Handle.func1", }, }, } diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go index 8816a3ae..27c71ce8 100644 --- a/fxmcpserver/module_test.go +++ b/fxmcpserver/module_test.go @@ -8,14 +8,15 @@ import ( "github.com/ankorstore/yokai/fxconfig" "github.com/ankorstore/yokai/fxgenerate" + "github.com/ankorstore/yokai/fxhealthcheck" "github.com/ankorstore/yokai/fxlog" "github.com/ankorstore/yokai/fxmcpserver" + fs "github.com/ankorstore/yokai/fxmcpserver/server" "github.com/ankorstore/yokai/fxmcpserver/server/sse" - "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" - "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" "github.com/ankorstore/yokai/fxmetrics" "github.com/ankorstore/yokai/fxtrace" + "github.com/ankorstore/yokai/healthcheck" "github.com/ankorstore/yokai/log/logtest" "github.com/ankorstore/yokai/trace/tracetest" "github.com/mark3labs/mcp-go/client" @@ -34,10 +35,10 @@ func TestMCPServerModule(t *testing.T) { var mcpServer *server.MCPServer var handler sse.MCPSSEServerContextHandler + var checker *healthcheck.Checker var logBuffer logtest.TestLogBuffer var traceExporter tracetest.TestTraceExporter var metricsRegistry *prometheus.Registry - var info *fxmcpserver.MCPServerModuleInfo fxtest.New( t, @@ -47,26 +48,31 @@ func TestMCPServerModule(t *testing.T) { fxtrace.FxTraceModule, fxgenerate.FxGenerateModule, fxmetrics.FxMetricsModule, + fxhealthcheck.FxHealthcheckModule, fxmcpserver.FxMCPServerModule, fx.Options( - fxmcpserver.AsMCPServerTools(tool.NewTestTool), - fxmcpserver.AsMCPServerPrompts(prompt.NewTestPrompt), - fxmcpserver.AsMCPServerResources(resource.NewTestResource), - fxmcpserver.AsMCPServerResourceTemplates(resource.NewTestResourceTemplate), + fxmcpserver.AsMCPServerTools(tool.NewAdvancedTestTool), + fxhealthcheck.AsCheckerProbe(fs.NewMCPServerProbe), ), - fx.Populate(&mcpServer, &handler, &logBuffer, &traceExporter, &metricsRegistry, &info), + fx.Supply(fx.Annotate(context.Background(), fx.As(new(context.Context)))), + fx.Populate(&mcpServer, &handler, &checker, &logBuffer, &traceExporter, &metricsRegistry), ).RequireStart().RequireStop() - // test server + // create test server testServer := server.NewTestServer(mcpServer, server.WithSSEContextFunc(handler.Handle())) defer testServer.Close() - // test client + // health check + checkResult := checker.Check(context.Background(), healthcheck.Readiness) + assert.True(t, checkResult.Success) + assert.Equal(t, "MCP SSE server is running", checkResult.ProbesResults["mcpserver"].Message) + + // create test client testClient, err := client.NewSSEMCPClient(testServer.URL + "/sse") assert.NoError(t, err) // start the client - ctx, cancel := context.WithTimeout(context.Background(), time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err = testClient.Start(ctx) @@ -79,27 +85,24 @@ func TestMCPServerModule(t *testing.T) { }) // send initialize request - expectedRequest := `{"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}` - expectedResponse := `{"protocolVersion":"2024-11-05","capabilities":{"logging":{},"prompts":{},"resources":{},"tools":{}},"serverInfo":{"name":"test-server","version":"1.0.0"}}` - - initRequest := mcp.InitializeRequest{} - initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION - initRequest.Params.ClientInfo = mcp.Implementation{ + initializeRequest := mcp.InitializeRequest{} + initializeRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initializeRequest.Params.ClientInfo = mcp.Implementation{ Name: "test-client", Version: "1.0.0", } - initResult, err := testClient.Initialize(ctx, initRequest) + initializeResult, err := testClient.Initialize(ctx, mcp.InitializeRequest{}) assert.NoError(t, err) - assert.Equal(t, "test-server", initResult.ServerInfo.Name) - assert.Equal(t, "1.0.0", initResult.ServerInfo.Version) + + assert.Equal(t, "test-server", initializeResult.ServerInfo.Name) + assert.Equal(t, "1.0.0", initializeResult.ServerInfo.Version) logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ - "level": "info", - "mcpMethod": "initialize", - "mcpRequest": expectedRequest, - "mcpResponse": expectedResponse, - "message": "MCP request success", + "level": "info", + "mcpMethod": "initialize", + "mcpTransport": "sse", + "message": "MCP request success", }) tracetest.AssertHasTraceSpan( @@ -107,8 +110,7 @@ func TestMCPServerModule(t *testing.T) { traceExporter, "MCP initialize", attribute.String("mcp.method", "initialize"), - attribute.String("mcp.request", expectedRequest), - attribute.String("mcp.response", expectedResponse), + attribute.String("mcp.transport", "sse"), ) expectedMetric := ` @@ -123,38 +125,92 @@ func TestMCPServerModule(t *testing.T) { ) assert.NoError(t, err) - // send tool/list request - expectedRequest = `{"method":"tools/list","params":{}}` - expectedResponse = `{"tools":[{"annotations":{"destructiveHint":true,"openWorldHint":true},"description":"Test tool.","inputSchema":{"properties":{},"type":"object"},"name":"test-tool"}]}` + // send success tools/call request + expectedRequest := `{"method":"tools/call","params":{"name":"advanced-test-tool","arguments":{"shouldFail":"false"}}}` + expectedResponse := `{"content":[{"type":"text","text":"test"}]}` - toolsListRequest := mcp.ListToolsRequest{} + callToolRequest := mcp.CallToolRequest{} + callToolRequest.Params.Name = "advanced-test-tool" + callToolRequest.Params.Arguments = map[string]interface{}{ + "shouldFail": "false", + } - toolsListResult, err := testClient.ListTools(ctx, toolsListRequest) + _, err = testClient.CallTool(ctx, callToolRequest) assert.NoError(t, err) - assert.Len(t, toolsListResult.Tools, 1) logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ - "level": "info", - "mcpMethod": "tools/list", - "mcpRequest": expectedRequest, - "mcpResponse": expectedResponse, - "message": "MCP request success", + "level": "info", + "mcpMethod": "tools/call", + "mcpTool": "advanced-test-tool", + "mcpRequest": expectedRequest, + "mcpResponse": expectedResponse, + "mcpTransport": "sse", + "message": "MCP request success", }) tracetest.AssertHasTraceSpan( t, traceExporter, - "MCP tools/list", - attribute.String("mcp.method", "tools/list"), + "MCP tools/call advanced-test-tool", + attribute.String("mcp.method", "tools/call"), + attribute.String("mcp.tool", "advanced-test-tool"), attribute.String("mcp.request", expectedRequest), attribute.String("mcp.response", expectedResponse), + attribute.String("mcp.transport", "sse"), + ) + + expectedMetric = ` + # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests + # TYPE foo_bar_mcp_server_requests_total counter + foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="success",target="advanced-test-tool"} 1 + ` + err = testutil.GatherAndCompare( + metricsRegistry, + strings.NewReader(expectedMetric), + "foo_bar_mcp_server_requests_total", + ) + assert.NoError(t, err) + + // send failing tools/call request + expectedRequest = `{"method":"tools/call","params":{"name":"advanced-test-tool","arguments":{"shouldFail":"true"}}}` + + callToolRequest = mcp.CallToolRequest{} + callToolRequest.Params.Name = "advanced-test-tool" + callToolRequest.Params.Arguments = map[string]interface{}{ + "shouldFail": "true", + } + + _, err = testClient.CallTool(ctx, callToolRequest) + assert.Error(t, err) + assert.Equal(t, "advanced tool test failure", err.Error()) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "error", + "mcpError": "request error: advanced tool test failure", + "mcpMethod": "tools/call", + "mcpTool": "advanced-test-tool", + "mcpRequest": expectedRequest, + "mcpTransport": "sse", + "message": "MCP request error", + }) + + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP tools/call advanced-test-tool", + attribute.String("mcp.method", "tools/call"), + attribute.String("mcp.tool", "advanced-test-tool"), + attribute.String("mcp.request", expectedRequest), + attribute.String("mcp.transport", "sse"), ) expectedMetric = ` # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests # TYPE foo_bar_mcp_server_requests_total counter - foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 - foo_bar_mcp_server_requests_total{method="tools/list",status="success",target=""} 1 + foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="success",target="advanced-test-tool"} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="error",target="advanced-test-tool"} 1 ` err = testutil.GatherAndCompare( metricsRegistry, diff --git a/fxmcpserver/register_test.go b/fxmcpserver/register_test.go index 2e420d4e..28f3579e 100644 --- a/fxmcpserver/register_test.go +++ b/fxmcpserver/register_test.go @@ -7,6 +7,7 @@ import ( "github.com/ankorstore/yokai/fxmcpserver" "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate" "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" "github.com/stretchr/testify/assert" "go.uber.org/fx" @@ -15,7 +16,7 @@ import ( func TestAsMCPServerTool(t *testing.T) { t.Parallel() - reg := fxmcpserver.AsMCPServerTool(tool.NewTestTool) + reg := fxmcpserver.AsMCPServerTool(tool.NewSimpleTestTool) assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) assert.Implements(t, (*fx.Option)(nil), reg) @@ -24,7 +25,7 @@ func TestAsMCPServerTool(t *testing.T) { func TestAsMCPServerTools(t *testing.T) { t.Parallel() - reg := fxmcpserver.AsMCPServerTools(tool.NewTestTool) + reg := fxmcpserver.AsMCPServerTools(tool.NewSimpleTestTool) assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) assert.Implements(t, (*fx.Option)(nil), reg) @@ -33,7 +34,7 @@ func TestAsMCPServerTools(t *testing.T) { func TestAsMCPServerPrompt(t *testing.T) { t.Parallel() - reg := fxmcpserver.AsMCPServerPrompt(prompt.NewTestPrompt) + reg := fxmcpserver.AsMCPServerPrompt(prompt.NewSimpleTestPrompt) assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) assert.Implements(t, (*fx.Option)(nil), reg) @@ -42,7 +43,7 @@ func TestAsMCPServerPrompt(t *testing.T) { func TestAsMCPServerPrompts(t *testing.T) { t.Parallel() - reg := fxmcpserver.AsMCPServerPrompts(prompt.NewTestPrompt) + reg := fxmcpserver.AsMCPServerPrompts(prompt.NewSimpleTestPrompt) assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) assert.Implements(t, (*fx.Option)(nil), reg) @@ -51,7 +52,7 @@ func TestAsMCPServerPrompts(t *testing.T) { func TestAsMCPServerResource(t *testing.T) { t.Parallel() - reg := fxmcpserver.AsMCPServerResource(resource.NewTestResource) + reg := fxmcpserver.AsMCPServerResource(resource.NewSimpleTestResource) assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) assert.Implements(t, (*fx.Option)(nil), reg) @@ -60,7 +61,7 @@ func TestAsMCPServerResource(t *testing.T) { func TestAsMCPServerResources(t *testing.T) { t.Parallel() - reg := fxmcpserver.AsMCPServerResources(resource.NewTestResource) + reg := fxmcpserver.AsMCPServerResources(resource.NewSimpleTestResource) assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) assert.Implements(t, (*fx.Option)(nil), reg) @@ -69,7 +70,7 @@ func TestAsMCPServerResources(t *testing.T) { func TestAsMCPServerResourceTemplate(t *testing.T) { t.Parallel() - reg := fxmcpserver.AsMCPServerResourceTemplate(resource.NewTestResourceTemplate) + reg := fxmcpserver.AsMCPServerResourceTemplate(resourcetemplate.NewSimpleTestResourceTemplate) assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) assert.Implements(t, (*fx.Option)(nil), reg) @@ -78,7 +79,7 @@ func TestAsMCPServerResourceTemplate(t *testing.T) { func TestAsMCPServerResourceTemplates(t *testing.T) { t.Parallel() - reg := fxmcpserver.AsMCPServerResourceTemplates(resource.NewTestResourceTemplate) + reg := fxmcpserver.AsMCPServerResourceTemplates(resourcetemplate.NewSimpleTestResourceTemplate) assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) assert.Implements(t, (*fx.Option)(nil), reg) diff --git a/fxmcpserver/server/healthcheck_test.go b/fxmcpserver/server/healthcheck_test.go index f3cb5080..ed32c31f 100644 --- a/fxmcpserver/server/healthcheck_test.go +++ b/fxmcpserver/server/healthcheck_test.go @@ -30,5 +30,5 @@ func TestMCPServerProbe(t *testing.T) { res := probe.Check(context.Background()) assert.False(t, res.Success) - assert.Equal(t, "MCP SSE server is not running, MCP Stdio server is not running", res.Message) + assert.Equal(t, "MCP SSE server is not running", res.Message) } diff --git a/fxmcpserver/server/registry_test.go b/fxmcpserver/server/registry_test.go index 7df796e1..9b4f0691 100644 --- a/fxmcpserver/server/registry_test.go +++ b/fxmcpserver/server/registry_test.go @@ -7,6 +7,7 @@ import ( "github.com/ankorstore/yokai/fxmcpserver/server" "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate" "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" "github.com/stretchr/testify/assert" ) @@ -22,16 +23,16 @@ func TestMCPServerRegistry_Info(t *testing.T) { reg := server.NewMCPServerRegistry( cfg, []server.MCPServerTool{ - tool.NewTestTool(), + tool.NewSimpleTestTool(), }, []server.MCPServerPrompt{ - prompt.NewTestPrompt(), + prompt.NewSimpleTestPrompt(), }, []server.MCPServerResource{ - resource.NewTestResource(), + resource.NewSimpleTestResource(), }, []server.MCPServerResourceTemplate{ - resource.NewTestResourceTemplate(), + resourcetemplate.NewSimpleTestResourceTemplate(), }, ) @@ -52,16 +53,16 @@ func TestMCPServerRegistry_Info(t *testing.T) { ResourceTemplates map[string]string }{ Tools: map[string]string{ - "test-tool": "github.com/ankorstore/yokai/fxmcpserver/testdata/tool.(*TestTool).Handle.func1", + "simple-test-tool": "github.com/ankorstore/yokai/fxmcpserver/testdata/tool.(*SimpleTestTool).Handle.func1", }, Prompts: map[string]string{ - "test-prompt": "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt.(*TestPrompt).Handle.func1", + "simple-test-prompt": "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt.(*SimpleTestPrompt).Handle.func1", }, Resources: map[string]string{ - "test-resource": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*TestResource).Handle.func1", + "simple-test-resource": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*SimpleTestResource).Handle.func1", }, ResourceTemplates: map[string]string{ - "test-template": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*TestResourceTemplate).Handle.func1", + "simple-test-resource-template": "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate.(*SimpleTestResourceTemplate).Handle.func1", }, }, } diff --git a/fxmcpserver/testdata/config/config.yaml b/fxmcpserver/testdata/config/config.yaml index d4f4ef1b..d07262a4 100644 --- a/fxmcpserver/testdata/config/config.yaml +++ b/fxmcpserver/testdata/config/config.yaml @@ -27,7 +27,7 @@ modules: keep_alive: true keep_alive_interval: 10 stdio: - expose: true + expose: false log: request: true response: true diff --git a/fxmcpserver/testdata/prompt/prompt.go b/fxmcpserver/testdata/prompt/prompt.go deleted file mode 100644 index 7f59b1ee..00000000 --- a/fxmcpserver/testdata/prompt/prompt.go +++ /dev/null @@ -1,45 +0,0 @@ -package prompt - -import ( - "context" - - "github.com/ankorstore/yokai/log" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "go.opencensus.io/trace" -) - -type TestPrompt struct{} - -func NewTestPrompt() *TestPrompt { - return &TestPrompt{} -} - -func (p *TestPrompt) Name() string { - return "test-prompt" -} - -func (p *TestPrompt) Options() []mcp.PromptOption { - return []mcp.PromptOption{ - mcp.WithPromptDescription("Test prompt."), - } -} - -func (p *TestPrompt) Handle() server.PromptHandlerFunc { - return func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - ctx, span := trace.StartSpan(ctx, "TestPrompt.Handle") - defer span.End() - - log.CtxLogger(ctx).Info().Msg("TestPrompt.Handle") - - return mcp.NewGetPromptResult( - "ok", - []mcp.PromptMessage{ - mcp.NewPromptMessage( - mcp.RoleAssistant, - mcp.NewTextContent("test content"), - ), - }, - ), nil - } -} diff --git a/fxmcpserver/testdata/prompt/simple.go b/fxmcpserver/testdata/prompt/simple.go new file mode 100644 index 00000000..36936789 --- /dev/null +++ b/fxmcpserver/testdata/prompt/simple.go @@ -0,0 +1,38 @@ +package prompt + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type SimpleTestPrompt struct{} + +func NewSimpleTestPrompt() *SimpleTestPrompt { + return &SimpleTestPrompt{} +} + +func (p *SimpleTestPrompt) Name() string { + return "simple-test-prompt" +} + +func (p *SimpleTestPrompt) Options() []mcp.PromptOption { + return []mcp.PromptOption{ + mcp.WithPromptDescription("Simple test prompt."), + } +} + +func (p *SimpleTestPrompt) Handle() server.PromptHandlerFunc { + return func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return mcp.NewGetPromptResult( + "ok", + []mcp.PromptMessage{ + mcp.NewPromptMessage( + mcp.RoleAssistant, + mcp.NewTextContent("test content"), + ), + }, + ), nil + } +} diff --git a/fxmcpserver/testdata/resource/resource.go b/fxmcpserver/testdata/resource/resource.go deleted file mode 100644 index c2b80587..00000000 --- a/fxmcpserver/testdata/resource/resource.go +++ /dev/null @@ -1,47 +0,0 @@ -package resource - -import ( - "context" - - "github.com/ankorstore/yokai/log" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "go.opencensus.io/trace" -) - -type TestResource struct{} - -func NewTestResource() *TestResource { - return &TestResource{} -} - -func (r *TestResource) Name() string { - return "test-resource" -} - -func (r *TestResource) URI() string { - return "test://resources" -} - -func (r *TestResource) Options() []mcp.ResourceOption { - return []mcp.ResourceOption{ - mcp.WithResourceDescription("Test resource."), - } -} - -func (r *TestResource) Handle() server.ResourceHandlerFunc { - return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - ctx, span := trace.StartSpan(ctx, "TestResource.Handle") - defer span.End() - - log.CtxLogger(ctx).Info().Msg("TestResource.Handle") - - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: request.Params.URI, - MIMEType: "text/plain", - Text: "ok", - }, - }, nil - } -} diff --git a/fxmcpserver/testdata/resource/simple.go b/fxmcpserver/testdata/resource/simple.go new file mode 100644 index 00000000..3f3cdebc --- /dev/null +++ b/fxmcpserver/testdata/resource/simple.go @@ -0,0 +1,40 @@ +package resource + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type SimpleTestResource struct{} + +func NewSimpleTestResource() *SimpleTestResource { + return &SimpleTestResource{} +} + +func (r *SimpleTestResource) Name() string { + return "simple-test-resource" +} + +func (r *SimpleTestResource) URI() string { + return "simple-test://resources" +} + +func (r *SimpleTestResource) Options() []mcp.ResourceOption { + return []mcp.ResourceOption{ + mcp.WithResourceDescription("Simple test resource."), + } +} + +func (r *SimpleTestResource) Handle() server.ResourceHandlerFunc { + return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: request.Params.URI, + MIMEType: "text/plain", + Text: "ok", + }, + }, nil + } +} diff --git a/fxmcpserver/testdata/resource/template.go b/fxmcpserver/testdata/resource/template.go deleted file mode 100644 index 6d0ccd7b..00000000 --- a/fxmcpserver/testdata/resource/template.go +++ /dev/null @@ -1,47 +0,0 @@ -package resource - -import ( - "context" - - "github.com/ankorstore/yokai/log" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "go.opencensus.io/trace" -) - -type TestResourceTemplate struct{} - -func NewTestResourceTemplate() *TestResourceTemplate { - return &TestResourceTemplate{} -} - -func (r *TestResourceTemplate) Name() string { - return "test-template" -} - -func (r *TestResourceTemplate) URI() string { - return "test://resources/{id}" -} - -func (r *TestResourceTemplate) Options() []mcp.ResourceTemplateOption { - return []mcp.ResourceTemplateOption{ - mcp.WithTemplateDescription("Test resource template."), - } -} - -func (r *TestResourceTemplate) Handle() server.ResourceTemplateHandlerFunc { - return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - ctx, span := trace.StartSpan(ctx, "TestResourceTemplate.Handle") - defer span.End() - - log.CtxLogger(ctx).Info().Msg("TestResourceTemplate.Handle") - - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: request.Params.URI, - MIMEType: "text/plain", - Text: "ok", - }, - }, nil - } -} diff --git a/fxmcpserver/testdata/resourcetemplate/simple.go b/fxmcpserver/testdata/resourcetemplate/simple.go new file mode 100644 index 00000000..1aee252b --- /dev/null +++ b/fxmcpserver/testdata/resourcetemplate/simple.go @@ -0,0 +1,40 @@ +package resourcetemplate + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type SimpleTestResourceTemplate struct{} + +func NewSimpleTestResourceTemplate() *SimpleTestResourceTemplate { + return &SimpleTestResourceTemplate{} +} + +func (r *SimpleTestResourceTemplate) Name() string { + return "simple-test-resource-template" +} + +func (r *SimpleTestResourceTemplate) URI() string { + return "simple-test://resources/{id}" +} + +func (r *SimpleTestResourceTemplate) Options() []mcp.ResourceTemplateOption { + return []mcp.ResourceTemplateOption{ + mcp.WithTemplateDescription("Simple test resource template."), + } +} + +func (r *SimpleTestResourceTemplate) Handle() server.ResourceTemplateHandlerFunc { + return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: request.Params.URI, + MIMEType: "text/plain", + Text: "ok", + }, + }, nil + } +} diff --git a/fxmcpserver/testdata/tool/advanced.go b/fxmcpserver/testdata/tool/advanced.go new file mode 100644 index 00000000..467785b5 --- /dev/null +++ b/fxmcpserver/testdata/tool/advanced.go @@ -0,0 +1,52 @@ +package tool + +import ( + "context" + "fmt" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.opencensus.io/trace" +) + +type AdvancedTestTool struct { + config *config.Config +} + +func NewAdvancedTestTool(config *config.Config) *AdvancedTestTool { + return &AdvancedTestTool{ + config: config, + } +} + +func (t *AdvancedTestTool) Name() string { + return "advanced-test-tool" +} + +func (t *AdvancedTestTool) Options() []mcp.ToolOption { + return []mcp.ToolOption{ + mcp.WithDescription("Advanced test tool."), + mcp.WithBoolean( + "shouldFail", + mcp.Description("If the tool call should fail or not."), + ), + } +} + +func (t *AdvancedTestTool) Handle() server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx, span := trace.StartSpan(ctx, "AdvancedTestTool.Handle") + defer span.End() + + log.CtxLogger(ctx).Info().Msg("AdvancedTestTool.Handle") + + shouldFail := request.Params.Arguments["shouldFail"].(string) + if shouldFail == "true" { + return nil, fmt.Errorf("advanced tool test failure") + } + + return mcp.NewToolResultText(t.config.AppName()), nil + } +} diff --git a/fxmcpserver/testdata/tool/simple.go b/fxmcpserver/testdata/tool/simple.go new file mode 100644 index 00000000..6d954d82 --- /dev/null +++ b/fxmcpserver/testdata/tool/simple.go @@ -0,0 +1,30 @@ +package tool + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type SimpleTestTool struct{} + +func NewSimpleTestTool() *SimpleTestTool { + return &SimpleTestTool{} +} + +func (t *SimpleTestTool) Name() string { + return "simple-test-tool" +} + +func (t *SimpleTestTool) Options() []mcp.ToolOption { + return []mcp.ToolOption{ + mcp.WithDescription("Simple test tool."), + } +} + +func (t *SimpleTestTool) Handle() server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("ok"), nil + } +} diff --git a/fxmcpserver/testdata/tool/tool.go b/fxmcpserver/testdata/tool/tool.go deleted file mode 100644 index 83328c1c..00000000 --- a/fxmcpserver/testdata/tool/tool.go +++ /dev/null @@ -1,37 +0,0 @@ -package tool - -import ( - "context" - - "github.com/ankorstore/yokai/log" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "go.opencensus.io/trace" -) - -type TestTool struct{} - -func NewTestTool() *TestTool { - return &TestTool{} -} - -func (t *TestTool) Name() string { - return "test-tool" -} - -func (t *TestTool) Options() []mcp.ToolOption { - return []mcp.ToolOption{ - mcp.WithDescription("Test tool."), - } -} - -func (t *TestTool) Handle() server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - ctx, span := trace.StartSpan(ctx, "TestTool.Handle") - defer span.End() - - log.CtxLogger(ctx).Info().Msg("TestTool.Handle") - - return mcp.NewToolResultText("ok"), nil - } -} From bd63f1c47e027f6bab0eb363c92e2a0e6182a800 Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 23:01:16 +0200 Subject: [PATCH 06/11] feat(fxmcpserver): Provided module --- fxmcpserver/module.go | 20 +++++++++++++++----- fxmcpserver/module_test.go | 5 +++-- fxmcpserver/testdata/config/config.test.yaml | 10 ++++++++++ fxmcpserver/testdata/config/config.yaml | 6 ------ 4 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 fxmcpserver/testdata/config/config.test.yaml diff --git a/fxmcpserver/module.go b/fxmcpserver/module.go index 0aadf85a..4ccb8bf4 100644 --- a/fxmcpserver/module.go +++ b/fxmcpserver/module.go @@ -2,6 +2,7 @@ package fxmcpserver import ( "context" + "fmt" "github.com/ankorstore/yokai/config" fs "github.com/ankorstore/yokai/fxmcpserver/server" @@ -170,13 +171,20 @@ func ProvideMCPSSEServer(p ProvideMCPSSEServerParam) *sse.MCPSSEServer { if p.Config.GetBool("modules.mcp.server.transport.sse.expose") { p.LifeCycle.Append(fx.Hook{ OnStart: func(context.Context) error { - //nolint:contextcheck,errcheck - go sseServer.Start(p.Context) + if !p.Config.IsTestEnv() { + fmt.Println("*************************************** start") + //nolint:contextcheck,errcheck + go sseServer.Start(p.Context) + } return nil }, OnStop: func(ctx context.Context) error { - return sseServer.Stop(ctx) + if !p.Config.IsTestEnv() { + return sseServer.Stop(ctx) + } + + return nil }, }) } @@ -226,8 +234,10 @@ func ProvideMCPStdioServer(p ProvideMCPStdioServerParam) *stdio.MCPStdioServer { if p.Config.GetBool("modules.mcp.server.transport.stdio.expose") { p.LifeCycle.Append(fx.Hook{ OnStart: func(context.Context) error { - //nolint:contextcheck,errcheck - go stdioServer.Start(p.Context) + if !p.Config.IsTestEnv() { + //nolint:contextcheck,errcheck + go stdioServer.Start(p.Context) + } return nil }, diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go index 27c71ce8..58cfc90d 100644 --- a/fxmcpserver/module_test.go +++ b/fxmcpserver/module_test.go @@ -31,6 +31,7 @@ import ( ) func TestMCPServerModule(t *testing.T) { + t.Setenv("APP_ENV", "test") t.Setenv("APP_CONFIG_PATH", "testdata/config") var mcpServer *server.MCPServer @@ -64,8 +65,8 @@ func TestMCPServerModule(t *testing.T) { // health check checkResult := checker.Check(context.Background(), healthcheck.Readiness) - assert.True(t, checkResult.Success) - assert.Equal(t, "MCP SSE server is running", checkResult.ProbesResults["mcpserver"].Message) + assert.False(t, checkResult.Success) + assert.Equal(t, "MCP SSE server is not running", checkResult.ProbesResults["mcpserver"].Message) // create test client testClient, err := client.NewSSEMCPClient(testServer.URL + "/sse") diff --git a/fxmcpserver/testdata/config/config.test.yaml b/fxmcpserver/testdata/config/config.test.yaml new file mode 100644 index 00000000..c47200d1 --- /dev/null +++ b/fxmcpserver/testdata/config/config.test.yaml @@ -0,0 +1,10 @@ +app: + name: test + version: 0.1.0 +modules: + log: + level: debug + output: test + trace: + processor: + type: test diff --git a/fxmcpserver/testdata/config/config.yaml b/fxmcpserver/testdata/config/config.yaml index d07262a4..0a862f1d 100644 --- a/fxmcpserver/testdata/config/config.yaml +++ b/fxmcpserver/testdata/config/config.yaml @@ -2,12 +2,6 @@ app: name: test version: 0.1.0 modules: - log: - level: debug - output: test - trace: - processor: - type: test mcp: server: name: "test-server" From 30eea8611b0b9fb49dbe751d50ae181d13b06f5a Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 23:03:24 +0200 Subject: [PATCH 07/11] feat(fxmcpserver): Provided module --- fxmcpserver/module.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/fxmcpserver/module.go b/fxmcpserver/module.go index 4ccb8bf4..ee731082 100644 --- a/fxmcpserver/module.go +++ b/fxmcpserver/module.go @@ -2,7 +2,6 @@ package fxmcpserver import ( "context" - "fmt" "github.com/ankorstore/yokai/config" fs "github.com/ankorstore/yokai/fxmcpserver/server" @@ -172,7 +171,6 @@ func ProvideMCPSSEServer(p ProvideMCPSSEServerParam) *sse.MCPSSEServer { p.LifeCycle.Append(fx.Hook{ OnStart: func(context.Context) error { if !p.Config.IsTestEnv() { - fmt.Println("*************************************** start") //nolint:contextcheck,errcheck go sseServer.Start(p.Context) } From a1ba7089712f0a01204ba72bbb688764447f3a9e Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 23:11:43 +0200 Subject: [PATCH 08/11] feat(fxmcpserver): Provided module --- fxmcpserver/module_test.go | 9 ++++++++- fxmcpserver/server/provider.go | 2 ++ fxmcpserver/testdata/config/config.yaml | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go index 58cfc90d..8e47f7e8 100644 --- a/fxmcpserver/module_test.go +++ b/fxmcpserver/module_test.go @@ -13,6 +13,9 @@ import ( "github.com/ankorstore/yokai/fxmcpserver" fs "github.com/ankorstore/yokai/fxmcpserver/server" "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" + "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate" "github.com/ankorstore/yokai/fxmcpserver/testdata/tool" "github.com/ankorstore/yokai/fxmetrics" "github.com/ankorstore/yokai/fxtrace" @@ -52,7 +55,11 @@ func TestMCPServerModule(t *testing.T) { fxhealthcheck.FxHealthcheckModule, fxmcpserver.FxMCPServerModule, fx.Options( - fxmcpserver.AsMCPServerTools(tool.NewAdvancedTestTool), + fxmcpserver.AsMCPServerTools(tool.NewSimpleTestTool, tool.NewAdvancedTestTool), + fxmcpserver.AsMCPServerPrompts(prompt.NewSimpleTestPrompt), + fxmcpserver.AsMCPServerResources(resource.NewSimpleTestResource), + fxmcpserver.AsMCPServerResourceTemplates(resourcetemplate.NewSimpleTestResourceTemplate), + fxhealthcheck.AsCheckerProbe(fs.NewMCPServerProbe), ), fx.Supply(fx.Annotate(context.Background(), fx.As(new(context.Context)))), diff --git a/fxmcpserver/server/provider.go b/fxmcpserver/server/provider.go index 1213fe1a..cf6119f0 100644 --- a/fxmcpserver/server/provider.go +++ b/fxmcpserver/server/provider.go @@ -39,6 +39,8 @@ func NewDefaultMCPServerHooksProvider(registry prometheus.Registerer, config *co buckets := prometheus.DefBuckets if bucketsConfig := config.GetString("modules.mcp.server.metrics.buckets"); bucketsConfig != "" { + buckets = []float64{} + for _, s := range Split(bucketsConfig) { f, err := strconv.ParseFloat(s, 64) if err == nil { diff --git a/fxmcpserver/testdata/config/config.yaml b/fxmcpserver/testdata/config/config.yaml index 0a862f1d..d820dd76 100644 --- a/fxmcpserver/testdata/config/config.yaml +++ b/fxmcpserver/testdata/config/config.yaml @@ -33,3 +33,4 @@ modules: enabled: true namespace: foo subsystem: bar + buckets: 0.1, 1, 10 From a617a90485861f911cde387a53dd3a81ce82e76f Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Mon, 5 May 2025 23:54:52 +0200 Subject: [PATCH 09/11] feat(fxmcpserver): Provided module --- fxmcpserver/module_test.go | 196 +++++++++++++++++++++++- fxmcpserver/server/provider.go | 4 +- fxmcpserver/testdata/prompt/simple.go | 2 +- fxmcpserver/testdata/resource/simple.go | 2 +- 4 files changed, 198 insertions(+), 6 deletions(-) diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go index 8e47f7e8..f4986d5e 100644 --- a/fxmcpserver/module_test.go +++ b/fxmcpserver/module_test.go @@ -143,8 +143,9 @@ func TestMCPServerModule(t *testing.T) { "shouldFail": "false", } - _, err = testClient.CallTool(ctx, callToolRequest) + callToolResult, err := testClient.CallTool(ctx, callToolRequest) assert.NoError(t, err) + assert.False(t, callToolResult.IsError) logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ "level": "info", @@ -180,7 +181,7 @@ func TestMCPServerModule(t *testing.T) { ) assert.NoError(t, err) - // send failing tools/call request + // send error tools/call request expectedRequest = `{"method":"tools/call","params":{"name":"advanced-test-tool","arguments":{"shouldFail":"true"}}}` callToolRequest = mcp.CallToolRequest{} @@ -226,4 +227,195 @@ func TestMCPServerModule(t *testing.T) { "foo_bar_mcp_server_requests_total", ) assert.NoError(t, err) + + // send success prompts/get request + expectedRequest = `{"method":"prompts/get","params":{"name":"simple-test-prompt"}}` + expectedResponse = `{"description":"ok","messages":[{"role":"assistant","content":{"type":"text","text":"simple test prompt"}}]}` + + getPromptRequest := mcp.GetPromptRequest{} + getPromptRequest.Params.Name = "simple-test-prompt" + + getPromptResult, err := testClient.GetPrompt(ctx, getPromptRequest) + assert.NoError(t, err) + assert.Equal(t, mcp.RoleAssistant, getPromptResult.Messages[0].Role) + assert.Equal(t, "simple test prompt", getPromptResult.Messages[0].Content.(mcp.TextContent).Text) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "info", + "mcpMethod": "prompts/get", + "mcpPrompt": "simple-test-prompt", + "mcpRequest": expectedRequest, + "mcpResponse": expectedResponse, + "mcpTransport": "sse", + "message": "MCP request success", + }) + + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP prompts/get simple-test-prompt", + attribute.String("mcp.method", "prompts/get"), + attribute.String("mcp.prompt", "simple-test-prompt"), + attribute.String("mcp.request", expectedRequest), + attribute.String("mcp.response", expectedResponse), + attribute.String("mcp.transport", "sse"), + ) + + expectedMetric = ` + # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests + # TYPE foo_bar_mcp_server_requests_total counter + foo_bar_mcp_server_requests_total{method="prompts/get",status="success",target="simple-test-prompt"} 1 + foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="success",target="advanced-test-tool"} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="error",target="advanced-test-tool"} 1 + ` + err = testutil.GatherAndCompare( + metricsRegistry, + strings.NewReader(expectedMetric), + "foo_bar_mcp_server_requests_total", + ) + assert.NoError(t, err) + + // send error prompts/get request + expectedRequest = `{"method":"prompts/get","params":{"name":"invalid-test-prompt"}}` + + getPromptRequest = mcp.GetPromptRequest{} + getPromptRequest.Params.Name = "invalid-test-prompt" + + _, err = testClient.GetPrompt(ctx, getPromptRequest) + assert.Error(t, err) + assert.Equal(t, "prompt 'invalid-test-prompt' not found: prompt not found", err.Error()) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "error", + "mcpError": "request error: prompt 'invalid-test-prompt' not found: prompt not found", + "mcpMethod": "prompts/get", + "mcpPrompt": "invalid-test-prompt", + "mcpRequest": expectedRequest, + "mcpTransport": "sse", + "message": "MCP request error", + }) + + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP prompts/get invalid-test-prompt", + attribute.String("mcp.method", "prompts/get"), + attribute.String("mcp.prompt", "invalid-test-prompt"), + attribute.String("mcp.request", expectedRequest), + attribute.String("mcp.transport", "sse"), + ) + + expectedMetric = ` + # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests + # TYPE foo_bar_mcp_server_requests_total counter + foo_bar_mcp_server_requests_total{method="prompts/get",status="error",target="invalid-test-prompt"} 1 + foo_bar_mcp_server_requests_total{method="prompts/get",status="success",target="simple-test-prompt"} 1 + foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="success",target="advanced-test-tool"} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="error",target="advanced-test-tool"} 1 + ` + err = testutil.GatherAndCompare( + metricsRegistry, + strings.NewReader(expectedMetric), + "foo_bar_mcp_server_requests_total", + ) + assert.NoError(t, err) + + // send success resources/get request + expectedRequest = `{"method":"resources/read","params":{"uri":"simple-test://resources"}}` + expectedResponse = `{"contents":[{"uri":"simple-test://resources","mimeType":"text/plain","text":"simple test resource"}]}` + + readResourceRequest := mcp.ReadResourceRequest{} + readResourceRequest.Params.URI = "simple-test://resources" + + readResourceResult, err := testClient.ReadResource(ctx, readResourceRequest) + assert.NoError(t, err) + assert.Equal(t, "simple test resource", readResourceResult.Contents[0].(mcp.TextResourceContents).Text) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "info", + "mcpMethod": "resources/read", + "mcpResourceURI": "simple-test://resources", + "mcpRequest": expectedRequest, + "mcpResponse": expectedResponse, + "mcpTransport": "sse", + "message": "MCP request success", + }) + + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP resources/read simple-test://resources", + attribute.String("mcp.method", "resources/read"), + attribute.String("mcp.resourceURI", "simple-test://resources"), + attribute.String("mcp.request", expectedRequest), + attribute.String("mcp.response", expectedResponse), + attribute.String("mcp.transport", "sse"), + ) + + expectedMetric = ` + # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests + # TYPE foo_bar_mcp_server_requests_total counter + foo_bar_mcp_server_requests_total{method="prompts/get",status="error",target="invalid-test-prompt"} 1 + foo_bar_mcp_server_requests_total{method="prompts/get",status="success",target="simple-test-prompt"} 1 + foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 + foo_bar_mcp_server_requests_total{method="resources/read",status="success",target="simple-test://resources"} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="success",target="advanced-test-tool"} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="error",target="advanced-test-tool"} 1 + ` + err = testutil.GatherAndCompare( + metricsRegistry, + strings.NewReader(expectedMetric), + "foo_bar_mcp_server_requests_total", + ) + assert.NoError(t, err) + + // send error resources/get request + expectedRequest = `{"method":"resources/read","params":{"uri":"simple-test://invalid"}}` + + readResourceRequest = mcp.ReadResourceRequest{} + readResourceRequest.Params.URI = "simple-test://invalid" + + readResourceResult, err = testClient.ReadResource(ctx, readResourceRequest) + assert.Error(t, err) + assert.Equal(t, "handler not found for resource URI 'simple-test://invalid': resource not found", err.Error()) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "error", + "mcpError": "request error: handler not found for resource URI 'simple-test://invalid': resource not found", + "mcpMethod": "resources/read", + "mcpResourceURI": "simple-test://invalid", + "mcpRequest": expectedRequest, + "mcpTransport": "sse", + "message": "MCP request error", + }) + + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP resources/read simple-test://invalid", + attribute.String("mcp.method", "resources/read"), + attribute.String("mcp.resourceURI", "simple-test://invalid"), + attribute.String("mcp.request", expectedRequest), + attribute.String("mcp.transport", "sse"), + ) + + expectedMetric = ` + # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests + # TYPE foo_bar_mcp_server_requests_total counter + foo_bar_mcp_server_requests_total{method="prompts/get",status="error",target="invalid-test-prompt"} 1 + foo_bar_mcp_server_requests_total{method="prompts/get",status="success",target="simple-test-prompt"} 1 + foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 + foo_bar_mcp_server_requests_total{method="resources/read",status="error",target="simple-test://invalid"} 1 + foo_bar_mcp_server_requests_total{method="resources/read",status="success",target="simple-test://resources"} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="success",target="advanced-test-tool"} 1 + foo_bar_mcp_server_requests_total{method="tools/call",status="error",target="advanced-test-tool"} 1 + ` + err = testutil.GatherAndCompare( + metricsRegistry, + strings.NewReader(expectedMetric), + "foo_bar_mcp_server_requests_total", + ) + assert.NoError(t, err) } diff --git a/fxmcpserver/server/provider.go b/fxmcpserver/server/provider.go index cf6119f0..289feb3b 100644 --- a/fxmcpserver/server/provider.go +++ b/fxmcpserver/server/provider.go @@ -150,7 +150,7 @@ func (p *DefaultMCPServerHooksProvider) Provide() *server.Hooks { case mcp.MethodResourcesRead: if req, ok := message.(*mcp.ReadResourceRequest); ok { spanNameSuffix = fmt.Sprintf("%s %s", spanNameSuffix, req.Params.URI) - spanAttributes = append(spanAttributes, attribute.String("mcp.resource", req.Params.URI)) + spanAttributes = append(spanAttributes, attribute.String("mcp.resourceURI", req.Params.URI)) logFields["mcpResourceURI"] = req.Params.URI metricTarget = req.Params.URI } @@ -224,7 +224,7 @@ func (p *DefaultMCPServerHooksProvider) Provide() *server.Hooks { case mcp.MethodResourcesRead: if req, ok := message.(*mcp.ReadResourceRequest); ok { spanNameSuffix = fmt.Sprintf("%s %s", spanNameSuffix, req.Params.URI) - spanAttributes = append(spanAttributes, attribute.String("mcp.resource", req.Params.URI)) + spanAttributes = append(spanAttributes, attribute.String("mcp.resourceURI", req.Params.URI)) logFields["mcpResourceURI"] = req.Params.URI metricTarget = req.Params.URI } diff --git a/fxmcpserver/testdata/prompt/simple.go b/fxmcpserver/testdata/prompt/simple.go index 36936789..6664068a 100644 --- a/fxmcpserver/testdata/prompt/simple.go +++ b/fxmcpserver/testdata/prompt/simple.go @@ -30,7 +30,7 @@ func (p *SimpleTestPrompt) Handle() server.PromptHandlerFunc { []mcp.PromptMessage{ mcp.NewPromptMessage( mcp.RoleAssistant, - mcp.NewTextContent("test content"), + mcp.NewTextContent("simple test prompt"), ), }, ), nil diff --git a/fxmcpserver/testdata/resource/simple.go b/fxmcpserver/testdata/resource/simple.go index 3f3cdebc..68fbb24b 100644 --- a/fxmcpserver/testdata/resource/simple.go +++ b/fxmcpserver/testdata/resource/simple.go @@ -33,7 +33,7 @@ func (r *SimpleTestResource) Handle() server.ResourceHandlerFunc { mcp.TextResourceContents{ URI: request.Params.URI, MIMEType: "text/plain", - Text: "ok", + Text: "simple test resource", }, }, nil } From b98d119e29101f1b0521f5bf748409b0bc211d54 Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Tue, 6 May 2025 00:20:16 +0200 Subject: [PATCH 10/11] feat(fxmcpserver): Provided module --- fxmcpserver/module_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go index f4986d5e..b7c51f15 100644 --- a/fxmcpserver/module_test.go +++ b/fxmcpserver/module_test.go @@ -33,6 +33,7 @@ import ( "go.uber.org/fx/fxtest" ) +//nolint:maintidx,forcetypeassert func TestMCPServerModule(t *testing.T) { t.Setenv("APP_ENV", "test") t.Setenv("APP_CONFIG_PATH", "testdata/config") @@ -377,7 +378,7 @@ func TestMCPServerModule(t *testing.T) { readResourceRequest = mcp.ReadResourceRequest{} readResourceRequest.Params.URI = "simple-test://invalid" - readResourceResult, err = testClient.ReadResource(ctx, readResourceRequest) + _, err = testClient.ReadResource(ctx, readResourceRequest) assert.Error(t, err) assert.Equal(t, "handler not found for resource URI 'simple-test://invalid': resource not found", err.Error()) From 6568c7c7f4c8122c3f7eb41c0f06024280520ba6 Mon Sep 17 00:00:00 2001 From: Jonathan Vuillemin Date: Tue, 6 May 2025 10:41:40 +0200 Subject: [PATCH 11/11] feat(fxmcpserver): Provided module --- fxmcpserver/README.md | 652 +++++++++++++++++++++ fxmcpserver/fxmcpservertest/server.go | 59 ++ fxmcpserver/fxmcpservertest/server_test.go | 49 ++ fxmcpserver/info.go | 2 + fxmcpserver/module.go | 15 + fxmcpserver/module_test.go | 73 +-- 6 files changed, 785 insertions(+), 65 deletions(-) create mode 100644 fxmcpserver/README.md create mode 100644 fxmcpserver/fxmcpservertest/server.go create mode 100644 fxmcpserver/fxmcpservertest/server_test.go diff --git a/fxmcpserver/README.md b/fxmcpserver/README.md new file mode 100644 index 00000000..070f5ed2 --- /dev/null +++ b/fxmcpserver/README.md @@ -0,0 +1,652 @@ +# Fx MCP Server Module + +[![ci](https://github.com/ankorstore/yokai/actions/workflows/fxmcpserver-ci.yml/badge.svg)](https://github.com/ankorstore/yokai/actions/workflows/fxmcpserver-ci.yml) +[![go report](https://goreportcard.com/badge/github.com/ankorstore/yokai/fxmcpserver)](https://goreportcard.com/report/github.com/ankorstore/yokai/fxmcpserver) +[![codecov](https://codecov.io/gh/ankorstore/yokai/graph/badge.svg?token=ghUBlFsjhR&flag=fxmcpserver)](https://app.codecov.io/gh/ankorstore/yokai/tree/main/fxmcpserver) +[![Deps](https://img.shields.io/badge/osi-deps-blue)](https://deps.dev/go/github.com%2Fankorstore%2Fyokai%2Ffxmcpserver) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/ankorstore/yokai/fxmcpserver)](https://pkg.go.dev/github.com/ankorstore/yokai/fxmcpserver) + +> [Fx](https://uber-go.github.io/fx/) module for [mark3labs/mcp-go](https://github.com/mark3labs/mcp-go). + + +* [Installation](#installation) +* [Features](#features) +* [Documentation](#documentation) + * [Dependencies](#dependencies) + * [Loading](#loading) + * [Configuration](#configuration) + * [Registration](#registration) + * [Resources](#resources) + * [Resource templates](#resource-templates) + * [Prompts](#prompts) + * [Tools](#tools) + * [Testing](#testing) + + +## Installation + +```shell +go get github.com/ankorstore/yokai/fxmcpserver +``` + +## Features + +This module provides an [MCP server](https://modelcontextprotocol.io/introduction) to your application with: + +- automatic panic recovery +- automatic requests logging and tracing (method, target, duration, ...) +- automatic requests metrics (count and duration) +- possibility to register MCP resources, resource templates, prompts and tools +- possibility to expose the MCP server via Stdio (local) and/or HTTP SSE (remote) + +## Documentation + +### Dependencies + +This module is intended to be used alongside: + +- the [fxconfig](https://github.com/ankorstore/yokai/tree/main/fxconfig) module +- the [fxlog](https://github.com/ankorstore/yokai/tree/main/fxlog) module +- the [fxtrace](https://github.com/ankorstore/yokai/tree/main/fxtrace) module +- the [fxmetrics](https://github.com/ankorstore/yokai/tree/main/fxmetrics) module +- the [fxgenerate](https://github.com/ankorstore/yokai/tree/main/fxgenerate) module + +### Loading + +To load the module in your application: + +```go +package main + +import ( + "github.com/ankorstore/yokai/fxconfig" + "github.com/ankorstore/yokai/fxgenerate" + "github.com/ankorstore/yokai/fxlog" + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmetrics" + "github.com/ankorstore/yokai/fxtrace" + "go.uber.org/fx" +) + +func main() { + fx.New( + fxconfig.FxConfigModule, // load the module dependencies + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxmetrics.FxMetricsModule, + fxgenerate.FxGenerateModule, + fxmcpserver.FxMCPServerModule, // load the module + ).Run() +} +``` + +### Configuration + +Configuration reference: + +```yaml +# ./configs/config.yaml +app: + name: app + env: dev + version: 0.1.0 + debug: true +modules: + log: + level: info + output: stdout + trace: + processor: + type: stdout + mcp: + server: + name: "MCP Server" # server name ("MCP server" by default) + version: 1.0.0 # server version (1.0.0 by default) + capabilities: + resources: true # to expose MCP resources & resource templates (disabled by default) + prompts: true # to expose MCP prompts (disabled by default) + tools: true # to expose MCP tools (disabled by default) + transport: + sse: + expose: true # to remotely expose the MCP server via SSE (disabled by default) + address: ":8082" # exposition address (":8082" by default) + base_url: "" # base url ("" by default) + base_path: "" # base path ("" by default) + sse_endpoint: "/sse" # SSE endpoint ("/sse" by default) + message_endpoint: "/message" # message endpoint ("/message" by default) + keep_alive: true # to keep connection alive + keep_alive_interval: 10 # keep alive interval in seconds (10 by default) + stdio: + expose: false # to locally expose the MCP server via Stdio (disabled by default) + log: + request: true # to log MCP requests contents (disabled by default) + response: true # to log MCP responses contents (disabled by default) + trace: + request: true # to trace MCP requests contents (disabled by default) + response: true # to trace MCP responses contents (disabled by default) + metrics: + collect: + enabled: true # to collect MCP server metrics (disabled by default) + namespace: foo # MCP server metrics namespace ("" by default) + subsystem: bar # MCP server metrics subsystem ("" by default) + buckets: 0.1, 1, 10 # to override default request duration buckets +``` + +Notes: + +- the MCP server logging will be based on the [fxlog](https://github.com/ankorstore/yokai/tree/main/fxlog) module configuration +- the MCP server tracing will be based on the [fxtrace](https://github.com/ankorstore/yokai/tree/main/fxtrace) module configuration + +### Registration + +This module offers the possibility to easily register MCP resources, resource templates, prompts and tools. + +#### Resources + +This module offers an [MCPServerResource](server/registry.go) interface to implement to provide an [MCP resource](https://modelcontextprotocol.io/docs/concepts/resources). + +You can use the `AsMCPServerResource()` function to register an MCP resource, or `AsMCPServerResources()` to register several MCP resources at once. + +The dependencies of your MCP resources will be autowired. + +```go +package main + +import ( + "context" + "os" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxconfig" + "github.com/ankorstore/yokai/fxgenerate" + "github.com/ankorstore/yokai/fxlog" + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmetrics" + "github.com/ankorstore/yokai/fxtrace" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.uber.org/fx" +) + +type ReadmeResource struct { + config *config.Config +} + +func NewReadmeResource(config *config.Config) *ReadmeResource { + return &ReadmeResource{ + config: config, + } +} + +func (r *ReadmeResource) Name() string { + return "readme" +} + +func (r *ReadmeResource) URI() string { + return "docs://readme" +} + +func (r *ReadmeResource) Options() []mcp.ResourceOption { + return []mcp.ResourceOption{ + mcp.WithResourceDescription("Project README"), + } +} + +func (r *ReadmeResource) Handle() server.ResourceHandlerFunc { + return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + content, err := os.ReadFile(r.config.GetString("config.readme.path")) + if err != nil { + return nil, err + } + + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: "docs://readme", + MIMEType: "text/markdown", + Text: string(content), + }, + }, nil + } +} + +func main() { + fx.New( + fxconfig.FxConfigModule, + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxmetrics.FxMetricsModule, + fxgenerate.FxGenerateModule, + fxmcpserver.FxMCPServerModule, + fx.Options( + fxmcpserver.AsMCPServerResource(NewReadmeResource), // registers the ReadmeResource as MCP resource + ), + ).Run() +} +``` + +To expose it, you need to ensure that the MCP server has the `resources` capability enabled: + +```yaml +# ./configs/config.yaml +modules: + mcp: + server: + capabilities: + resources: true # to expose MCP resources & resource templates (disabled by default) +``` + +#### Resource templates + +This module offers an [MCPServerResourceTemplate](server/registry.go) interface to implement to provide an [MCP resource template](https://modelcontextprotocol.io/docs/concepts/resources). + +You can use the `AsMCPServerResourceTemplate()` function to register an MCP resource template, or `AsMCPServerResourceTemplates()` to register several MCP resource templates at once. + +The dependencies of your MCP resource templates will be autowired. + +```go +package main + +import ( + "context" + + "github.com/foo/bar/internal/user" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxconfig" + "github.com/ankorstore/yokai/fxgenerate" + "github.com/ankorstore/yokai/fxlog" + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmetrics" + "github.com/ankorstore/yokai/fxtrace" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.uber.org/fx" +) + +type UserProfileResource struct { + repository *user.Respository +} + +func NewUserProfileResource(repository *user.Respository) *UserProfileResource { + return &UserProfileResource{ + repository: repository, + } +} + +func (r *UserProfileResource) Name() string { + return "user-profile" +} + +func (r *UserProfileResource) URI() string { + return "users://{id}/profile" +} + +func (r *UserProfileResource) Options() []mcp.ResourceTemplateOption { + return []mcp.ResourceTemplateOption{ + mcp.WithTemplateDescription("User profile"), + } +} + +func (r *UserProfileResource) Handle() server.ResourceTemplateHandlerFunc { + return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + // some user id extraction logic + userID := extractUserIDFromURI(request.Params.URI) + + // find user profile by user id + user, err := r.repository.Find(userID) + if err != nil { + return nil, err + } + + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: request.Params.URI, + MIMEType: "application/json", + Text: user, + }, + }, nil + } +} + +func main() { + fx.New( + fxconfig.FxConfigModule, + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxmetrics.FxMetricsModule, + fxgenerate.FxGenerateModule, + fxmcpserver.FxMCPServerModule, + fx.Options( + fxmcpserver.AsMCPServerResourceTemplate(NewUserProfileResource), // registers the UserProfileResource as MCP resource template + ), + ).Run() +} +``` + +To expose it, you need to ensure that the MCP server has the `resources` capability enabled: + +```yaml +# ./configs/config.yaml +modules: + mcp: + server: + capabilities: + resources: true # to expose MCP resources & resource templates (disabled by default) +``` + +#### Prompts + +This module offers an [MCPServerPrompt](server/registry.go) interface to implement to provide an [MCP prompt](https://modelcontextprotocol.io/docs/concepts/prompts). + +You can use the `AsMCPServerPrompt()` function to register an MCP prompt, or `AsMCPServerPrompts()` to register several MCP prompts at once. + +The dependencies of your MCP prompts will be autowired. + +```go +package main + +import ( + "context" + "os" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxconfig" + "github.com/ankorstore/yokai/fxgenerate" + "github.com/ankorstore/yokai/fxlog" + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmetrics" + "github.com/ankorstore/yokai/fxtrace" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.uber.org/fx" +) + +type GreetingPrompt struct { + config *config.Config +} + +func NewGreetingPrompt(config *config.Config) *GreetingPrompt { + return &GreetingPrompt{ + config: config, + } +} + +func (p *GreetingPrompt) Name() string { + return "greeting" +} + +func (p *GreetingPrompt) Options() []mcp.PromptOption { + return []mcp.PromptOption{ + mcp.WithPromptDescription("A friendly greeting prompt"), + mcp.WithArgument( + "name", + mcp.ArgumentDescription("Name of the person to greet"), + ), + } +} + +func (p *GreetingPrompt) Handle() server.PromptHandlerFunc { + return func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + name := request.Params.Arguments["name"] + if name == "" { + name = "friend" + } + + return mcp.NewGetPromptResult( + "A friendly greeting", + []mcp.PromptMessage{ + mcp.NewPromptMessage( + mcp.RoleAssistant, + mcp.NewTextContent(fmt.Sprintf("Hello, %s! I am %s. How can I help you today?", name, p.config.GetString("config.assistant.name"))), + ), + }, + ), nil + } +} + +func main() { + fx.New( + fxconfig.FxConfigModule, + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxmetrics.FxMetricsModule, + fxgenerate.FxGenerateModule, + fxmcpserver.FxMCPServerModule, + fx.Options( + fxmcpserver.AsMCPServerPrompt(NewGreetingPrompt), // registers the GreetingPrompt as MCP prompt + ), + ).Run() +} +``` + +To expose it, you need to ensure that the MCP server has the `prompts` capability enabled: + +```yaml +# ./configs/config.yaml +modules: + mcp: + server: + capabilities: + prompts: true # to expose MCP prompts (disabled by default) +``` + +#### Tools + +This module offers an [MCPServerTool](server/registry.go) interface to implement to provide an [MCP tool](https://modelcontextprotocol.io/docs/concepts/tools). + +You can use the `AsMCPServerTool()` function to register an MCP tool, or `AsMCPServerTools()` to register several MCP tools at once. + +The dependencies of your MCP tools will be autowired. + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxconfig" + "github.com/ankorstore/yokai/fxgenerate" + "github.com/ankorstore/yokai/fxlog" + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmetrics" + "github.com/ankorstore/yokai/fxtrace" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.uber.org/fx" +) + +type CalculatorTool struct { + config *config.Config +} + +func NewCalculatorTool(config *config.Config) *CalculatorTool { + return &CalculatorTool{ + config: config, + } +} + +func (t *CalculatorTool) Name() string { + return "calculator" +} + +func (t *CalculatorTool) Options() []mcp.ToolOption { + return []mcp.ToolOption{ + mcp.WithDescription("Perform basic arithmetic calculations"), + mcp.WithString( + "operation", + mcp.Required(), + mcp.Description("The arithmetic operation to perform"), + mcp.Enum("add", "subtract", "multiply", "divide"), + ), + mcp.WithNumber( + "x", + mcp.Required(), + mcp.Description("First number"), + ), + mcp.WithNumber( + "y", + mcp.Required(), + mcp.Description("Second number"), + ), + } +} + +func (t *CalculatorTool) Handle() server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if !t.config.GetBool("config.calculator.enabled") { + return nil, fmt.Errorf("calculator is not enabled") + } + + op := request.Params.Arguments["operation"].(string) + x := request.Params.Arguments["x"].(float64) + y := request.Params.Arguments["y"].(float64) + + var result float64 + switch op { + case "add": + result = x + y + case "subtract": + result = x - y + case "multiply": + result = x * y + case "divide": + if y == 0 { + return mcp.NewToolResultError("cannot divide by zero"), nil + } + + result = x / y + } + + return mcp.FormatNumberResult(result), nil + } +} + +func main() { + fx.New( + fxconfig.FxConfigModule, + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxmetrics.FxMetricsModule, + fxgenerate.FxGenerateModule, + fxmcpserver.FxMCPServerModule, + fx.Options( + fxmcpserver.AsMCPServerTool(NewCalculatorTool), // registers the CalculatorTool as MCP tool + ), + ).Run() +} +``` + +To expose it, you need to ensure that the MCP server has the `tools` capability enabled: + +```yaml +# ./configs/config.yaml +modules: + mcp: + server: + capabilities: + tools: true # to expose MCP tools (disabled by default) +``` + +### Testing + +This module provides a [MCPSSETestServer](fxmcpservertest/server.go) to enable you to easily test your exposed MCP capabilities. + +From this server, you can create a ready to use client via `StartClient()` to perform MCP requests, to functionally test your MCP server. + +You can then test it, considering `logs`, `traces` and `metrics` are enabled: + +```go +package internal_test + +import ( + "context" + "strings" + "testing" + + "github.com/ankorstore/yokai/fxconfig" + "github.com/ankorstore/yokai/fxgenerate" + "github.com/ankorstore/yokai/fxhttpserver" + "github.com/ankorstore/yokai/fxlog" + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmcpserver/fxmcpservertest" + "github.com/ankorstore/yokai/fxmetrics" + "github.com/ankorstore/yokai/fxtrace" + "github.com/ankorstore/yokai/log/logtest" + "github.com/ankorstore/yokai/trace/tracetest" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/attribute" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" +) + +func TestExample(t *testing.T) { + var testServer *fxmcpservertest.MCPSSETestServer + var logBuffer logtest.TestLogBuffer + var traceExporter tracetest.TestTraceExporter + var metricsRegistry *prometheus.Registry + + fxtest.New( + t, + fx.NopLogger, + fxconfig.FxConfigModule, + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxgenerate.FxGenerateModule, + fxmetrics.FxMetricsModule, + fxmcpserver.FxMCPServerModule, + fx.Populate(&testServer, &logBuffer, &traceExporter, &metricsRegistry), + ).RequireStart().RequireStop() + + // close the test server once done + defer testServer.Close() + + // start test client + testClient, err := testServer.StartClient(context.Background()) + assert.NoError(t, err) + + // close the test client once done + defer testClient.Close() + + // send MCP ping request + err = testClient.Ping(context.Background()) + assert.NoError(t, err) + + // assertion on the logs buffer + logtest.AssertHasLogRecord(t, logBuffer, map[string]interface{}{ + "level": "info", + "mcpMethod": "ping", + "mcpTransport": "sse", + "message": "MCP request success", + }) + + // assertion on the traces exporter + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP ping", + attribute.String("mcp.method", "ping"), + attribute.String("mcp.transport", "sse"), + ) + + // assertion on the metrics registry + expectedMetric := ` + # HELP mcp_server_requests_total Number of processed HTTP requests + # TYPE mcp_server_requests_total counter + mcp_server_requests_total{method="ping",status="success",target=""} 1 + ` + + err = testutil.GatherAndCompare( + metricsRegistry, + strings.NewReader(expectedMetric), + "mcp_server_requests_total", + ) + assert.NoError(t, err) +} +``` + +You can find more tests examples in this module own [tests](module_test.go). \ No newline at end of file diff --git a/fxmcpserver/fxmcpservertest/server.go b/fxmcpserver/fxmcpservertest/server.go new file mode 100644 index 00000000..aad9c2c8 --- /dev/null +++ b/fxmcpserver/fxmcpservertest/server.go @@ -0,0 +1,59 @@ +package fxmcpservertest + +import ( + "context" + "net/http/httptest" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type MCPSSETestServer struct { + config *config.Config + testServer *httptest.Server +} + +func NewMCPSSETestServer(cfg *config.Config, srv *server.MCPServer, hdl sse.MCPSSEServerContextHandler) *MCPSSETestServer { + testSrv := server.NewTestServer(srv, server.WithSSEContextFunc(hdl.Handle())) + + return &MCPSSETestServer{ + config: cfg, + testServer: testSrv, + } +} + +func (s *MCPSSETestServer) Close() { + s.testServer.Close() +} + +func (s *MCPSSETestServer) StartClient(ctx context.Context, options ...transport.ClientOption) (*client.Client, error) { + baseURL := s.testServer.URL + s.config.GetString("modules.mcp.server.transport.sse.sse_endpoint") + + cli, err := client.NewSSEMCPClient(baseURL, options...) + if err != nil { + return nil, err + } + + err = cli.Start(ctx) + if err != nil { + return nil, err + } + + initReq := mcp.InitializeRequest{} + initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initReq.Params.ClientInfo = mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + } + + _, err = cli.Initialize(ctx, initReq) + if err != nil { + return nil, err + } + + return cli, nil +} diff --git a/fxmcpserver/fxmcpservertest/server_test.go b/fxmcpserver/fxmcpservertest/server_test.go new file mode 100644 index 00000000..1deaacaf --- /dev/null +++ b/fxmcpserver/fxmcpservertest/server_test.go @@ -0,0 +1,49 @@ +package fxmcpservertest_test + +import ( + "context" + "testing" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/fxmcpservertest" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/generate/uuid" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/log/logtest" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/sdk/trace" +) + +func TestMCPSSETestServer(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../testdata/config"), + ) + assert.NoError(t, err) + + gm := uuid.NewDefaultUuidGenerator() + + tp := trace.NewTracerProvider() + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + hdl := sse.NewDefaultMCPSSEServerContextHandler(gm, tp, lg) + + mcpSrv := server.NewMCPServer("test-server", "1.0.0") + + srv := fxmcpservertest.NewMCPSSETestServer(cfg, mcpSrv, hdl) + defer srv.Close() + + cli, err := srv.StartClient(context.Background()) + assert.NoError(t, err) + + err = cli.Ping(context.Background()) + assert.NoError(t, err) + + err = cli.Close() + assert.NoError(t, err) +} diff --git a/fxmcpserver/info.go b/fxmcpserver/info.go index cf1c0e71..b8104d50 100644 --- a/fxmcpserver/info.go +++ b/fxmcpserver/info.go @@ -7,6 +7,7 @@ import ( "github.com/ankorstore/yokai/fxmcpserver/server/stdio" ) +// MCPServerModuleInfo is the MCP server module info. type MCPServerModuleInfo struct { config *config.Config registry *server.MCPServerRegistry @@ -14,6 +15,7 @@ type MCPServerModuleInfo struct { stdioServer *stdio.MCPStdioServer } +// NewMCPServerModuleInfo returns a new MCPServerModuleInfo instance. func NewMCPServerModuleInfo( config *config.Config, registry *server.MCPServerRegistry, diff --git a/fxmcpserver/module.go b/fxmcpserver/module.go index ee731082..e95780e9 100644 --- a/fxmcpserver/module.go +++ b/fxmcpserver/module.go @@ -4,6 +4,7 @@ import ( "context" "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/fxmcpservertest" fs "github.com/ankorstore/yokai/fxmcpserver/server" "github.com/ankorstore/yokai/fxmcpserver/server/sse" "github.com/ankorstore/yokai/fxmcpserver/server/stdio" @@ -25,6 +26,7 @@ var FxMCPServerModule = fx.Module( ProvideMCPServerRegistry, ProvideMCPServer, ProvideMCPSSEServer, + ProvideMCPSSETestServer, ProvideMCPStdioServer, // module overridable dependencies fx.Annotate( @@ -190,6 +192,19 @@ func ProvideMCPSSEServer(p ProvideMCPSSEServerParam) *sse.MCPSSEServer { return sseServer } +// ProvideMCPSSETestServerParam allows injection of the required dependencies in ProvideMCPSSETestServer. +type ProvideMCPSSETestServerParam struct { + fx.In + Config *config.Config + MCPServer *server.MCPServer + MCPSSEServerContextHandler sse.MCPSSEServerContextHandler +} + +// ProvideMCPSSETestServer provides the fxmcpservertest.MCPSSETestServer. +func ProvideMCPSSETestServer(p ProvideMCPSSEServerParam) *fxmcpservertest.MCPSSETestServer { + return fxmcpservertest.NewMCPSSETestServer(p.Config, p.MCPServer, p.MCPSSEServerContextHandler) +} + // ProvideDefaultMCPStdioContextHandlerParam allows injection of the required dependencies in ProvideDefaultMCPStdioServerContextHandler. type ProvideDefaultMCPStdioContextHandlerParam struct { fx.In diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go index b7c51f15..620e5426 100644 --- a/fxmcpserver/module_test.go +++ b/fxmcpserver/module_test.go @@ -4,15 +4,14 @@ import ( "context" "strings" "testing" - "time" "github.com/ankorstore/yokai/fxconfig" "github.com/ankorstore/yokai/fxgenerate" "github.com/ankorstore/yokai/fxhealthcheck" "github.com/ankorstore/yokai/fxlog" "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmcpserver/fxmcpservertest" fs "github.com/ankorstore/yokai/fxmcpserver/server" - "github.com/ankorstore/yokai/fxmcpserver/server/sse" "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt" "github.com/ankorstore/yokai/fxmcpserver/testdata/resource" "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate" @@ -22,9 +21,7 @@ import ( "github.com/ankorstore/yokai/healthcheck" "github.com/ankorstore/yokai/log/logtest" "github.com/ankorstore/yokai/trace/tracetest" - "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" @@ -38,8 +35,7 @@ func TestMCPServerModule(t *testing.T) { t.Setenv("APP_ENV", "test") t.Setenv("APP_CONFIG_PATH", "testdata/config") - var mcpServer *server.MCPServer - var handler sse.MCPSSEServerContextHandler + var testServer *fxmcpservertest.MCPSSETestServer var checker *healthcheck.Checker var logBuffer logtest.TestLogBuffer var traceExporter tracetest.TestTraceExporter @@ -60,80 +56,27 @@ func TestMCPServerModule(t *testing.T) { fxmcpserver.AsMCPServerPrompts(prompt.NewSimpleTestPrompt), fxmcpserver.AsMCPServerResources(resource.NewSimpleTestResource), fxmcpserver.AsMCPServerResourceTemplates(resourcetemplate.NewSimpleTestResourceTemplate), - fxhealthcheck.AsCheckerProbe(fs.NewMCPServerProbe), ), fx.Supply(fx.Annotate(context.Background(), fx.As(new(context.Context)))), - fx.Populate(&mcpServer, &handler, &checker, &logBuffer, &traceExporter, &metricsRegistry), + fx.Populate(&testServer, &checker, &logBuffer, &traceExporter, &metricsRegistry), ).RequireStart().RequireStop() - // create test server - testServer := server.NewTestServer(mcpServer, server.WithSSEContextFunc(handler.Handle())) defer testServer.Close() + ctx := context.Background() + // health check checkResult := checker.Check(context.Background(), healthcheck.Readiness) assert.False(t, checkResult.Success) assert.Equal(t, "MCP SSE server is not running", checkResult.ProbesResults["mcpserver"].Message) - // create test client - testClient, err := client.NewSSEMCPClient(testServer.URL + "/sse") + // start test client + testClient, err := testServer.StartClient(ctx) assert.NoError(t, err) - // start the client - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - err = testClient.Start(ctx) - assert.NoError(t, err) defer testClient.Close() - logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ - "level": "info", - "message": "MCP session registered", - }) - - // send initialize request - initializeRequest := mcp.InitializeRequest{} - initializeRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION - initializeRequest.Params.ClientInfo = mcp.Implementation{ - Name: "test-client", - Version: "1.0.0", - } - - initializeResult, err := testClient.Initialize(ctx, mcp.InitializeRequest{}) - assert.NoError(t, err) - - assert.Equal(t, "test-server", initializeResult.ServerInfo.Name) - assert.Equal(t, "1.0.0", initializeResult.ServerInfo.Version) - - logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ - "level": "info", - "mcpMethod": "initialize", - "mcpTransport": "sse", - "message": "MCP request success", - }) - - tracetest.AssertHasTraceSpan( - t, - traceExporter, - "MCP initialize", - attribute.String("mcp.method", "initialize"), - attribute.String("mcp.transport", "sse"), - ) - - expectedMetric := ` - # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests - # TYPE foo_bar_mcp_server_requests_total counter - foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1 - ` - err = testutil.GatherAndCompare( - metricsRegistry, - strings.NewReader(expectedMetric), - "foo_bar_mcp_server_requests_total", - ) - assert.NoError(t, err) - // send success tools/call request expectedRequest := `{"method":"tools/call","params":{"name":"advanced-test-tool","arguments":{"shouldFail":"false"}}}` expectedResponse := `{"content":[{"type":"text","text":"test"}]}` @@ -169,7 +112,7 @@ func TestMCPServerModule(t *testing.T) { attribute.String("mcp.transport", "sse"), ) - expectedMetric = ` + expectedMetric := ` # HELP foo_bar_mcp_server_requests_total Number of processed MCP requests # TYPE foo_bar_mcp_server_requests_total counter foo_bar_mcp_server_requests_total{method="initialize",status="success",target=""} 1