diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 0b6f1aea..c2d4fa82 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -32,6 +32,7 @@ jobs: - "sql" - "trace" - "worker" + - "fxclock" - "fxcore" - "fxconfig" - "fxcron" @@ -41,6 +42,7 @@ jobs: - "fxhttpclient" - "fxhttpserver" - "fxlog" + - "fxmcpserver" - "fxmetrics" - "fxorm" - "fxsql" diff --git a/.github/workflows/fxclock-ci.yml b/.github/workflows/fxclock-ci.yml new file mode 100644 index 00000000..9419805b --- /dev/null +++ b/.github/workflows/fxclock-ci.yml @@ -0,0 +1,32 @@ +name: "fxclock-ci" + +on: + push: + branches: + - "feat**" + - "fix**" + - "hotfix**" + - "chore**" + paths: + - "fxclock/**.go" + - "fxclock/go.mod" + - "fxclock/go.sum" + pull_request: + types: + - opened + - synchronize + - reopened + branches: + - main + paths: + - "fxclock/**.go" + - "fxclock/go.mod" + - "fxclock/go.sum" + +jobs: + ci: + uses: ./.github/workflows/common-ci.yml + secrets: inherit + with: + module: "fxclock" + go_version: "1.21" 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/.release-please-manifest.json b/.release-please-manifest.json index 81285657..63f98f8c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{"config":"1.5.0","log":"1.2.0","generate":"1.3.0","trace":"1.3.0","healthcheck":"1.1.0","httpclient":"1.4.0","httpserver":"1.6.0","orm":"1.1.0","fxconfig":"1.3.0","fxgenerate":"1.3.0","fxlog":"1.1.0","fxtrace":"1.2.0","fxmetrics":"1.2.0","fxhealthcheck":"1.1.0","fxorm":"1.2.0","fxhttpclient":"1.4.0","fxhttpserver":"1.7.0","fxcore":"1.10.0","worker":"1.2.0","fxworker":"1.1.0","fxcron":"1.1.0","grpcserver":"1.2.0","fxgrpcserver":"1.3.0","sql":"1.1.0","fxsql":"1.3.0","fxvalidator":"1.0.0"} \ No newline at end of file +{"config":"1.5.0","log":"1.2.0","generate":"1.3.0","trace":"1.4.0","healthcheck":"1.1.0","httpclient":"1.5.0","httpserver":"1.6.0","orm":"1.1.0","fxconfig":"1.3.0","fxgenerate":"1.3.0","fxlog":"1.1.0","fxtrace":"1.2.0","fxmetrics":"1.2.0","fxhealthcheck":"1.1.1","fxorm":"1.2.0","fxhttpclient":"1.4.0","fxhttpserver":"1.7.1","fxcore":"1.12.0","worker":"1.2.0","fxworker":"1.1.1","fxcron":"1.1.1","grpcserver":"1.2.0","fxgrpcserver":"1.3.1","sql":"1.1.0","fxsql":"1.3.0","fxvalidator":"1.0.0","fxclock":"1.0.0","fxmcpserver":"1.6.0"} \ No newline at end of file diff --git a/README.md b/README.md index 0ba8f33f..6a7ad0df 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Yokai provides ready to use `application templates` to start your projects: - for [gRPC applications](https://ankorstore.github.io/yokai/getting-started/grpc-application) - for [HTTP applications](https://ankorstore.github.io/yokai/getting-started/http-application) +- for [MCP applications](https://ankorstore.github.io/yokai/getting-started/mcp-application) - for [worker applications](https://ankorstore.github.io/yokai/getting-started/worker-application) ## Showroom @@ -86,6 +87,7 @@ Yokai provides a [showroom repository](https://github.com/ankorstore/yokai-showr - [gRPC demo application](https://github.com/ankorstore/yokai-showroom/tree/main/grpc-demo) - [HTTP demo application](https://github.com/ankorstore/yokai-showroom/tree/main/http-demo) +- [MCP demo application](https://github.com/ankorstore/yokai-showroom/tree/main/mcp-demo) - [worker demo application](https://github.com/ankorstore/yokai-showroom/tree/main/worker-demo) ## Contributing diff --git a/docs/assets/images/dash-tasks-dark.png b/docs/assets/images/dash-tasks-dark.png new file mode 100644 index 00000000..3df0cdf4 Binary files /dev/null and b/docs/assets/images/dash-tasks-dark.png differ diff --git a/docs/assets/images/dash-tasks-light.png b/docs/assets/images/dash-tasks-light.png new file mode 100644 index 00000000..f189f0b2 Binary files /dev/null and b/docs/assets/images/dash-tasks-light.png differ diff --git a/docs/demos/grpc-application.md b/docs/demos/grpc-application.md index 32d185e2..c1ce57bf 100644 --- a/docs/demos/grpc-application.md +++ b/docs/demos/grpc-application.md @@ -22,11 +22,12 @@ This demo application is following the [recommended project layout](https://go.d - `cmd/`: entry points - `configs/`: configuration files -- `internal/`: - - `interceptor/`: gRPC interceptors - - `service/`: gRPC services - - `bootstrap.go`: bootstrap - - `register.go`: dependencies registration + - `internal/`: + - `api/`: gRPC API + - `interceptor/`: gRPC interceptors + - `service/`: gRPC services + - `bootstrap.go`: bootstrap + - `register.go`: dependencies registration - `proto/`: protobuf definition and stubs ### Makefile @@ -76,7 +77,7 @@ This demo application also provides [reflection](../modules/fxgrpcserver.md#refl ### Authentication -This demo application provides example [authentication interceptors](https://github.com/ankorstore/yokai-showroom/tree/main/grpc-demo/internal/interceptor/authentication.go). +This demo application provides example [authentication interceptors](https://github.com/ankorstore/yokai-showroom/tree/main/grpc-demo/internal/api/interceptor/authentication.go). You can enable authentication in the application [configuration file](https://github.com/ankorstore/yokai-showroom/tree/main/grpc-demo/configs/config.yaml) with `config.authentication.enabled=true`. diff --git a/docs/demos/http-application.md b/docs/demos/http-application.md index 64b222c2..dd4d2bfe 100644 --- a/docs/demos/http-application.md +++ b/docs/demos/http-application.md @@ -27,11 +27,13 @@ This demo application is following the [recommended project layout](https://go.d - `migrations/`: database migrations - `seeds/`: database seeds - `internal/`: - - `handler/`: HTTP handlers - - `middleware/`: HTTP middlewares - - `model/`: models - - `repository/`: models repositories - - `service/`: services + - `api/`: HTTP API + - `handler/`: HTTP handlers + - `middleware/`: HTTP middlewares + - `domain/`: domain + - `model.go`: gophers model + - `repository.go`: gophers repository + - `service.go`: gophers service - `bootstrap.go`: bootstrap - `register.go`: dependencies registration - `router.go`: routing registration @@ -81,7 +83,7 @@ On [http://localhost:8080](http://localhost:8080), you can use: ### Authentication -This demo application provides an example [authentication middleware](https://github.com/ankorstore/yokai-showroom/blob/main/http-demo/internal/middleware/authentication.go). +This demo application provides an example [authentication middleware](https://github.com/ankorstore/yokai-showroom/blob/main/http-demo/internal/api/middleware/authentication.go). You can enable authentication in the application [configuration file](https://github.com/ankorstore/yokai-showroom/blob/main/http-demo/configs/config.yaml) with `config.authentication.enabled=true`. diff --git a/docs/demos/mcp-application.md b/docs/demos/mcp-application.md new file mode 100644 index 00000000..5a7fc27f --- /dev/null +++ b/docs/demos/mcp-application.md @@ -0,0 +1,111 @@ +--- +title: Demos - MCP application +icon: material/folder-eye-outline +--- + +# :material-folder-eye-outline: Demo - MCP application + +> Yokai's [showroom](https://github.com/ankorstore/yokai-showroom) provides an [MCP server demo application](https://github.com/ankorstore/yokai-showroom/tree/main/mcp-demo). + +## Overview + +This [MCP server demo application](https://github.com/ankorstore/yokai-showroom/tree/main/mcp-demo) is a simple [MCP server](https://modelcontextprotocol.io/introduction) to manage [gophers](https://go.dev/blog/gopher). + +It provides: + +- a [Yokai](https://github.com/ankorstore/yokai) application container, with the [MCP server](../modules/fxmcpserver.md) and [SQL](../modules/fxsql.md) modules to offer the gophers MCP server +- a [MySQL](https://www.mysql.com/) container to store the gophers +- a [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) container to interact with the MCP server +- a [Jaeger](https://www.jaegertracing.io/) container to collect the application traces + +### Layout + +This demo application is following the [recommended project layout](https://go.dev/doc/modules/layout#server-project): + +- `cmd/`: entry points +- `configs/`: configuration files +- `db/`: + - `migrations/`: database migrations + - `seeds/`: database seeds +- `internal/`: + - `domain/`: domain + - `model.go`: gophers model + - `repository.go`: gophers repository + - `service.go`: gophers service + - `mcp/`: MCP registrations + - `prompt/`: MCP prompts + - `resource/`: MCP resources + - `tool/`: MCP tools + - `bootstrap.go`: bootstrap + - `register.go`: dependencies registration + +### Makefile + +This demo application provides a `Makefile`: + +``` +make up # start the docker compose stack +make down # stop the docker compose stack +make logs # stream the docker compose stack logs +make fresh # refresh the docker compose stack +make migrate # run database migrations +make test # run tests +make lint # run linter +``` + +## Usage + +### Start the application + +To start the application, simply run: + +```shell +make fresh +``` + +After a short moment, the application will offer: + +- [http://localhost:8080/mcp](http://localhost:8080/mcp): application MCP server (Streamable HTTP) +- [http://localhost:8081](http://localhost:8081): application core dashboard +- [http://localhost:6274](http://localhost:6274): MCP inspector +- [http://localhost:16686](http://localhost:16686): jaeger UI + +### Interact with the application + +#### MCP inspector + +You can use the provided [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector), available on [http://localhost:6274](http://localhost:6274). + +To connect to the MCP server, use: + +- `Streamable HTTP` as transport type +- `http://mcp-demo-app:8080/mcp` as URL + +Then simply click `Connect`: from there, you will be able to interact with the resources, prompts and tools of the application. + +#### MCP hosts + +If you use MCP compatible applications like [Cursor](https://www.cursor.com/), or [Claude desktop](https://claude.ai/download), you can register this application as MCP server: + +```json +{ + "mcpServers": { + "mcp-demo-app": { + "url": "http://localhost:8080/mcp" + } + } +} +``` + +Note, if you client does not support remote MCP servers, you can use a [local proxy](https://developers.cloudflare.com/agents/guides/test-remote-mcp-server/#connect-your-remote-mcp-server-to-claude-desktop-via-a-local-proxy): + +```json +{ + "mcpServers": { + "mcp-demo-app": { + "command": "npx", + "args": ["mcp-remote", "http://localhost:8080/mcp"] + } + } +} +``` diff --git a/docs/getting-started/mcp-application.md b/docs/getting-started/mcp-application.md new file mode 100644 index 00000000..7555d2b5 --- /dev/null +++ b/docs/getting-started/mcp-application.md @@ -0,0 +1,80 @@ +--- +title: Getting started - MCP application +icon: material/rocket-launch-outline +--- + +# :material-rocket-launch-outline: Getting started - MCP application + +> Yokai provides a ready to use [MCP server application template](https://github.com/ankorstore/yokai-mcp-template) to start your MCP projects. + +## Overview + +The [MCP server application template](https://github.com/ankorstore/yokai-mcp-template) provides: + +- a ready to extend [Yokai](https://github.com/ankorstore/yokai) application, with the [MCP server](../modules/fxmcpserver.md) module installed +- a ready to use [dev environment](https://github.com/ankorstore/yokai-http-template/blob/main/docker-compose.yaml), based on [Air](https://github.com/air-verse/air) (for live reloading) +- a ready to use [Dockerfile](https://github.com/ankorstore/yokai-http-template/blob/main/Dockerfile) for production +- some examples of [MCP tool](https://github.com/ankorstore/yokai-mcp-template/blob/main/internal/tool/example.go) and [test](https://github.com/ankorstore/yokai-mcp-template/blob/main/internal/tool/example_test.go) to get started + +### Layout + +This template is following the [recommended project layout](https://go.dev/doc/modules/layout#server-project): + +- `cmd/`: entry points +- `configs/`: configuration files +- `internal/`: + - `tool/`: MCP tool and test examples + - `bootstrap.go`: bootstrap + - `register.go`: dependencies registration + +### Makefile + +This template provides a [Makefile](https://github.com/ankorstore/yokai-http-template/blob/main/Makefile): + +``` +make up # start the docker compose stack +make down # stop the docker compose stack +make logs # stream the docker compose stack logs +make fresh # refresh the docker compose stack +make test # run tests +make lint # run linter +``` + +## Installation + +### With GitHub + +You can create your repository [using the GitHub template](https://github.com/new?template_name=yokai-mcp-template&template_owner=ankorstore). + +It will automatically rename your project resources, this operation can take a few minutes. + +Once ready, after cloning and going into your repository, simply run: + +```shell +make fresh +``` + +### With gonew + +You can install [gonew](https://go.dev/blog/gonew), and simply run: + +```shell +gonew github.com/ankorstore/yokai-mcp-template github.com/foo/bar +cd bar +make fresh +``` + +## Usage + +Once ready, the application will be available on: + +- [http://localhost:8080/sse](http://localhost:8080/sse) for the application MCP server +- [http://localhost:8081](http://localhost:8081) for the application core dashboard + +## Going further + +To go further, you can: + +- check the [MCP server](../modules/fxmcpserver.md) module documentation to learn more about its features +- follow the [MCP application tutorial](../tutorials/mcp-application.md) to create, step by step, an MCP server application +- test the [MCP demo application](../demos/mcp-application.md) to see all this in action diff --git a/docs/index.md b/docs/index.md index a64b160d..dc05ec92 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,4 +64,5 @@ Yokai provides ready to use `application templates` to start your projects: - for [gRPC applications](getting-started/grpc-application.md) - for [HTTP applications](getting-started/http-application.md) +- for [MCP applications](getting-started/mcp-application.md) - for [worker applications](getting-started/worker-application.md) \ No newline at end of file diff --git a/docs/modules/fxclock.md b/docs/modules/fxclock.md new file mode 100644 index 00000000..28d4fc97 --- /dev/null +++ b/docs/modules/fxclock.md @@ -0,0 +1,137 @@ +--- +title: Modules - Clock +icon: material/cube-outline +--- + +# :material-cube-outline: Clock Module + +[![ci](https://github.com/ankorstore/yokai/actions/workflows/fxclock-ci.yml/badge.svg)](https://github.com/ankorstore/yokai/actions/workflows/fxclock-ci.yml) +[![go report](https://goreportcard.com/badge/github.com/ankorstore/yokai/fxclock)](https://goreportcard.com/report/github.com/ankorstore/yokai/fxclock) +[![codecov](https://codecov.io/gh/ankorstore/yokai/graph/badge.svg?token=ghUBlFsjhR&flag=fxclock)](https://app.codecov.io/gh/ankorstore/yokai/tree/main/fxclock) +[![Deps](https://img.shields.io/badge/osi-deps-blue)](https://deps.dev/go/github.com%2Fankorstore%2Fyokai%2Ffxclock) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/ankorstore/yokai/fxclock)](https://pkg.go.dev/github.com/ankorstore/yokai/fxclock) + +## Overview + +Yokai provides a [fxclock](https://github.com/ankorstore/yokai/tree/main/fxclock) module, that you can use to control time. + +It wraps the [clockwork](https://github.com/jonboulle/clockwork) module. + +## Installation + +First install the module: + +```shell +go get github.com/ankorstore/yokai/fxclock +``` + +Then activate it in your application bootstrapper: + +```go title="internal/bootstrap.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxcore" + "github.com/ankorstore/yokai/fxclock" +) + +var Bootstrapper = fxcore.NewBootstrapper().WithOptions( + // modules registration + fxclock.FxClockModule, + // ... +) +``` + +## Usage + +This module provides a [clockwork.Clock](https://github.com/jonboulle/clockwork) instance, ready to inject in your code. + +This is particularly useful if you need to control time (set time, fast-forward, ...). + +For example: + +```go title="internal/service/example.go" +package service + +import ( + "github.com/jonboulle/clockwork" +) + +type ExampleService struct { + clock clockwork.Clock +} + +func NewExampleService(clock clockwork.Clock) *ExampleService { + return &ExampleService{ + clock: clock, + } +} + +func (s *ExampleService) Now() string { + return s.clock.Now().String() +} +``` + +See the underlying vendor [documentation](https://github.com/jonboulle/clockwork) for more details. + +## Testing + +This module provides a [*clockwork.FakeClock](https://github.com/jonboulle/clockwork) instance, that will be automatically injected as `clockwork.Clock` in your constructors in `test` mode. + +### Global time + +By default, the fake clock is set to `time.Now()` (your test execution time). + +You can configure the global time in your test in your testing configuration file (for all your tests), in [RFC3339](https://datatracker.ietf.org/doc/html/rfc3339) format: + +```yaml title="configs/config_test.yaml" +modules: + clock: + test: + time: "2006-01-02T15:04:05Z07:00" # time in RFC3339 format +``` + +You can also [override this value](https://ankorstore.github.io/yokai/modules/fxconfig/#env-var-substitution), per test, by setting the `MODULES_CLOCK_TEST_TIME` env var. + +### Time control + +You can `populate` the [*clockwork.FakeClock](https://github.com/jonboulle/clockwork) from your test to control time: + +```go title="internal/service/example_test.go" +package service_test + +import ( + "testing" + "time" + + "github.com/ankorstore/yokai/fxsql" + "github.com/foo/bar/internal" + "github.com/foo/bar/internal/service" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "go.uber.org/fx" +) + +func TestExampleService(t *testing.T) { + testTime := "2025-03-30T12:00:00Z" + expectedTime, err := time.Parse(time.RFC3339, testTime) + assert.NoError(t, err) + + t.Setenv("MODULES_CLOCK_TEST_TIME", testTime) + + var svc service.ExampleService + var clock *clockwork.FakeClock + + internal.RunTest(t, fx.Populate(&svc, &clock)) + + // current time as configured above + assert.Equal(t, expectedTime, svc.Now()) // 2025-03-30T12:00:00Z + + clock.Advance(5 * time.Hour) + + // current time is now advanced by 5 hours + assert.Equal(t, expectedTime.Add(5*time.Hour), svc.Now()) // 2025-03-30T17:00:00Z +} +``` + +See [tests example](https://github.com/ankorstore/yokai/blob/main/fxclock/module_test.go) for more details. diff --git a/docs/modules/fxcore.md b/docs/modules/fxcore.md index 519e5a33..e180b203 100644 --- a/docs/modules/fxcore.md +++ b/docs/modules/fxcore.md @@ -46,6 +46,7 @@ When you use a Yokai `application template`, you have nothing to install, it's r modules: core: server: + expose: true # to expose the core http server, disabled by default address: ":8081" # core http server listener address (default :8081) errors: obfuscate: false # to obfuscate error messages on the core http server responses @@ -98,6 +99,9 @@ modules: liveness: expose: true # to expose health check liveness route, disabled by default path: /livez # health check liveness route path (default /livez) + tasks: + expose: true # to expose tasks route, disabled by default + path: /tasks/:name # tasks route path (default /tasks/:name) debug: config: expose: true # to expose debug config route @@ -254,19 +258,181 @@ If `modules.core.server.dashboard=true`, the core dashboard is available on the ![](../../assets/images/dash-core-light.png#only-light) ![](../../assets/images/dash-core-dark.png#only-dark) -From there, you can get: +Since it's served on a dedicated port, you can safely decide to +leave it enabled on production, to not expose it to the public, and access it +via [port forward](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/). -- an overview of your application -- information and tooling about your application: build, config, metrics, pprof, etc. -- access to the configured health check endpoints -- access to the loaded modules information (when exposed) +### Core -The core dashboard is made for `development` purposes. +The `Core` section of the dashboard offers you information about: + +- `Build`: environment and Go information about your application +- `Config`: resolved configuration +- `Metrics`: exposed metrics +- `Routes`: routes of the core dashboard +- `Pprof`: pprof page +- `Stats`: statistics page + +### Health Check + +The `Healthcheck` section of the dashboard offers you the possibility to trigger the health check endpoints, depending on their configuration. + +You must ensure the health checks are exposed: + +```yaml title="configs/config.yaml" +modules: + core: + server: + healthcheck: + startup: + expose: true # to expose health check startup route, disabled by default + path: /healthz # health check startup route path (default /healthz) + readiness: + expose: true # to expose health check readiness route, disabled by default + path: /readyz # health check readiness route path (default /readyz) + liveness: + expose: true # to expose health check liveness route, disabled by default + path: /livez # health check liveness route path (default /livez) + +``` + +See the [Health Check](https://ankorstore.github.io/yokai/modules/fxhealthcheck/) module documentation for more information. + +### Tasks + +If you need to execute one shot / private operations (like flush a cache, trigger an export, etc.) but don't want to expose an endpoint or a command for this, you can create a task. + +Yokai will collect them, and make them available in the core dashboard interface, under the `Tasks` section. + +This is particularly useful for admin / maintenance purposes, without exposing those to your end users. + +First, you must ensure the tasks are exposed: + +```yaml title="configs/config.yaml" +modules: + core: + server: + tasks: + expose: true # to expose tasks route, disabled by default + path: /tasks/:name # tasks route path (default /tasks/:name) + +``` + +Then, provide a [Task](https://github.com/ankorstore/yokai/blob/main/fxcore/task.go) implementation: + +```go title="internal/tasks/example.go" +package tasks + +import ( + "context" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxcore" +) + +var _ fxcore.Task = (*ExampleTask)(nil) + +type ExampleTask struct { + config *config.Config +} + +func NewExampleTask(config *config.Config) *ExampleTask { + return &ExampleTask{ + config: config, + } +} + +func (t *ExampleTask) Name() string { + return "example" +} + +func (t *ExampleTask) Run(ctx context.Context, input []byte) fxcore.TaskResult { + return fxcore.TaskResult{ + Success: true, // task execution status + Message: "example message", // task execution message + Details: map[string]any{ // optional task execution details + "app": t.config.AppName(), + "input": string(input), + }, + } +} +``` + +Then, register the task with `AsTask()`: + +```go title="internal/register.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxcore" + "github.com/foo/bar/internal/tasks" + "go.uber.org/fx" +) + +func Register() fx.Option { + return fx.Options( + // register the ExampleTask (will auto wire dependencies) + fxcore.AsTask(tasks.NewExampleTask), + // ... + ) +} +``` + +Note: you can also use `AsTasks()` to register several tasks at once. + +It'll be then available on the core dashboard for execution: + +![](../../assets/images/dash-tasks-light.png#only-light) +![](../../assets/images/dash-tasks-dark.png#only-dark) + +### Modules + +The `Modules` section of the dashboard offers you the possibility to check the details of the modules exposing information to the core. + +If you want your module to expose information in this section, you can provide a [FxModuleInfo](https://github.com/ankorstore/yokai/blob/main/fxcore/info.go) implementation: + +```go title="internal/info.go" +package internal + +type ExampleModuleInfo struct {} + +func (i *ExampleModuleInfo) Name() string { + return "example" +} + +func (i *ExampleModuleInfo) Data() map[string]any { + return map[string]any{ + "example": "value", + } +} +``` + +and then register it in the `core-module-infos` group: + +```go title="internal/register.go" +package internal + +import ( + "go.uber.org/fx" +) + +func Register() fx.Option { + return fx.Options( + // register the ExampleModuleInfo in the core dashboard + fx.Provide( + fx.Annotate( + ExampleModuleInfo, + fx.As(new(interface{})), + fx.ResultTags(`group:"core-module-infos"`), + ), + ), + // ... + ) +} +``` + +See [example](https://github.com/ankorstore/yokai/blob/main/fxhttpserver/info.go). -But since it's served on a dedicated port, you can safely decide to -leave it enabled on production, to not expose it to the public, and access it -via [port forward](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/) -for example. ## Testing diff --git a/docs/modules/fxhttpclient.md b/docs/modules/fxhttpclient.md index 244a5dcc..b6c8ab2e 100644 --- a/docs/modules/fxhttpclient.md +++ b/docs/modules/fxhttpclient.md @@ -206,4 +206,92 @@ http_client_requests_total{method="GET",status="2xx",host="https://example.com", ## Testing -See [net/http/httptest](https://pkg.go.dev/net/http/httptest) documentation. \ No newline at end of file +This module provides a [httpclienttest.NewTestHTTPServer()](https://github.com/ankorstore/yokai/blob/main/httpclient/httpclienttest/server.go) helper for testing your clients against a test server, that allows you: + +- to define test HTTP roundtrips: a couple of test aware functions to define the request and the response behavior +- to configure several test HTTP roundtrips if you need to test successive calls + +To use it: + +```go title="internal/service/example_test.go" +package service_test + +import ( + "net/http" + "testing" + + "github.com/ankorstore/yokai/httpclient" + "github.com/ankorstore/yokai/httpclient/httpclienttest" + "github.com/stretchr/testify/assert" +) + +func TestHTTPClient(t *testing.T) { + t.Parallel() + + // retrieve your client + var client *http.Client + + // test server preparation + testServer := httpclienttest.NewTestHTTPServer( + t, + // configures a roundtrip for the 1st client call (/foo) + httpclienttest.WithTestHTTPRoundTrip( + // func to configure / assert on the client request + func(tb testing.TB, req *http.Request) error { + tb.Helper() + + // performs some assertions + assert.Equal(tb, "/foo", req.URL.Path) + + // returning an error here will make the test fail, if needed + return nil + }, + // func to configure / assert on the response for the client + func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + // prepares the response for the client + w.Header.Set("foo", "bar") + + // performs some assertions + assert.Equal(tb, "bar", w.Header.Get("foo")) + + // returning an error here will make the test fail, if needed + return nil + }, + ), + // configures a roundtrip for the 2nd client call (/bar) + httpclienttest.WithTestHTTPRoundTrip( + // func to configure / assert on the client request + func(tb testing.TB, req *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/bar", req.URL.Path) + + return nil + }, + // func to configure / assert on the response for the client + func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + w.WriteHeader(http.StatusInternalServerError) + + return nil + }, + ), + ) + + // 1st client call (/foo) + resp, err := client.Get(testServer.URL + "/foo") + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "bar", resp.Header.Get("foo")) + + // 2nd client call (/bar) + resp, err = client.Get(testServer.URL + "/bar") + assert.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} +``` + +You can find more complete examples in the module [tests](https://github.com/ankorstore/yokai/blob/main/httpclient/httpclienttest/server_test.go). diff --git a/docs/modules/fxmcpserver.md b/docs/modules/fxmcpserver.md new file mode 100644 index 00000000..b33effeb --- /dev/null +++ b/docs/modules/fxmcpserver.md @@ -0,0 +1,929 @@ +--- +title: Modules - MCP Server +icon: material/cube-outline +--- + +# :material-cube-outline: 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) + +## Overview + +Yokai provides a [fxmcpserver](https://github.com/ankorstore/yokai/tree/main/fxmcpserver) module, offering an [MCP server](https://modelcontextprotocol.io/introduction) to your application. + +It wraps the [mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) module. + +It comes 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 register MCP Streamable HTTP and SSE server context hooks +- possibility to expose the MCP server via Streamable HTTP (remote), HTTP SSE (remote) and Stdio (local) + +## Installation + +First, install the module: + +```shell +go get github.com/ankorstore/yokai/fxmcpserver +``` + +Then activate it in your application bootstrapper: + +```go title="internal/bootstrap.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxcore" + "github.com/ankorstore/yokai/fxmcpserver" +) + +var Bootstrapper = fxcore.NewBootstrapper().WithOptions( + // modules registration + fxmcpserver.FxMCPServerModule, + // ... +) +``` + +## Configuration + +```yaml title="configs/config.yaml" +modules: + 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 and resource templates (disabled by default) + prompts: true # to expose MCP prompts (disabled by default) + tools: true # to expose MCP tools (disabled by default) + transport: + stream: + expose: true # to remotely expose the MCP server via Streamable HTTP (disabled by default) + address: ":8083" # exposition address (":8083" by default) + stateless: false # stateless server mode (disabled by default) + base_path: "/mcp" # base path ("/mcp" by default) + keep_alive: true # to keep the connections alive + keep_alive_interval: 10 # keep alive interval in seconds (10 by default) + 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: true # 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 +``` + +## Usage + +This module offers the possibility to easily register MCP resources, resource templates, prompts and tools. + +### Resources registration + +This module offers an [MCPServerResource](https://github.com/ankorstore/yokai/blob/main/fxmcpserver/server/registry.go) interface to implement to provide an [MCP resource](https://modelcontextprotocol.io/docs/concepts/resources). + +For example, an MCP resource that reads a file path coming from the configuration: + +```go title="internal/mcp/resource/readme.go" +package resource + +import ( + "context" + "os" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +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 + } +} +``` + +You can register your MCP resource: + +- with `AsMCPServerResource()` to register a single MCP resource +- with `AsMCPServerResources()` to register several MCP resources at once + +```go title="internal/register.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/foo/bar/internal/mcp/resource" + "go.uber.org/fx" +) + +func Register() fx.Option { + return fx.Options( + // registers ReadmeResource as MCP resource + fxmcpserver.AsMCPServerResource(resource.NewReadmeResource), + // ... + ) +} +``` + +The dependencies of your MCP resources will be autowired. + +To expose it, you need to ensure that the MCP server has the `resources` capability enabled: + +```yaml title="configs/config.yaml" +modules: + mcp: + server: + capabilities: + resources: true # to expose MCP resources & resource templates (disabled by default) +``` + +### Resource templates registration + +This module offers an [MCPServerResourceTemplate](https://github.com/ankorstore/yokai/blob/main/fxmcpserver/server/registry.go) interface to implement to provide an [MCP resource template](https://modelcontextprotocol.io/docs/concepts/resources). + +For example, an MCP resource template that retrieves a user profile for a given id: + +```go title="internal/mcp/resource/readme.go" +package resource + +import ( + "context" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/log" + "github.com/foo/bar/internal/user" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +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 + } +} +``` + +You can register your MCP resource template: + +- with `AsMCPServerResourceTemplate()` to register a single MCP resource template +- with `AsMCPServerResourceTemplates()` to register several MCP resource templates at once + +```go title="internal/register.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/foo/bar/internal/mcp/resource" + "go.uber.org/fx" +) + +func Register() fx.Option { + return fx.Options( + // registers UserProfileResource as MCP resource template + fxmcpserver.AsMCPServerResourceTemplate(resource.NewUserProfileResource), + // ... + ) +} +``` + +The dependencies of your MCP resource templates will be autowired. + +To expose it, you need to ensure that the MCP server has the `resources` capability enabled: + +```yaml title="configs/config.yaml" +modules: + mcp: + server: + capabilities: + resources: true # to expose MCP resources & resource templates (disabled by default) +``` + +### Prompts registration + +This module offers an [MCPServerPrompt](https://github.com/ankorstore/yokai/blob/main/fxmcpserver/server/registry.go) interface to implement to provide an [MCP prompt](https://modelcontextprotocol.io/docs/concepts/prompts). + +For example, an MCP prompt that greets a provided user name: + +```go title="internal/mcp/prompt/greet.go" +package prompt + +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" +) + +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 + } +} +``` + +You can register your MCP prompt: + +- with `AsMCPServerPrompt()` to register a single MCP prompt +- with `AsMCPServerPrompts()` to register several MCP prompts at once + +```go title="internal/register.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/foo/bar/internal/mcp/prompt" + "go.uber.org/fx" +) + +func Register() fx.Option { + return fx.Options( + // registers GreetingPrompt as MCP prompt + fxmcpserver.AsMCPServerPrompt(prompt.NewGreetingPrompt), + // ... + ) +} +``` + +The dependencies of your MCP prompts will be autowired. + +To expose it, you need to ensure that the MCP server has the `prompts` capability enabled: + +```yaml title="configs/config.yaml" +modules: + mcp: + server: + capabilities: + prompts: true # to expose MCP prompts (disabled by default) +``` + +### Tools registration + +This module offers an [MCPServerTool](https://github.com/ankorstore/yokai/blob/main/fxmcpserver/server/registry.go) interface to implement to provide an [MCP tool](https://modelcontextprotocol.io/docs/concepts/tools). + +For example, an MCP tool that performs basic arithmetic calculations: + +```go title="internal/mcp/tool/calculator.go" +package tool + +import ( + "context" + "fmt" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/trace" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +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) { + // correlated trace span + ctx, span := trace.CtxTracer(ctx).Start(ctx, "in calculator tool") + defer span.End() + + // correlated log + log.CtxLogger(ctx).Info().Msg("in calculator tool") + + // calculator logic + 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 + } +} +``` + +You can register your MCP tool: + +- with `AsMCPServerTool()` to register a single MCP tool +- with `AsMCPServerTools()` to register several MCP tools at once + +```go title="internal/register.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/foo/bar/internal/mcp/tool" + "go.uber.org/fx" +) + +func Register() fx.Option { + return fx.Options( + // registers CalculatorTool as MCP tool + fxmcpserver.AsMCPServerTool(tool.NewCalculatorTool), + // ... + ) +} +``` + +The dependencies of your MCP tools will be autowired. + +To expose it, you need to ensure that the MCP server has the `tools` capability enabled: + +```yaml title="configs/config.yaml" +modules: + mcp: + server: + capabilities: + tools: true # to expose MCP tools (disabled by default) +``` + +## Hooks + +This module provides hooking mechanisms for the `StreamableHTTP` and `SSE` servers requests handling. + +### StreamableHTTP server hooks + +This module offers the possibility to provide context hooks with [MCPStreamableHTTPServerContextHook](https://github.com/ankorstore/yokai/blob/main/fxmcpserver/server/stream/context.go) implementations, that will be applied on each MCP StreamableHTTP request. + +For example, an MCP StreamableHTTP server context hook that adds a config value to the context: + +```go title="internal/mcp/resource/readme.go" +package hook + +import ( + "context" + "net/http" + + "github.com/ankorstore/yokai/config" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type ExampleHook struct { + config *config.Config +} + +func NewExampleHook(config *config.Config) *ExampleHook { + return &ExampleHook{ + config: config, + } +} + +func (h *ExampleHook) Handle() server.HTTPContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, "foo", h.config.GetString("foo")) + } +} +``` + +You can register your MCP StreamableHTTP server context hook: + +- with `AsMCPStreamableHTTPServerContextHook()` to register a single MCP StreamableHTTP server context hook +- with `AsMCPStreamableHTTPServerContextHooks()` to register several MCP StreamableHTTP server context hooks at once + +```go title="internal/register.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/foo/bar/internal/mcp/hook" + "go.uber.org/fx" +) + +func Register() fx.Option { + return fx.Options( + // registers ExampleHook as MCP StreamableHTTP server context hook + fxmcpserver.AsMCPStreamableHTTPServerContextHook(hook.NewExampleHook), + // ... + ) +} +``` + +The dependencies of your MCP StreamableHTTP server context hooks will be autowired. + +### SSE server hooks + +This module offers the possibility to provide context hooks with [MCPSSEServerContextHook](https://github.com/ankorstore/yokai/blob/main/fxmcpserver/server/sse/context.go) implementations, that will be applied on each MCP SSE request. + +For example, an MCP SSE server context hook that adds a config value to the context: + +```go title="internal/mcp/resource/readme.go" +package hook + +import ( + "context" + "net/http" + + "github.com/ankorstore/yokai/config" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +type ExampleHook struct { + config *config.Config +} + +func NewExampleHook(config *config.Config) *ExampleHook { + return &ExampleHook{ + config: config, + } +} + +func (h *ExampleHook) Handle() server.SSEContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, "foo", h.config.GetString("foo")) + } +} +``` + +You can register your MCP SSE server context hook: + +- with `AsMCPSSEServerContextHook()` to register a single MCP SSE server context hook +- with `AsMCPSSEServerContextHooks()` to register several MCP SSE server context hooks at once + +```go title="internal/register.go" +package internal + +import ( + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/foo/bar/internal/mcp/hook" + "go.uber.org/fx" +) + +func Register() fx.Option { + return fx.Options( + // registers ExampleHook as MCP SSE server context hook + fxmcpserver.AsMCPSSEServerContextHook(hook.NewExampleHook), + // ... + ) +} +``` + +The dependencies of your MCP SSE server context hooks will be autowired. + +## Logging + +You can configure the MCP server requests and responses automatic logging: + +```yaml title="configs/config.yaml" +modules: + mcp: + server: + log: + request: true # to log MCP requests contents (disabled by default) + response: true # to log MCP responses contents (disabled by default) +``` + +As a result, in your application logs: + +``` +INF in calculator tool mcpRequestID=460aab37-e16e-4464-9956-54fce47746e7 mcpSessionID=8f617d54-e4c9-4459-bb26-76b4d96e2b72 mcpTransport=streamable-http service=yokai-mcp spanID=0f536ffa84fb8800 system=mcpserver traceID=594a9585cbfd5362c03968cd6d7d786c +INF MCP request success mcpLatency=4.869308ms mcpMethod=tools/call mcpRequest="..." mcpResponse="..." mcpRequestID=460aab37-e16e-4464-9956-54fce47746e7 mcpSessionID=8f617d54-e4c9-4459-bb26-76b4d96e2b72 mcpTool=calculator mcpTransport=streamable-http service=yokai-mcp spanID=0f536ffa84fb8800 system=mcpserver traceID=594a9585cbfd5362c03968cd6d7d786c +``` + +If both HTTP server logging and tracing are enabled, log records will automatically have the current `traceID` and `spanID` to be able to correlate logs and trace spans. + +To get logs correlation in your MCP registrations, you need to retrieve the logger from the context with `log.CtxLogger()`: + +```go +log.CtxLogger(c.Request().Context()).Info().Msg("in calculator tool") +``` + +The MCP server logging will be based on the [log](fxlog.md) module configuration. + +## Tracing + +You can configure the MCP server requests and responses automatic tracing: + +```yaml title="configs/config.yaml" +modules: + mcp: + server: + trace: + request: true # to trace MCP requests contents (disabled by default) + response: true # to trace MCP responses contents (disabled by default) +``` + +As a result, in your application trace spans attributes: + +``` +service.name: yokai-mcp +mcp.method: tools/call +mcp.tool: calculator +mcp.transport: streamable-http +mcp.request: ... +mcp.response: ... +... +``` + +To get traces correlation in your MCP registrations, you need to retrieve the tracer from the context with `trace.CtxTracer()`: + +```go +ctx, span := trace.CtxTracer(ctx).Start(ctx, "in calculator tool") +defer span.End() +``` + +The MCP server tracing will be based on the [fxtrace](trace.md) module configuration. + +## Metrics + +You can enable MCP requests automatic metrics with `modules.mcp.server.metrics.collect.enable=true`: + +```yaml title="configs/config.yaml" +modules: + mcp: + server: + 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 +``` + +For example, after calling the `calculator` MCP tool, the [core](fxcore.md) HTTP server will expose in the configured metrics endpoint: + +```makefile title="[GET] /metrics" +# ... +# HELP mcp_server_requests_duration_seconds Time spent processing MCP requests +# TYPE mcp_server_requests_duration_seconds histogram +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="0.005"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="0.01"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="0.025"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="0.05"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="0.1"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="0.25"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="0.5"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="1"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="2.5"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="5"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="10"} 1 +mcp_server_requests_duration_seconds_bucket{method="tools/call",target="calculator",le="+Inf"} 1 +mcp_server_requests_duration_seconds_sum{method="tools/call",target="calculator"} 0.004869308 +mcp_server_requests_duration_seconds_count{method="tools/call",target="calculator"} 1 +# HELP mcp_server_requests_total Number of processed MCP requests +# TYPE mcp_server_requests_total counter +mcp_server_requests_total{method="tools/call",status="success",target="calculator"} 1 +``` + +## Testing + +This module provide `StreamableHTTP` and `SSE` test servers, to functionally test your applications. + +### StreamableHTTP test server + +This module provides a [MCPStreamableHTTPTestServer](https://github.com/ankorstore/yokai/blob/main/fxmcpserver/fxmcpservertest/stream.go) to enable you to easily test your exposed MCP registrations. + +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 easily assert on: + +- MCP responses +- logs +- traces +- metrics + +For example, to test an `MCP ping`: + +```go title="internal/mcp/ping_test.go" +package handler_test + +import ( + "testing" + + "github.com/ankorstore/yokai/log/logtest" + "github.com/ankorstore/yokai/trace/tracetest" + "github.com/foo/bar/internal" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "go.uber.org/fx" +) + +func TestMCPPing(t *testing.T) { + var testServer *fxmcpservertest.MCPStreamableHTTPTestServer + var logBuffer logtest.TestLogBuffer + var traceExporter tracetest.TestTraceExporter + var metricsRegistry *prometheus.Registry + + internal.RunTest(t, fx.Populate(&testServer, &logBuffer, &traceExporter, &metricsRegistry)) + + // close the test server once done + defer testServer.Close() + + // start test client + testClient, err := testServer.StartClient(context.Background()) + assert.NoError(t, err) + + // 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": "streamable-http", + "message": "MCP request success", + }) + + // assertion on the traces exporter + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP ping", + attribute.String("mcp.method", "ping"), + attribute.String("mcp.transport", "streamable-http"), + ) + + // assertion on the metrics registry + expectedMetric := ` + # HELP mcp_server_requests_total Number of processed MCP 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) +} +``` + +### SSE test server + +This module provides a [MCPSSETestServer](https://github.com/ankorstore/yokai/blob/main/fxmcpserver/fxmcpservertest/sse.go) to enable you to easily test your exposed MCP registrations. + +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 easily assert on: + +- MCP responses +- logs +- traces +- metrics + +For example, to test an `MCP ping`: + +```go title="internal/mcp/ping_test.go" +package handler_test + +import ( + "testing" + + "github.com/ankorstore/yokai/log/logtest" + "github.com/ankorstore/yokai/trace/tracetest" + "github.com/foo/bar/internal" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "go.uber.org/fx" +) + +func TestMCPPing(t *testing.T) { + var testServer *fxmcpservertest.MCPSSETestServer + var logBuffer logtest.TestLogBuffer + var traceExporter tracetest.TestTraceExporter + var metricsRegistry *prometheus.Registry + + internal.RunTest(t, fx.Populate(&testServer, &logBuffer, &traceExporter, &metricsRegistry)) + + // 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 MCP 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) +} +``` diff --git a/docs/tutorials/grpc-application.md b/docs/tutorials/grpc-application.md index 0785783c..fb355260 100644 --- a/docs/tutorials/grpc-application.md +++ b/docs/tutorials/grpc-application.md @@ -7,4 +7,4 @@ icon: material/school-outline > How to build, step by step, an gRPC application with Yokai. -:material-sign-caution: Coming soon... \ No newline at end of file +:material-sign-caution: Coming soon, meanwhile you can check the [gRPC demo application](../demos/grpc-application.md). \ No newline at end of file diff --git a/docs/tutorials/http-application.md b/docs/tutorials/http-application.md index 4cf6d06f..56674834 100644 --- a/docs/tutorials/http-application.md +++ b/docs/tutorials/http-application.md @@ -748,7 +748,8 @@ First let's activate the [trace](../modules/fxtrace.md#configuration) module exp ```yaml title="configs/config.yaml" modules: trace: - processor: stdout + processor: + type: stdout ``` Let's then add trace spans from our `ListGophersHandler` with `trace.CtxTracerProvider()`: diff --git a/docs/tutorials/mcp-application.md b/docs/tutorials/mcp-application.md new file mode 100644 index 00000000..ad336fe6 --- /dev/null +++ b/docs/tutorials/mcp-application.md @@ -0,0 +1,10 @@ +--- +title: Tutorials - MCP application +icon: material/school-outline +--- + +# :material-school-outline: Tutorial - MCP application + +> How to build, step by step, an MCP server application with Yokai. + +:material-sign-caution: Coming soon, meanwhile you can check the [MCP demo application](../demos/mcp-application.md). \ No newline at end of file diff --git a/docs/tutorials/worker-application.md b/docs/tutorials/worker-application.md index f8d96dac..5672fa66 100644 --- a/docs/tutorials/worker-application.md +++ b/docs/tutorials/worker-application.md @@ -485,7 +485,8 @@ First let's activate the [trace](../modules/fxtrace.md#configuration) module exp ```yaml title="configs/config.yaml" modules: trace: - processor: stdout + processor: + type: stdout ``` Let's then add trace spans to our `SubscribeWorker` with `trace.CtxTracerProvider()`: diff --git a/fxclock/.golangci.yml b/fxclock/.golangci.yml new file mode 100644 index 00000000..60d036ad --- /dev/null +++ b/fxclock/.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 + - loggercheck + - 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/fxclock/CHANGELOG.md b/fxclock/CHANGELOG.md new file mode 100644 index 00000000..217b0505 --- /dev/null +++ b/fxclock/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 1.0.0 (2025-03-31) + + +### Features + +* **fxclock:** Provided module ([#332](https://github.com/ankorstore/yokai/issues/332)) ([2930493](https://github.com/ankorstore/yokai/commit/2930493a2fb268d2f54da054485cfe7a410db3af)) diff --git a/fxclock/README.md b/fxclock/README.md new file mode 100644 index 00000000..8fe0ca99 --- /dev/null +++ b/fxclock/README.md @@ -0,0 +1,161 @@ +# Fx Clock Module + +[![ci](https://github.com/ankorstore/yokai/actions/workflows/fxclock-ci.yml/badge.svg)](https://github.com/ankorstore/yokai/actions/workflows/fxclock-ci.yml) +[![go report](https://goreportcard.com/badge/github.com/ankorstore/yokai/fxclock)](https://goreportcard.com/report/github.com/ankorstore/yokai/fxclock) +[![codecov](https://codecov.io/gh/ankorstore/yokai/graph/badge.svg?token=ghUBlFsjhR&flag=fxclock)](https://app.codecov.io/gh/ankorstore/yokai/tree/main/fxclock) +[![Deps](https://img.shields.io/badge/osi-deps-blue)](https://deps.dev/go/github.com%2Fankorstore%2Fyokai%2Ffxclock) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/ankorstore/yokai/fxclock)](https://pkg.go.dev/github.com/ankorstore/yokai/fxclock) + +> [Fx](https://uber-go.github.io/fx/) module for [clockwork](https://github.com/jonboulle/clockwork). + + +* [Installation](#installation) +* [Features](#features) +* [Documentation](#documentation) + * [Dependencies](#dependencies) + * [Loading](#loading) + * [Usage](#usage) + * [Testing](#testing) + * [Global time](#global-time) + * [Time control](#time-control) + + +## Installation + +```shell +go get github.com/ankorstore/yokai/fxclock +``` + +## Features + +This module provides a [clockwork.Clock](https://github.com/jonboulle/clockwork) instance for your application, that you +can use to control time. + +## Documentation + +### Dependencies + +This module is intended to be used alongside the [fxconfig](https://github.com/ankorstore/yokai/tree/main/fxconfig) +module. + +### Loading + +To load the module in your Fx application: + +```go +package main + +import ( + "time" + + "github.com/ankorstore/yokai/fxclock" + "github.com/ankorstore/yokai/fxconfig" + "github.com/jonboulle/clockwork" + "go.uber.org/fx" +) + +func main() { + fx.New( + fxconfig.FxConfigModule, // load the module dependencies + fxclock.FxClockModule, // load the module + fx.Invoke(func(clock clockwork.Clock) { // invoke the clock + clock.Sleep(3 * time.Second) + }), + ).Run() +} +``` + +### Usage + +This module provides a [clockwork.Clock](https://github.com/jonboulle/clockwork) instance, ready to inject in your code. + +This is particularly useful if you need to control time (set time, fast-forward, ...). + +For example: + +```go +package service + +import ( + "github.com/jonboulle/clockwork" +) + +type ExampleService struct { + clock clockwork.Clock +} + +func NewExampleService(clock clockwork.Clock) *ExampleService { + return &ExampleService{ + clock: clock, + } +} + +func (s *ExampleService) Now() string { + return s.clock.Now().String() +} +``` + +See the underlying vendor [documentation](https://github.com/jonboulle/clockwork) for more details. + +### Testing + +This module provides a [*clockwork.FakeClock](https://github.com/jonboulle/clockwork) instance, that will be automatically injected as `clockwork.Clock` in your constructors in `test` mode. + +#### Global time + +By default, the fake clock is set to `time.Now()` (your test execution time). + +You can configure the global time in your test in your testing configuration file (for all your tests), in [RFC3339](https://datatracker.ietf.org/doc/html/rfc3339) format: + +```yaml +# ./configs/config_test.yaml +modules: + clock: + test: + time: "2006-01-02T15:04:05Z07:00" # time in RFC3339 format +``` + +You can also [override this value](https://ankorstore.github.io/yokai/modules/fxconfig/#env-var-substitution), per test, by setting the `MODULES_CLOCK_TEST_TIME` env var. + +#### Time control + +You can `populate` the [*clockwork.FakeClock](https://github.com/jonboulle/clockwork) from your test to control time: + +```go +package service_test + +import ( + "testing" + "time" + + "github.com/ankorstore/yokai/fxsql" + "github.com/foo/bar/internal" + "github.com/foo/bar/internal/service" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "go.uber.org/fx" +) + +func TestExampleService(t *testing.T) { + testTime := "2025-03-30T12:00:00Z" + expectedTime, err := time.Parse(time.RFC3339, testTime) + assert.NoError(t, err) + + t.Setenv("MODULES_CLOCK_TEST_TIME", testTime) + + var svc service.ExampleService + var clock *clockwork.FakeClock + + internal.RunTest(t, fx.Populate(&svc, &clock)) + + // current time as configured above + assert.Equal(t, expectedTime, svc.Now()) // 2025-03-30T12:00:00Z + + clock.Advance(5 * time.Hour) + + // current time is now advanced by 5 hours + assert.Equal(t, expectedTime.Add(5*time.Hour), svc.Now()) // 2025-03-30T17:00:00Z +} +``` + +See [tests example](module_test.go) for more details. diff --git a/fxclock/go.mod b/fxclock/go.mod new file mode 100644 index 00000000..e161ddf1 --- /dev/null +++ b/fxclock/go.mod @@ -0,0 +1,37 @@ +module github.com/ankorstore/yokai/fxclock + +go 1.21 + +require ( + github.com/ankorstore/yokai/config v1.5.0 + github.com/ankorstore/yokai/fxconfig v1.3.0 + github.com/jonboulle/clockwork v0.5.0 + github.com/stretchr/testify v1.10.0 + go.uber.org/fx v1.23.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // 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/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.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.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/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/fxclock/go.sum b/fxclock/go.sum new file mode 100644 index 00000000..e8eeadb9 --- /dev/null +++ b/fxclock/go.sum @@ -0,0 +1,84 @@ +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/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/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/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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/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/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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +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.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/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/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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +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= +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/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/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= diff --git a/fxclock/module.go b/fxclock/module.go new file mode 100644 index 00000000..e95a16b2 --- /dev/null +++ b/fxclock/module.go @@ -0,0 +1,51 @@ +package fxclock + +import ( + "time" + + "github.com/ankorstore/yokai/config" + "github.com/jonboulle/clockwork" + "go.uber.org/fx" +) + +// ModuleName is the module name. +const ModuleName = "clock" + +// FxClockModule is the [Fx] clockwork module. +// +// [Fx]: https://github.com/uber-go/fx +var FxClockModule = fx.Module( + ModuleName, + fx.Provide( + NewFxClock, + ), +) + +// FxClockParam allows injection of the required dependencies in [NewFxClock]. +type FxClockParam struct { + fx.In + Config *config.Config +} + +// NewFxClock returns a new [clockwork.Clock] instance. +func NewFxClock(p FxClockParam) (clockwork.Clock, *clockwork.FakeClock, error) { + if p.Config.IsTestEnv() { + testTimeCfg := p.Config.GetString("modules.clock.test.time") + if testTimeCfg == "" { + testClock := clockwork.NewFakeClock() + + return testClock, testClock, nil + } + + testTime, err := time.Parse(time.RFC3339, testTimeCfg) + if err != nil { + return nil, nil, err + } + + testClock := clockwork.NewFakeClockAt(testTime) + + return testClock, testClock, nil + } + + return clockwork.NewRealClock(), nil, nil +} diff --git a/fxclock/module_test.go b/fxclock/module_test.go new file mode 100644 index 00000000..3cb5b11c --- /dev/null +++ b/fxclock/module_test.go @@ -0,0 +1,133 @@ +package fxclock_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxclock" + "github.com/ankorstore/yokai/fxclock/testdata/service" + "github.com/ankorstore/yokai/fxconfig" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" +) + +func TestFxClockworkClockModule(t *testing.T) { + t.Setenv("APP_ENV", config.AppEnvDev) + t.Setenv("APP_CONFIG_PATH", "testdata/config") + + runTest := func(tb testing.TB) (clockwork.Clock, *clockwork.FakeClock, *service.TestService) { + tb.Helper() + + var clock clockwork.Clock + var fakeClock *clockwork.FakeClock + var srv *service.TestService + + app := fxtest.New( + tb, + fx.NopLogger, + fxconfig.FxConfigModule, + fxclock.FxClockModule, + fx.Provide(service.NewTestService), + fx.Populate(&clock, &fakeClock, &srv), + ) + + app.RequireStart().RequireStop() + assert.NoError(tb, app.Err()) + + return clock, fakeClock, srv + } + + t.Run("normal mode", func(t *testing.T) { + clock, fakeClock, srv := runTest(t) + + assert.NotNil(t, clock) + assert.Implements(t, (*clockwork.Clock)(nil), clock) + assert.Equal(t, "*clockwork.realClock", fmt.Sprintf("%T", clock)) + + assert.Nil(t, fakeClock) + + assert.NotNil(t, srv) + }) + + t.Run("test mode with default time", func(t *testing.T) { + t.Setenv("APP_ENV", config.AppEnvTest) + + clock, fakeClock, srv := runTest(t) + assert.NotNil(t, clock) + assert.Implements(t, (*clockwork.Clock)(nil), clock) + assert.Equal(t, "*clockwork.FakeClock", fmt.Sprintf("%T", clock)) + + assert.NotNil(t, fakeClock) + assert.Implements(t, (*clockwork.Clock)(nil), fakeClock) + assert.Equal(t, "*clockwork.FakeClock", fmt.Sprintf("%T", fakeClock)) + + assert.NotNil(t, srv) + + startTime := srv.Now() + fakeClock.Advance(10 * time.Minute) + + assert.Equal(t, startTime.Add(10*time.Minute), srv.Now()) + }) + + t.Run("with test clock and fixed time", func(t *testing.T) { + testTime := "2025-03-30T12:00:00Z" + + t.Setenv("APP_ENV", config.AppEnvTest) + t.Setenv("MODULES_CLOCK_TEST_TIME", testTime) + + clock, fakeClock, srv := runTest(t) + assert.NotNil(t, clock) + assert.Implements(t, (*clockwork.Clock)(nil), clock) + assert.Equal(t, "*clockwork.FakeClock", fmt.Sprintf("%T", clock)) + + assert.NotNil(t, fakeClock) + assert.Implements(t, (*clockwork.Clock)(nil), fakeClock) + assert.Equal(t, "*clockwork.FakeClock", fmt.Sprintf("%T", fakeClock)) + + assert.NotNil(t, srv) + + expectedTime, _ := time.Parse(time.RFC3339, testTime) + assert.Equal(t, expectedTime, srv.Now()) + + fakeClock.Advance(5 * time.Hour) + assert.Equal(t, expectedTime.Add(5*time.Hour), srv.Now()) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + srv.Sleep(3 * time.Second) + wg.Done() + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := fakeClock.BlockUntilContext(ctx, 1) + assert.NoError(t, err) + + fakeClock.Advance(10 * time.Second) + wg.Wait() + }) + + t.Run("test mode with invalid time", func(t *testing.T) { + testTime := "invalid" + t.Setenv("APP_ENV", config.AppEnvTest) + t.Setenv("MODULES_CLOCK_TEST_TIME", testTime) + + app := fx.New( + fx.NopLogger, + fxconfig.FxConfigModule, + fxclock.FxClockModule, + fx.Invoke(func(clock clockwork.Clock) {}), + ) + + err := app.Start(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("cannot parse %q", testTime)) + }) +} diff --git a/fxclock/testdata/config/config.dev.yaml b/fxclock/testdata/config/config.dev.yaml new file mode 100644 index 00000000..d158fdb7 --- /dev/null +++ b/fxclock/testdata/config/config.dev.yaml @@ -0,0 +1,6 @@ +app: + env: dev +modules: + log: + level: debug + output: test diff --git a/fxclock/testdata/config/config.test.yaml b/fxclock/testdata/config/config.test.yaml new file mode 100644 index 00000000..11807d58 --- /dev/null +++ b/fxclock/testdata/config/config.test.yaml @@ -0,0 +1,6 @@ +app: + env: test +modules: + log: + level: debug + output: test diff --git a/fxclock/testdata/config/config.yaml b/fxclock/testdata/config/config.yaml new file mode 100644 index 00000000..9e9314ba --- /dev/null +++ b/fxclock/testdata/config/config.yaml @@ -0,0 +1,2 @@ +app: + name: test-app diff --git a/fxclock/testdata/service/service.go b/fxclock/testdata/service/service.go new file mode 100644 index 00000000..5abbd937 --- /dev/null +++ b/fxclock/testdata/service/service.go @@ -0,0 +1,23 @@ +package service + +import ( + "time" + + "github.com/jonboulle/clockwork" +) + +type TestService struct { + clock clockwork.Clock +} + +func NewTestService(clock clockwork.Clock) *TestService { + return &TestService{clock: clock} +} + +func (s *TestService) Now() time.Time { + return s.clock.Now() +} + +func (s *TestService) Sleep(d time.Duration) { + s.clock.Sleep(d) +} diff --git a/fxcore/CHANGELOG.md b/fxcore/CHANGELOG.md index 5bafd1d3..2dc3d217 100644 --- a/fxcore/CHANGELOG.md +++ b/fxcore/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.12.0](https://github.com/ankorstore/yokai/compare/fxcore/v1.11.0...fxcore/v1.12.0) (2025-03-18) + + +### Features + +* **fxcore:** Added core server exposition config ([#329](https://github.com/ankorstore/yokai/issues/329)) ([f5b741b](https://github.com/ankorstore/yokai/commit/f5b741bbadf7d3097df7b0d1879c4306a37f893f)) + +## [1.11.0](https://github.com/ankorstore/yokai/compare/fxcore/v1.10.0...fxcore/v1.11.0) (2025-03-13) + + +### Features + +* **fxcore:** Added core tasks system ([#326](https://github.com/ankorstore/yokai/issues/326)) ([ae58a8f](https://github.com/ankorstore/yokai/commit/ae58a8fe30a2b196101bd5a428ab464b528ae7b3)) + ## [1.10.0](https://github.com/ankorstore/yokai/compare/fxcore/v1.9.0...fxcore/v1.10.0) (2025-02-13) diff --git a/fxcore/README.md b/fxcore/README.md index b39b7536..c5bee175 100644 --- a/fxcore/README.md +++ b/fxcore/README.md @@ -83,6 +83,7 @@ modules: type: stdout core: server: + expose: true # to expose the core http server, disabled by default address: ":8081" # core http server listener address (default :8081) errors: obfuscate: false # to obfuscate error messages on the core http server responses @@ -134,6 +135,9 @@ modules: liveness: expose: true # to expose health check liveness route, disabled by default path: /livez # health check liveness route path (default /livez) + tasks: + expose: true # to expose tasks route, disabled by default + path: /tasks/:name # tasks route path (default /tasks/:name) debug: config: expose: true # to expose debug config route diff --git a/fxcore/go.mod b/fxcore/go.mod index 5d7cd354..e82ddac9 100644 --- a/fxcore/go.mod +++ b/fxcore/go.mod @@ -14,7 +14,7 @@ require ( github.com/ankorstore/yokai/healthcheck v1.1.0 github.com/ankorstore/yokai/httpserver v1.6.0 github.com/ankorstore/yokai/log v1.2.0 - github.com/ankorstore/yokai/trace v1.3.0 + github.com/ankorstore/yokai/trace v1.4.0 github.com/arl/statsviz v0.6.0 github.com/labstack/echo/v4 v4.13.3 github.com/labstack/gommon v0.4.2 diff --git a/fxcore/go.sum b/fxcore/go.sum index ec0a18cb..8df59b53 100644 --- a/fxcore/go.sum +++ b/fxcore/go.sum @@ -22,6 +22,8 @@ github.com/ankorstore/yokai/log v1.2.0 h1:jiuDiC0dtqIGIOsFQslUHYoFJ1qjI+rOMa6dI1 github.com/ankorstore/yokai/log v1.2.0/go.mod h1:MVvUcms1AYGo0BT6l88B9KJdvtK6/qGKdgyKVXfbmyc= github.com/ankorstore/yokai/trace v1.3.0 h1:0ji32oymIcxTmH5h6GRWLo5ypwBbWrZkXRf9rWF9070= github.com/ankorstore/yokai/trace v1.3.0/go.mod h1:m7EL2MRBilgCtrly5gA4F0jkGSXR2EbG6LsotbTJ4nA= +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/arl/statsviz v0.6.0 h1:jbW1QJkEYQkufd//4NDYRSNBpwJNrdzPahF7ZmoGdyE= github.com/arl/statsviz v0.6.0/go.mod h1:0toboo+YGSUXDaS4g1D5TVS4dXs7S7YYT5J/qnW2h8s= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/fxcore/info.go b/fxcore/info.go index 320cd7cb..d773f964 100644 --- a/fxcore/info.go +++ b/fxcore/info.go @@ -1,6 +1,9 @@ package fxcore import ( + "fmt" + "sort" + "github.com/ankorstore/yokai/config" "github.com/ankorstore/yokai/log" "github.com/ankorstore/yokai/trace" @@ -129,3 +132,57 @@ func (i *FxCoreModuleInfo) Data() map[string]interface{} { "extra": i.ExtraInfos, } } + +// FxModuleInfoRegistry is the registry collecting info about registered modules. +type FxModuleInfoRegistry struct { + infos map[string]FxModuleInfo +} + +// FxModuleInfoRegistryParam allows injection of the required dependencies in [NewFxModuleInfoRegistry]. +type FxModuleInfoRegistryParam struct { + fx.In + Infos []any `group:"core-module-infos"` +} + +// NewFxModuleInfoRegistry returns a new [FxModuleInfoRegistry]. +func NewFxModuleInfoRegistry(p FxModuleInfoRegistryParam) *FxModuleInfoRegistry { + infos := make(map[string]FxModuleInfo) + + for _, info := range p.Infos { + if castInfo, ok := info.(FxModuleInfo); ok { + infos[castInfo.Name()] = castInfo + } + } + + return &FxModuleInfoRegistry{ + infos: infos, + } +} + +func (r *FxModuleInfoRegistry) Names() []string { + names := make([]string, len(r.infos)) + + i := 0 + for name := range r.infos { + names[i] = name + i++ + } + + sort.Strings(names) + + return names +} + +// All returns a map of all registered [FxModuleInfo]. +func (r *FxModuleInfoRegistry) All() map[string]FxModuleInfo { + return r.infos +} + +// Find returns a [FxModuleInfo] by name. +func (r *FxModuleInfoRegistry) Find(name string) (FxModuleInfo, error) { + if info, ok := r.infos[name]; ok { + return info, nil + } + + return nil, fmt.Errorf("fx module info with name %s was not found", name) +} diff --git a/fxcore/info_test.go b/fxcore/info_test.go index b1ed0f3f..1a9539e9 100644 --- a/fxcore/info_test.go +++ b/fxcore/info_test.go @@ -8,7 +8,17 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewFxCoreModuleInfo(t *testing.T) { +type testModuleInfo struct{} + +func (i *testModuleInfo) Name() string { + return "test" +} + +func (i *testModuleInfo) Data() map[string]interface{} { + return map[string]interface{}{} +} + +func TestFxCoreModuleInfo(t *testing.T) { t.Setenv("APP_ENV", "test") cfg, err := config.NewDefaultConfigFactory().Create( @@ -54,3 +64,70 @@ func TestNewFxCoreModuleInfo(t *testing.T) { info.Data(), ) } + +func TestFxModuleInfoRegistry(t *testing.T) { + t.Parallel() + + createRegistry := func(tb testing.TB) *fxcore.FxModuleInfoRegistry { + tb.Helper() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("./testdata/config"), + ) + assert.NoError(tb, err) + + return fxcore.NewFxModuleInfoRegistry(fxcore.FxModuleInfoRegistryParam{ + Infos: []interface{}{ + &testModuleInfo{}, + fxcore.NewFxCoreModuleInfo(fxcore.FxCoreModuleInfoParam{ + Config: cfg, + ExtraInfos: []fxcore.FxExtraInfo{}, + }), + "invalid", + }, + }) + } + + t.Run("test type", func(t *testing.T) { + t.Parallel() + + registry := createRegistry(t) + + assert.IsType(t, &fxcore.FxModuleInfoRegistry{}, registry) + }) + + t.Run("test all", func(t *testing.T) { + t.Parallel() + + registry := createRegistry(t) + + assert.Len(t, registry.All(), 2) + }) + + t.Run("test names", func(t *testing.T) { + t.Parallel() + + registry := createRegistry(t) + + assert.Equal(t, []string{fxcore.ModuleName, "test"}, registry.Names()) + }) + + t.Run("test find", func(t *testing.T) { + t.Parallel() + + registry := createRegistry(t) + + testInfo, err := registry.Find("test") + assert.NoError(t, err) + assert.Equal(t, "test", testInfo.Name()) + + coreInfo, err := registry.Find(fxcore.ModuleName) + assert.NoError(t, err) + assert.Equal(t, fxcore.ModuleName, coreInfo.Name()) + + invalidInfo, err := registry.Find("invalid") + assert.Error(t, err) + assert.Equal(t, "fx module info with name invalid was not found", err.Error()) + assert.Nil(t, invalidInfo) + }) +} diff --git a/fxcore/module.go b/fxcore/module.go index 98a9c981..bbaba712 100644 --- a/fxcore/module.go +++ b/fxcore/module.go @@ -4,6 +4,7 @@ import ( "context" "embed" "fmt" + "io" "net/http" "strconv" @@ -37,6 +38,7 @@ const ( DefaultHealthCheckStartupPath = "/healthz" DefaultHealthCheckLivenessPath = "/livez" DefaultHealthCheckReadinessPath = "/readyz" + DefaultTasksPath = "/tasks" DefaultDebugConfigPath = "/debug/config" DefaultDebugPProfPath = "/debug/pprof" DefaultDebugBuildPath = "/debug/build" @@ -63,6 +65,7 @@ var FxCoreModule = fx.Module( fxhealthcheck.FxHealthcheckModule, fx.Provide( NewFxModuleInfoRegistry, + NewTaskRegistry, NewFxCore, fx.Annotate( NewFxCoreModuleInfo, @@ -92,62 +95,68 @@ type FxCoreParam struct { Checker *healthcheck.Checker Config *config.Config Logger *log.Logger - Registry *FxModuleInfoRegistry + InfoRegistry *FxModuleInfoRegistry + TaskRegistry *TaskRegistry MetricsRegistry *prometheus.Registry } // NewFxCore returns a new [Core]. func NewFxCore(p FxCoreParam) (*Core, error) { - appDebug := p.Config.AppDebug() - - // logger - coreLogger := httpserver.NewEchoLogger( - log.FromZerolog(p.Logger.ToZerolog().With().Str("module", ModuleName).Logger()), - ) - - // server - coreServer, err := httpserver.NewDefaultHttpServerFactory().Create( - httpserver.WithDebug(appDebug), - httpserver.WithBanner(false), - httpserver.WithLogger(coreLogger), - httpserver.WithRenderer(NewDashboardRenderer(templatesFS, "templates/dashboard.html")), - httpserver.WithHttpErrorHandler( - httpserver.NewJsonErrorHandler( - p.Config.GetBool("modules.core.server.errors.obfuscate") || !appDebug, - p.Config.GetBool("modules.core.server.errors.stack") || appDebug, - ).Handle(), - ), - ) - if err != nil { - return nil, fmt.Errorf("failed to create core http server: %w", err) - } + var coreServer *echo.Echo + var err error + + if p.Config.GetBool("modules.core.server.expose") { + appDebug := p.Config.AppDebug() + + // logger + coreLogger := httpserver.NewEchoLogger( + log.FromZerolog(p.Logger.ToZerolog().With().Str("module", ModuleName).Logger()), + ) + + // server + coreServer, err = httpserver.NewDefaultHttpServerFactory().Create( + httpserver.WithDebug(appDebug), + httpserver.WithBanner(false), + httpserver.WithLogger(coreLogger), + httpserver.WithRenderer(NewDashboardRenderer(templatesFS, "templates/dashboard.html")), + httpserver.WithHttpErrorHandler( + httpserver.NewJsonErrorHandler( + p.Config.GetBool("modules.core.server.errors.obfuscate") || !appDebug, + p.Config.GetBool("modules.core.server.errors.stack") || appDebug, + ).Handle(), + ), + ) + if err != nil { + return nil, fmt.Errorf("failed to create core http server: %w", err) + } - // middlewares - coreServer = withMiddlewares(coreServer, p) + // middlewares + coreServer = withMiddlewares(coreServer, p) - // handlers - coreServer, err = withHandlers(coreServer, p) - if err != nil { - return nil, fmt.Errorf("failed to register core http server handlers: %w", err) - } + // handlers + coreServer, err = withHandlers(coreServer, p) + if err != nil { + return nil, fmt.Errorf("failed to register core http server handlers: %w", err) + } - // lifecycles - p.LifeCycle.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - address := p.Config.GetString("modules.core.server.address") - if address == "" { - address = DefaultAddress - } + // lifecycles + p.LifeCycle.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + address := p.Config.GetString("modules.core.server.address") + if address == "" { + address = DefaultAddress + } - //nolint:errcheck - go coreServer.Start(address) + //nolint:errcheck + go coreServer.Start(address) - return nil - }, - OnStop: func(ctx context.Context) error { - return coreServer.Shutdown(ctx) - }, - }) + return nil + }, + OnStop: func(ctx context.Context) error { + return coreServer.Shutdown(ctx) + }, + }) + } return NewCore(p.Config, p.Checker, coreServer), nil } @@ -232,7 +241,7 @@ func withHandlers(coreServer *echo.Echo, p FxCoreParam) (*echo.Echo, error) { dashboardEnabled := p.Config.GetBool("modules.core.server.dashboard.enabled") // dashboard overview - overviewInfo, err := p.Registry.Find(ModuleName) + overviewInfo, err := p.InfoRegistry.Find(ModuleName) if err != nil { return nil, err } @@ -248,6 +257,7 @@ func withHandlers(coreServer *echo.Echo, p FxCoreParam) (*echo.Echo, error) { overviewTraceProcessorExpose := p.Config.GetBool("modules.core.server.dashboard.overview.trace_processor") // template expositions + tasksExpose := p.Config.GetBool("modules.core.server.tasks.expose") metricsExpose := p.Config.GetBool("modules.core.server.metrics.expose") startupExpose := p.Config.GetBool("modules.core.server.healthcheck.startup.expose") livenessExpose := p.Config.GetBool("modules.core.server.healthcheck.liveness.expose") @@ -260,6 +270,7 @@ func withHandlers(coreServer *echo.Echo, p FxCoreParam) (*echo.Echo, error) { modulesExpose := p.Config.GetBool("modules.core.server.debug.modules.expose") // template paths + tasksPath := p.Config.GetString("modules.core.server.tasks.path") metricsPath := p.Config.GetString("modules.core.server.metrics.path") startupPath := p.Config.GetString("modules.core.server.healthcheck.startup.path") livenessPath := p.Config.GetString("modules.core.server.healthcheck.liveness.path") @@ -271,6 +282,48 @@ func withHandlers(coreServer *echo.Echo, p FxCoreParam) (*echo.Echo, error) { buildPath := p.Config.GetString("modules.core.server.debug.build.path") modulesPath := p.Config.GetString("modules.core.server.debug.modules.path") + // tasks + if tasksExpose { + if tasksPath == "" { + tasksPath = DefaultTasksPath + } + + coreServer.POST(fmt.Sprintf("%s/:name", tasksPath), func(c echo.Context) error { + ctx := c.Request().Context() + + logger := log.CtxLogger(ctx) + + name := c.Param("name") + + input, err := io.ReadAll(c.Request().Body) + if err != nil { + logger.Error().Err(err).Str("task", name).Msg("request body read error") + + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("cannot read request body: %v", err.Error())) + } + + err = c.Request().Body.Close() + if err != nil { + logger.Error().Err(err).Str("task", name).Msg("request body close error") + + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("cannot close request body: %v", err.Error())) + } + + res := p.TaskRegistry.Run(ctx, name, input) + if !res.Success { + logger.Error().Err(err).Str("task", name).Msg("task execution error") + + return c.JSON(http.StatusInternalServerError, res) + } + + logger.Info().Str("task", name).Msg("task execution success") + + return c.JSON(http.StatusOK, res) + }) + + coreServer.Logger.Debug("registered tasks handler") + } + // metrics if metricsExpose { if metricsPath == "" { @@ -393,14 +446,14 @@ func withHandlers(coreServer *echo.Echo, p FxCoreParam) (*echo.Echo, error) { coreServer.Logger.Debug("registered debug build handler") } - // debug modules + // modules if modulesExpose || appDebug { if modulesPath == "" { modulesPath = DefaultDebugModulesPath } coreServer.GET(fmt.Sprintf("%s/:name", modulesPath), func(c echo.Context) error { - info, err := p.Registry.Find(c.Param("name")) + info, err := p.InfoRegistry.Find(c.Param("name")) if err != nil { return echo.NewHTTPError(http.StatusNotFound, err.Error()) } @@ -466,6 +519,9 @@ func withHandlers(coreServer *echo.Echo, p FxCoreParam) (*echo.Echo, error) { "overviewLogOutputExpose": overviewLogOutputExpose, "overviewTraceSamplerExpose": overviewTraceSamplerExpose, "overviewTraceProcessorExpose": overviewTraceProcessorExpose, + "tasksExpose": tasksExpose, + "tasksPath": tasksPath, + "tasksNames": p.TaskRegistry.Names(), "metricsExpose": metricsExpose, "metricsPath": metricsPath, "startupExpose": startupExpose, @@ -486,7 +542,7 @@ func withHandlers(coreServer *echo.Echo, p FxCoreParam) (*echo.Echo, error) { "buildPath": buildPath, "modulesExpose": modulesExpose || appDebug, "modulesPath": modulesPath, - "modulesNames": p.Registry.Names(), + "modulesNames": p.InfoRegistry.Names(), "theme": theme, }) }) diff --git a/fxcore/module_test.go b/fxcore/module_test.go index 3401bac8..a84fa955 100644 --- a/fxcore/module_test.go +++ b/fxcore/module_test.go @@ -2,6 +2,7 @@ package fxcore_test import ( "bytes" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -9,6 +10,7 @@ import ( "github.com/ankorstore/yokai/fxcore" "github.com/ankorstore/yokai/fxcore/testdata/probes" + "github.com/ankorstore/yokai/fxcore/testdata/tasks" "github.com/ankorstore/yokai/fxhealthcheck" "github.com/ankorstore/yokai/healthcheck" "github.com/ankorstore/yokai/log/logtest" @@ -20,6 +22,17 @@ import ( "go.uber.org/fx" ) +func TestModuleWithServerDisabled(t *testing.T) { + t.Setenv("APP_CONFIG_PATH", "testdata/config") + t.Setenv("MODULES_CORE_SERVER_EXPOSE", "false") + + var core *fxcore.Core + + fxcore.NewBootstrapper().RunTestApp(t, fx.Populate(&core)) + + assert.Nil(t, core.HttpServer()) +} + func TestModuleWithMetricsDisabled(t *testing.T) { t.Setenv("APP_CONFIG_PATH", "testdata/config") t.Setenv("METRICS_ENABLED", "false") @@ -1053,3 +1066,71 @@ func TestModuleDashboardTheme(t *testing.T) { } } } + +func TestModuleDashboardTasks(t *testing.T) { + t.Setenv("APP_CONFIG_PATH", "testdata/config") + t.Setenv("TASKS_ENABLED", "true") + + var core *fxcore.Core + + fxcore.NewBootstrapper().RunTestApp( + t, + fxcore.AsTasks( + tasks.NewErrorTask, + tasks.NewSuccessTask, + ), + fx.Populate(&core), + ) + + // [GET] /tasks/success + req := httptest.NewRequest(http.MethodPost, "/tasks/success", bytes.NewBuffer([]byte("test input"))) + rec := httptest.NewRecorder() + core.HttpServer().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + res := fxcore.TaskResult{} + err := json.Unmarshal(rec.Body.Bytes(), &res) + assert.NoError(t, err) + + assert.True(t, res.Success) + assert.Equal(t, "task success", res.Message) + assert.Equal( + t, + map[string]any{ + "app": "core-app", + "input": "test input", + }, + res.Details, + ) + + // [GET] /tasks/error + req = httptest.NewRequest(http.MethodPost, "/tasks/error", bytes.NewBuffer([]byte("test input"))) + rec = httptest.NewRecorder() + core.HttpServer().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + + res = fxcore.TaskResult{} + err = json.Unmarshal(rec.Body.Bytes(), &res) + assert.NoError(t, err) + + assert.False(t, res.Success) + assert.Equal(t, "task error", res.Message) + assert.Nil(t, res.Details) + + // [GET] /tasks/invalid + req = httptest.NewRequest(http.MethodPost, "/tasks/invalid", bytes.NewBuffer([]byte("test input"))) + rec = httptest.NewRecorder() + core.HttpServer().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + + res = fxcore.TaskResult{} + err = json.Unmarshal(rec.Body.Bytes(), &res) + assert.NoError(t, err) + + assert.False(t, res.Success) + assert.Equal(t, "task invalid not found", res.Message) + assert.Nil(t, res.Details) +} diff --git a/fxcore/register.go b/fxcore/register.go index 065d29e5..cbd4758f 100644 --- a/fxcore/register.go +++ b/fxcore/register.go @@ -14,3 +14,25 @@ func AsCoreExtraInfo(name string, value string) fx.Option { ), ) } + +// AsTask registers a task in the core. +func AsTask(constructor any) fx.Option { + return fx.Provide( + fx.Annotate( + constructor, + fx.As(new(Task)), + fx.ResultTags(`group:"core-tasks"`), + ), + ) +} + +// AsTasks registers several tasks in the core. +func AsTasks(constructors ...any) fx.Option { + options := []fx.Option{} + + for _, constructor := range constructors { + options = append(options, AsTask(constructor)) + } + + return fx.Options(options...) +} diff --git a/fxcore/register_test.go b/fxcore/register_test.go index 23d84e3f..cb274cad 100644 --- a/fxcore/register_test.go +++ b/fxcore/register_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/ankorstore/yokai/fxcore" + "github.com/ankorstore/yokai/fxcore/testdata/tasks" "github.com/stretchr/testify/assert" ) @@ -15,3 +16,19 @@ func TestAsCoreExtraInfo(t *testing.T) { assert.Equal(t, "fx.supplyOption", fmt.Sprintf("%T", result)) } + +func TestAsTask(t *testing.T) { + t.Parallel() + + result := fxcore.AsTask(tasks.NewErrorTask) + + assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", result)) +} + +func TestAsTasks(t *testing.T) { + t.Parallel() + + result := fxcore.AsTasks(tasks.NewErrorTask) + + assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", result)) +} diff --git a/fxcore/registry.go b/fxcore/registry.go deleted file mode 100644 index 29a8c67c..00000000 --- a/fxcore/registry.go +++ /dev/null @@ -1,62 +0,0 @@ -package fxcore - -import ( - "fmt" - "sort" - - "go.uber.org/fx" -) - -// FxModuleInfoRegistry is the registry collecting info about registered modules. -type FxModuleInfoRegistry struct { - infos map[string]FxModuleInfo -} - -// FxModuleInfoRegistryParam allows injection of the required dependencies in [NewFxModuleInfoRegistry]. -type FxModuleInfoRegistryParam struct { - fx.In - Infos []any `group:"core-module-infos"` -} - -// NewFxModuleInfoRegistry returns a new [FxModuleInfoRegistry]. -func NewFxModuleInfoRegistry(p FxModuleInfoRegistryParam) *FxModuleInfoRegistry { - infos := make(map[string]FxModuleInfo) - - for _, info := range p.Infos { - if castInfo, ok := info.(FxModuleInfo); ok { - infos[castInfo.Name()] = castInfo - } - } - - return &FxModuleInfoRegistry{ - infos: infos, - } -} - -func (r *FxModuleInfoRegistry) Names() []string { - names := make([]string, len(r.infos)) - - i := 0 - for name := range r.infos { - names[i] = name - i++ - } - - sort.Strings(names) - - return names -} - -// All returns a map of all registered [FxModuleInfo]. -func (r *FxModuleInfoRegistry) All() map[string]FxModuleInfo { - return r.infos -} - -// Find returns a [FxModuleInfo] by name. -func (r *FxModuleInfoRegistry) Find(name string) (FxModuleInfo, error) { - if info, ok := r.infos[name]; ok { - return info, nil - } - - return nil, fmt.Errorf("fx module info with name %s was not found", name) -} diff --git a/fxcore/registry_test.go b/fxcore/registry_test.go deleted file mode 100644 index 80d794e8..00000000 --- a/fxcore/registry_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package fxcore_test - -import ( - "testing" - - "github.com/ankorstore/yokai/config" - "github.com/ankorstore/yokai/fxcore" - "github.com/stretchr/testify/assert" -) - -type testModuleInfo struct{} - -func (i *testModuleInfo) Name() string { - return "test" -} - -func (i *testModuleInfo) Data() map[string]interface{} { - return map[string]interface{}{} -} - -func TestNewFxModuleInfoRegistry(t *testing.T) { - t.Parallel() - - registry, err := prepareTestFxModuleInfoRegistry() - assert.NoError(t, err) - - assert.IsType(t, &fxcore.FxModuleInfoRegistry{}, registry) -} - -func TestAll(t *testing.T) { - t.Parallel() - - registry, err := prepareTestFxModuleInfoRegistry() - assert.NoError(t, err) - - assert.Len(t, registry.All(), 2) -} - -func TestNames(t *testing.T) { - t.Parallel() - - registry, err := prepareTestFxModuleInfoRegistry() - assert.NoError(t, err) - - assert.Equal(t, []string{fxcore.ModuleName, "test"}, registry.Names()) -} - -func TestFind(t *testing.T) { - t.Parallel() - - registry, err := prepareTestFxModuleInfoRegistry() - assert.NoError(t, err) - - testInfo, err := registry.Find("test") - assert.NoError(t, err) - assert.Equal(t, "test", testInfo.Name()) - - coreInfo, err := registry.Find(fxcore.ModuleName) - assert.NoError(t, err) - assert.Equal(t, fxcore.ModuleName, coreInfo.Name()) - - invalidInfo, err := registry.Find("invalid") - assert.Error(t, err) - assert.Equal(t, "fx module info with name invalid was not found", err.Error()) - assert.Nil(t, invalidInfo) -} - -func prepareTestFxModuleInfoRegistry() (*fxcore.FxModuleInfoRegistry, error) { - cfg, err := config.NewDefaultConfigFactory().Create( - config.WithFilePaths("./testdata/config"), - ) - if err != nil { - return nil, err - } - - return fxcore.NewFxModuleInfoRegistry(fxcore.FxModuleInfoRegistryParam{ - Infos: []interface{}{ - &testModuleInfo{}, - fxcore.NewFxCoreModuleInfo(fxcore.FxCoreModuleInfoParam{ - Config: cfg, - ExtraInfos: []fxcore.FxExtraInfo{}, - }), - "invalid", - }, - }), nil -} diff --git a/fxcore/task.go b/fxcore/task.go new file mode 100644 index 00000000..2d9f62f4 --- /dev/null +++ b/fxcore/task.go @@ -0,0 +1,72 @@ +package fxcore + +import ( + "context" + "fmt" + "sort" + + "go.uber.org/fx" +) + +// TaskResult is a Task execution result. +type TaskResult struct { + Success bool `json:"success"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` +} + +// Task is an interface for tasks implementations. +type Task interface { + Name() string + Run(ctx context.Context, input []byte) TaskResult +} + +// TaskRegistry is a registry of Task implementations. +type TaskRegistry struct { + tasks map[string]Task +} + +// TaskRegistryParams is used to inject dependencies in NewTaskRegistry. +type TaskRegistryParams struct { + fx.In + Tasks []Task `group:"core-tasks"` +} + +// NewTaskRegistry returns a new TaskRegistry instance. +func NewTaskRegistry(p TaskRegistryParams) *TaskRegistry { + tasks := make(map[string]Task) + + for _, task := range p.Tasks { + tasks[task.Name()] = task + } + + return &TaskRegistry{ + tasks: tasks, + } +} + +// Names returns all registered Task names. +func (r *TaskRegistry) Names() []string { + var names []string + + for name := range r.tasks { + names = append(names, name) + } + + sort.Strings(names) + + return names +} + +// Run runs a specific Task. +func (r *TaskRegistry) Run(ctx context.Context, name string, input []byte) TaskResult { + task, ok := r.tasks[name] + if !ok { + return TaskResult{ + Success: false, + Message: fmt.Sprintf("task %s not found", name), + } + } + + return task.Run(ctx, input) +} diff --git a/fxcore/task_test.go b/fxcore/task_test.go new file mode 100644 index 00000000..7094abb6 --- /dev/null +++ b/fxcore/task_test.go @@ -0,0 +1,82 @@ +package fxcore_test + +import ( + "context" + "testing" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxcore" + "github.com/ankorstore/yokai/fxcore/testdata/tasks" + "github.com/stretchr/testify/assert" +) + +func TestTaskRegistry(t *testing.T) { + t.Parallel() + + createRegistry := func(tb testing.TB) *fxcore.TaskRegistry { + tb.Helper() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("./testdata/config"), + ) + assert.NoError(tb, err) + + return fxcore.NewTaskRegistry(fxcore.TaskRegistryParams{ + Tasks: []fxcore.Task{ + tasks.NewSuccessTask(cfg), + tasks.NewErrorTask(), + }, + }) + } + + t.Run("test names", func(t *testing.T) { + t.Parallel() + + registry := createRegistry(t) + + assert.Equal(t, []string{"error", "success"}, registry.Names()) + }) + + t.Run("test run with success task", func(t *testing.T) { + t.Parallel() + + registry := createRegistry(t) + + res := registry.Run(context.Background(), "success", []byte("test input")) + + assert.True(t, res.Success) + assert.Equal(t, "task success", res.Message) + assert.Equal( + t, + map[string]any{ + "app": "core-app", + "input": "test input", + }, + res.Details, + ) + }) + + t.Run("test run with error task", func(t *testing.T) { + t.Parallel() + + registry := createRegistry(t) + + res := registry.Run(context.Background(), "error", []byte("test input")) + + assert.False(t, res.Success) + assert.Equal(t, "task error", res.Message) + assert.Nil(t, res.Details) + }) + + t.Run("test run with invalid task", func(t *testing.T) { + t.Parallel() + + registry := createRegistry(t) + + res := registry.Run(context.Background(), "invalid", []byte("test input")) + + assert.False(t, res.Success) + assert.Equal(t, "task invalid not found", res.Message) + assert.Nil(t, res.Details) + }) +} diff --git a/fxcore/templates/dashboard.html b/fxcore/templates/dashboard.html index e61eb4fb..85022ce4 100644 --- a/fxcore/templates/dashboard.html +++ b/fxcore/templates/dashboard.html @@ -1,3 +1,4 @@ + {{ .overviewInfo.AppName }} @@ -16,10 +17,10 @@

@@ -50,39 +50,39 @@
{{ if .buildExpose }} - - Build + +   Build {{ end }} {{ if .configExpose }} - - Config + +   Config {{ end }} {{ if .metricsExpose }} - - Metrics + +   Metrics {{ end }} {{ if .routesExpose }} - - Routes + +   Routes {{ end }} {{ if .pprofExpose }} - Pprof +   Pprof    {{ end }} {{ if .statsExpose }} - Stats +   Stats    {{ end }} @@ -97,26 +97,45 @@
{{ if .startupExpose }} - - Startup + +   Startup {{ end }} {{ if .livenessExpose }} - - Liveness + +   Liveness {{ end }} {{ if .readinessExpose }} - - Readiness + +   Readiness {{ end }}
{{ end }} + {{ if .tasksExpose }} +
+
+
+   Tasks +
+
+ {{ range $taskName := .tasksNames }} + +   {{ $taskName }} + + {{ else }} + +   n/a + + {{ end }} +
+
+ {{ end }} {{ if .modulesExpose }}
@@ -125,8 +144,8 @@
{{ range $moduleName := .modulesNames }} - - {{ $moduleName }} + +   {{ $moduleName }} {{ else }} @@ -145,120 +164,151 @@
Loading...
-  Loading ... +   Loading ...
-
-
+
{{ if or .overviewAppEnvExpose .overviewAppDebugExpose .overviewAppVersionExpose }} -
-
-

-   {{ .overviewInfo.AppName }} -

- - - {{ if and .overviewAppDescriptionExpose .overviewInfo.AppDescription }} - - - - - {{ end }} - {{ if .overviewAppEnvExpose }} - - - - - {{ end }} - {{ if .overviewAppDebugExpose }} - - - - - {{ end }} - {{ if .overviewAppVersionExpose }} - - - - - {{ end }} - -
Description{{ .overviewInfo.AppDescription }}
Env{{ .overviewInfo.AppEnv }}
Debug{{ .overviewInfo.AppDebug }}
Version{{ .overviewInfo.AppVersion }}
-
+
+
+

+   {{ .overviewInfo.AppName }} +

+ + + {{ if and .overviewAppDescriptionExpose .overviewInfo.AppDescription }} + + + + + {{ end }} + {{ if .overviewAppEnvExpose }} + + + + + {{ end }} + {{ if .overviewAppDebugExpose }} + + + + + {{ end }} + {{ if .overviewAppVersionExpose }} + + + + + {{ end }} + +
Description{{ .overviewInfo.AppDescription }}
Env{{ .overviewInfo.AppEnv }}
Debug{{ .overviewInfo.AppDebug }}
Version{{ .overviewInfo.AppVersion }}
+
{{ end }} {{ if or .overviewLogLevelExpose .overviewLogOutputExpose }} -
-
-
-

-   Logs -

- - - {{ if or .overviewLogLevelExpose }} - - - - - {{ end }} - {{ if or .overviewLogOutputExpose }} - - - - - {{ end }} - -
Level{{ .overviewInfo.LogLevel }}
Output{{ .overviewInfo.LogOutput }}
-
+
+
+
+

+   Logs +

+ + + {{ if or .overviewLogLevelExpose }} + + + + + {{ end }} + {{ if or .overviewLogOutputExpose }} + + + + + {{ end }} + +
Level{{ .overviewInfo.LogLevel }}
Output{{ .overviewInfo.LogOutput }}
+
{{ end }} {{ if or .overviewTraceSamplerExpose .overviewTraceProcessorExpose }} -
-
-
-

-   Traces -

- - - {{ if or .overviewTraceSamplerExpose }} - - - - - {{ end }} - {{ if or .overviewTraceProcessorExpose }} - - - - - {{ end }} - -
Sampler{{ .overviewInfo.TraceSampler }}
Processor{{ .overviewInfo.TraceProcessor }}
-
+
+
+
+

+   Traces +

+ + + {{ if or .overviewTraceSamplerExpose }} + + + + + {{ end }} + {{ if or .overviewTraceProcessorExpose }} + + + + + {{ end }} + +
Sampler{{ .overviewInfo.TraceSampler }}
Processor{{ .overviewInfo.TraceProcessor }}
+
{{ end }} {{ if ne (len .overviewInfo.ExtraInfos ) 0 }} -
-
-
-

-   Extra information -

- - - {{ range $infoName, $infoValue := .overviewInfo.ExtraInfos }} - - - - - {{ end }} - -
{{ $infoName }}{{ $infoValue }}
-
+
+
+
+

+   Extra information +

+ + + {{ range $infoName, $infoValue := .overviewInfo.ExtraInfos }} + + + + + {{ end }} + +
{{ $infoName }}{{ $infoValue }}
+
{{ end }}
+
+
+
+
+ +
+ +
+
+
  Task {% taskName %} execution success
+
+

{% taskResultMessage %}

+

+
+
+
+
  Task {% taskName %} execution error
+
+

{% taskResultMessage %}

+

+
+
+
@@ -279,65 +329,135 @@ title: { type: String, }, + view: { + type: String, + }, content: { type: String, }, + taskName: { + type: String, + }, + taskInput: { + type: String, + }, + taskRunning: { + type: Boolean, + }, + taskResultSuccess: { + type: Boolean, + }, + taskResultMessage: { + type: String, + }, + taskResultDetails: { + type: Object, + default: () => ({}) + }, }, setup() { - const error = ref('') - const loading = ref(false) - const title = ref('  Overview') - const content = ref('') + const error = ref(''); + const loading = ref(false); + const title = ref('  Overview'); + const view = ref('overview'); + const content = ref(''); + const taskName = ref(''); + const taskInput = ref(''); + const taskRunning = ref(false); + const taskResultSuccess = ref(true); + const taskResultMessage = ref(''); + const taskResultDetails = ref(undefined); - return { error, loading, title, content } + return { error, loading, title, view, content, taskName, taskInput, taskRunning, taskResultSuccess, taskResultMessage, taskResultDetails} }, methods: { loadContent(event) { - this.error = '' - this.loading = true + this.error = ''; + + let dataView = event.currentTarget.getAttribute('data-view'); + let dataTitle = event.currentTarget.getAttribute('data-title'); + let dataUrl = location.origin + event.currentTarget.getAttribute('data-url'); + let dataType = event.currentTarget.getAttribute('data-type'); + + this.view = dataView + + if (dataType === 'task') { + this.resetTask(); - let dataTitle = event.currentTarget.getAttribute('data-title') - let dataUrl = location.origin + event.currentTarget.getAttribute('data-url') - let dataType = event.currentTarget.getAttribute('data-type') + this.title = dataTitle; + this.taskName = event.currentTarget.getAttribute('data-task'); + } else { + this.loading = true + + axios + .get(dataUrl) + .then(response => { + this.title = dataTitle + this.content = response.data; + }) + .catch(error => { + if (dataType === 'healthcheck') { + this.title = dataTitle + this.content = error.response.data; + } else { + this.title = '  Error' + this.error = error.message + } + }) + .finally(() => { + this.loading = false; + hljs.highlightAll(); + }); + } + }, + resetTask() { + this.taskRunning = false + this.taskInput = '' + this.taskResultSuccess = true; + this.taskResultMessage = ''; + this.taskResultDetails = undefined; + }, + runTask() { + this.taskRunning = true axios - .get(dataUrl) + .post( + '{{ $.tasksPath }}/' + this.taskName, + this.taskInput + ) .then(response => { - this.title = dataTitle - this.content = response.data; + this.taskResultSuccess = response.data.success; + this.taskResultMessage = response.data.message; + this.taskResultDetails = response.data.details; }) .catch(error => { - if (dataType === 'healthcheck') { - this.title = dataTitle - this.content = error.response.data; - } else { - this.title = '  Error' - this.error = error.message - } + this.taskResultSuccess = error.response.data.success; + this.taskResultMessage = error.response.data.message; + this.taskResultDetails = error.response.data.details; }) .finally(() => { - this.loading = false; + this.taskRunning = false; hljs.highlightAll(); }); }, switchTheme(event) { - let dataTheme = event.currentTarget.getAttribute('data-theme') + let dataTheme = event.currentTarget.getAttribute('data-theme'); - localStorage.setItem("theme-mode", dataTheme) + localStorage.setItem("theme-mode", dataTheme); axios - .post("/theme", {"theme": dataTheme},) + .post("/theme", {"theme": dataTheme}) .then(() => location.reload()) .catch(error => { this.title = '  Error' this.error = error.message - }) + }); } }, computed: { computedContent() { if (this.error !== '') { - return '' + return ''; } else { if (typeof this.content === 'string' || this.content instanceof String) { return '
' + this.content + '
'; @@ -345,10 +465,17 @@ return '
' + JSON.stringify(this.content, null, 2) + '
'; } } + }, + computedTaskResultDetails() { + if (this.taskResultDetails === undefined) { + return ''; + } + + return '
' + JSON.stringify(this.taskResultDetails, null, 2) + '
'; } }, }).mount('#app') - document.dispatchEvent(new KeyboardEvent('keydown', {})) + document.dispatchEvent(new KeyboardEvent('keydown', {})); \ No newline at end of file diff --git a/fxcore/testdata/config/config.yaml b/fxcore/testdata/config/config.yaml index db70eb5b..5df3d33f 100644 --- a/fxcore/testdata/config/config.yaml +++ b/fxcore/testdata/config/config.yaml @@ -13,6 +13,7 @@ modules: type: test core: server: + expose: true errors: obfuscate: false stack: false @@ -47,6 +48,8 @@ modules: expose: ${READINESS_ENABLED} liveness: expose: ${LIVENESS_ENABLED} + tasks: + expose: ${TASKS_ENABLED} debug: config: expose: ${CONFIG_ENABLED} diff --git a/fxcore/testdata/tasks/error.go b/fxcore/testdata/tasks/error.go new file mode 100644 index 00000000..5dbeb4a8 --- /dev/null +++ b/fxcore/testdata/tasks/error.go @@ -0,0 +1,26 @@ +package tasks + +import ( + "context" + + "github.com/ankorstore/yokai/fxcore" +) + +var _ fxcore.Task = (*ErrorTask)(nil) + +type ErrorTask struct{} + +func NewErrorTask() *ErrorTask { + return &ErrorTask{} +} + +func (t *ErrorTask) Name() string { + return "error" +} + +func (t *ErrorTask) Run(context.Context, []byte) fxcore.TaskResult { + return fxcore.TaskResult{ + Success: false, + Message: "task error", + } +} diff --git a/fxcore/testdata/tasks/success.go b/fxcore/testdata/tasks/success.go new file mode 100644 index 00000000..182aab0d --- /dev/null +++ b/fxcore/testdata/tasks/success.go @@ -0,0 +1,35 @@ +package tasks + +import ( + "context" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxcore" +) + +var _ fxcore.Task = (*SuccessTask)(nil) + +type SuccessTask struct { + config *config.Config +} + +func NewSuccessTask(config *config.Config) *SuccessTask { + return &SuccessTask{ + config: config, + } +} + +func (t *SuccessTask) Name() string { + return "success" +} + +func (t *SuccessTask) Run(ctx context.Context, input []byte) fxcore.TaskResult { + return fxcore.TaskResult{ + Success: true, + Message: "task success", + Details: map[string]any{ + "app": t.config.AppName(), + "input": string(input), + }, + } +} diff --git a/fxcron/CHANGELOG.md b/fxcron/CHANGELOG.md index 9b379b60..b29d4ea2 100644 --- a/fxcron/CHANGELOG.md +++ b/fxcron/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.1.1](https://github.com/ankorstore/yokai/compare/fxcron/v1.1.0...fxcron/v1.1.1) (2025-09-02) + + +### Bug Fixes + +* **fxcron:** Fix resolution collision by using full import path in type IDs ([#366](https://github.com/ankorstore/yokai/issues/366)) ([8db4440](https://github.com/ankorstore/yokai/commit/8db44400dada7536833cea6251f8e39e21d05d85)) + ## [1.1.0](https://github.com/ankorstore/yokai/compare/fxcron/v1.0.0...fxcron/v1.1.0) (2024-03-15) diff --git a/fxcron/info.go b/fxcron/info.go index d856a60e..08ad85fc 100644 --- a/fxcron/info.go +++ b/fxcron/info.go @@ -1,7 +1,6 @@ package fxcron import ( - "reflect" "time" "github.com/go-co-op/gocron/v2" @@ -94,5 +93,5 @@ func (i *FxCronModuleInfo) jobNextRun(job gocron.Job) string { } func (i *FxCronModuleInfo) jobType(job CronJob) string { - return reflect.ValueOf(job).Type().String() + return GetType(job) } diff --git a/fxcron/module_test.go b/fxcron/module_test.go index 19022a7f..7f32cb54 100644 --- a/fxcron/module_test.go +++ b/fxcron/module_test.go @@ -360,7 +360,7 @@ func TestModuleInfo(t *testing.T) { "expression": `*/1 * * * * *`, "last_run": time.Time{}.Format(time.RFC3339), "next_run": startAt.Format(time.RFC3339), - "type": "*job.DummyCron", + "type": "github.com/ankorstore/yokai/fxcron/testdata/cron/job.DummyCron", }, }, "unscheduled": map[string]interface{}{}, diff --git a/fxcron/reflect.go b/fxcron/reflect.go index 899276a5..ce2ff295 100644 --- a/fxcron/reflect.go +++ b/fxcron/reflect.go @@ -4,12 +4,39 @@ import ( "reflect" ) -// GetType returns the type of a target. +// fullTypeID builds a stable identifier for a type in the form ".". +func fullTypeID(t reflect.Type) string { + if t == nil { + return "" + } + + // Unwrap pointers to get the underlying named type (if any). + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // For named types, PkgPath() + Name() gives a unique and stable identity. + if t.Name() != "" && t.PkgPath() != "" { + return t.PkgPath() + "." + t.Name() + } + + // Fallback for non-named kinds (slices, maps, func, etc.). + return t.String() +} + +// GetType returns a stable identifier for the given target’s type. func GetType(target any) string { - return reflect.TypeOf(target).String() + return fullTypeID(reflect.TypeOf(target)) } -// GetReturnType returns the return type of a target. +// GetReturnType returns a stable identifier for the return type of constructor-like target. +// If a target is a function, we examine its first return value (index 0), unwrap pointers, and +// build an identifier for that named type. For non-function or empty-return cases, we return "". func GetReturnType(target any) string { - return reflect.TypeOf(target).Out(0).String() + t := reflect.TypeOf(target) + if t == nil || t.Kind() != reflect.Func || t.NumOut() == 0 { + return "" + } + + return fullTypeID(t.Out(0)) } diff --git a/fxcron/reflect_test.go b/fxcron/reflect_test.go index e4ad2122..4427d863 100644 --- a/fxcron/reflect_test.go +++ b/fxcron/reflect_test.go @@ -17,7 +17,7 @@ func TestGetType(t *testing.T) { }{ {123, "int"}, {"test", "string"}, - {tracker.NewCronExecutionTracker(), "*tracker.CronExecutionTracker"}, + {tracker.NewCronExecutionTracker(), "github.com/ankorstore/yokai/fxcron/testdata/cron/tracker.CronExecutionTracker"}, } for _, tt := range tests { diff --git a/fxgrpcserver/CHANGELOG.md b/fxgrpcserver/CHANGELOG.md index 9648d5b8..08bc7c06 100644 --- a/fxgrpcserver/CHANGELOG.md +++ b/fxgrpcserver/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.3.1](https://github.com/ankorstore/yokai/compare/fxgrpcserver/v1.3.0...fxgrpcserver/v1.3.1) (2025-09-02) + + +### Bug Fixes + +* **fxgrpcserver:** Fix resolution collision by using full import path in type IDs ([#367](https://github.com/ankorstore/yokai/issues/367)) ([2cdca0b](https://github.com/ankorstore/yokai/commit/2cdca0b66f4cb594fa6d3d41fe9c5783191b126a)) + ## [1.3.0](https://github.com/ankorstore/yokai/compare/fxgrpcserver/v1.2.0...fxgrpcserver/v1.3.0) (2024-10-01) diff --git a/fxgrpcserver/reflect.go b/fxgrpcserver/reflect.go index cb7933b0..f53cf5c0 100644 --- a/fxgrpcserver/reflect.go +++ b/fxgrpcserver/reflect.go @@ -4,12 +4,39 @@ import ( "reflect" ) -// GetType returns the type of a target. +// fullTypeID builds a stable identifier for a type in the form ".". +func fullTypeID(t reflect.Type) string { + if t == nil { + return "" + } + + // Unwrap pointers to get the underlying named type (if any). + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // For named types, PkgPath() + Name() gives a unique and stable identity. + if t.Name() != "" && t.PkgPath() != "" { + return t.PkgPath() + "." + t.Name() + } + + // Fallback for non-named kinds (slices, maps, func, etc.). + return t.String() +} + +// GetType returns a stable identifier for the given target’s type. func GetType(target any) string { - return reflect.TypeOf(target).String() + return fullTypeID(reflect.TypeOf(target)) } -// GetReturnType returns the return type of a target. +// GetReturnType returns a stable identifier for the return type of constructor-like target. +// If a target is a function, we examine its first return value (index 0), unwrap pointers, and +// build an identifier for that named type. For non-function or empty-return cases, we return "". func GetReturnType(target any) string { - return reflect.TypeOf(target).Out(0).String() + t := reflect.TypeOf(target) + if t == nil || t.Kind() != reflect.Func || t.NumOut() == 0 { + return "" + } + + return fullTypeID(t.Out(0)) } diff --git a/fxgrpcserver/reflect_test.go b/fxgrpcserver/reflect_test.go index f6199b3b..c9b70158 100644 --- a/fxgrpcserver/reflect_test.go +++ b/fxgrpcserver/reflect_test.go @@ -3,7 +3,7 @@ package fxgrpcserver_test import ( "testing" - "github.com/ankorstore/yokai/fxhealthcheck" + "github.com/ankorstore/yokai/fxgrpcserver" "github.com/ankorstore/yokai/fxhealthcheck/testdata/probes" "github.com/stretchr/testify/assert" ) @@ -15,10 +15,11 @@ func TestGetType(t *testing.T) { target any expected string }{ + {nil, ""}, {123, "int"}, {"test", "string"}, - {probes.NewSuccessProbe(), "*probes.SuccessProbe"}, - {probes.NewFailureProbe(), "*probes.FailureProbe"}, + {probes.NewSuccessProbe(), "github.com/ankorstore/yokai/fxhealthcheck/testdata/probes.SuccessProbe"}, + {probes.NewFailureProbe(), "github.com/ankorstore/yokai/fxhealthcheck/testdata/probes.FailureProbe"}, } for _, tt := range tests { @@ -27,7 +28,7 @@ func TestGetType(t *testing.T) { t.Run(tt.expected, func(t *testing.T) { t.Parallel() - got := fxhealthcheck.GetType(tt.target) + got := fxgrpcserver.GetType(tt.target) assert.Equal(t, tt.expected, got) }) } @@ -40,6 +41,7 @@ func TestGetReturnType(t *testing.T) { target any expected string }{ + {nil, ""}, {func() string { return "test" }, "string"}, {func() int { return 123 }, "int"}, } @@ -49,7 +51,7 @@ func TestGetReturnType(t *testing.T) { t.Run(tt.expected, func(t *testing.T) { t.Parallel() - got := fxhealthcheck.GetReturnType(tt.target) + got := fxgrpcserver.GetReturnType(tt.target) assert.Equal(t, tt.expected, got) }) } diff --git a/fxhealthcheck/CHANGELOG.md b/fxhealthcheck/CHANGELOG.md index 07c42c31..dd9983f5 100644 --- a/fxhealthcheck/CHANGELOG.md +++ b/fxhealthcheck/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.1.1](https://github.com/ankorstore/yokai/compare/fxhealthcheck/v1.1.0...fxhealthcheck/v1.1.1) (2025-09-02) + + +### Bug Fixes + +* **fxhealthcheck:** Fix resolution collision by using full import path in type IDs ([#368](https://github.com/ankorstore/yokai/issues/368)) ([d318ece](https://github.com/ankorstore/yokai/commit/d318eced460fb7bb81d092c37f1d15d36544685c)) + ## [1.1.0](https://github.com/ankorstore/yokai/compare/fxhealthcheck/v1.0.0...fxhealthcheck/v1.1.0) (2024-03-15) diff --git a/fxhealthcheck/reflect.go b/fxhealthcheck/reflect.go index d6d18802..1f824108 100644 --- a/fxhealthcheck/reflect.go +++ b/fxhealthcheck/reflect.go @@ -4,12 +4,39 @@ import ( "reflect" ) -// GetType returns the type of a target. +// fullTypeID builds a stable identifier for a type in the form ".". +func fullTypeID(t reflect.Type) string { + if t == nil { + return "" + } + + // Unwrap pointers to get the underlying named type (if any). + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // For named types, PkgPath() + Name() gives a unique and stable identity. + if t.Name() != "" && t.PkgPath() != "" { + return t.PkgPath() + "." + t.Name() + } + + // Fallback for non-named kinds (slices, maps, func, etc.). + return t.String() +} + +// GetType returns a stable identifier for the given target’s type. func GetType(target any) string { - return reflect.TypeOf(target).String() + return fullTypeID(reflect.TypeOf(target)) } -// GetReturnType returns the return type of a target. +// GetReturnType returns a stable identifier for the return type of constructor-like target. +// If a target is a function, we examine its first return value (index 0), unwrap pointers, and +// build an identifier for that named type. For non-function or empty-return cases, we return "". func GetReturnType(target any) string { - return reflect.TypeOf(target).Out(0).String() + t := reflect.TypeOf(target) + if t == nil || t.Kind() != reflect.Func || t.NumOut() == 0 { + return "" + } + + return fullTypeID(t.Out(0)) } diff --git a/fxhealthcheck/reflect_test.go b/fxhealthcheck/reflect_test.go index 3c2ca639..0882c4b7 100644 --- a/fxhealthcheck/reflect_test.go +++ b/fxhealthcheck/reflect_test.go @@ -15,10 +15,11 @@ func TestGetType(t *testing.T) { target any expected string }{ + {nil, ""}, {123, "int"}, {"test", "string"}, - {probes.NewSuccessProbe(), "*probes.SuccessProbe"}, - {probes.NewFailureProbe(), "*probes.FailureProbe"}, + {probes.NewSuccessProbe(), "github.com/ankorstore/yokai/fxhealthcheck/testdata/probes.SuccessProbe"}, + {probes.NewFailureProbe(), "github.com/ankorstore/yokai/fxhealthcheck/testdata/probes.FailureProbe"}, } for _, tt := range tests { @@ -40,6 +41,7 @@ func TestGetReturnType(t *testing.T) { target any expected string }{ + {nil, ""}, {func() string { return "test" }, "string"}, {func() int { return 123 }, "int"}, } diff --git a/fxhealthcheck/registry_test.go b/fxhealthcheck/registry_test.go index 1a5b8a8d..4205676e 100644 --- a/fxhealthcheck/registry_test.go +++ b/fxhealthcheck/registry_test.go @@ -30,8 +30,8 @@ func TestResolveCheckerProbesRegistrationsSuccess(t *testing.T) { probes.NewFailureProbe(), }, Definitions: []fxhealthcheck.CheckerProbeDefinition{ - fxhealthcheck.NewCheckerProbeDefinition("*probes.SuccessProbe", healthcheck.Liveness), - fxhealthcheck.NewCheckerProbeDefinition("*probes.FailureProbe", healthcheck.Readiness), + fxhealthcheck.NewCheckerProbeDefinition("github.com/ankorstore/yokai/fxhealthcheck/testdata/probes.SuccessProbe", healthcheck.Liveness), + fxhealthcheck.NewCheckerProbeDefinition("github.com/ankorstore/yokai/fxhealthcheck/testdata/probes.FailureProbe", healthcheck.Readiness), }, } diff --git a/fxhttpserver/CHANGELOG.md b/fxhttpserver/CHANGELOG.md index 4d1efcc7..fe72bbbf 100644 --- a/fxhttpserver/CHANGELOG.md +++ b/fxhttpserver/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.7.1](https://github.com/ankorstore/yokai/compare/fxhttpserver/v1.7.0...fxhttpserver/v1.7.1) (2025-09-01) + + +### Bug Fixes + +* **fxhttpserver:** Fix HTTP handlers/middlewares dependency injection signature ([#362](https://github.com/ankorstore/yokai/issues/362)) ([2042e33](https://github.com/ankorstore/yokai/commit/2042e33001c50bbda91a6fe92c126036b2264338)) + ## [1.7.0](https://github.com/ankorstore/yokai/compare/fxhttpserver/v1.6.1...fxhttpserver/v1.7.0) (2025-02-13) diff --git a/fxhttpserver/reflect.go b/fxhttpserver/reflect.go index 6d032b57..3de7834c 100644 --- a/fxhttpserver/reflect.go +++ b/fxhttpserver/reflect.go @@ -6,14 +6,41 @@ import ( "github.com/labstack/echo/v4" ) -// GetType returns the type of a target. +// fullTypeID builds a stable identifier for a type in the form ".". +func fullTypeID(t reflect.Type) string { + if t == nil { + return "" + } + + // Unwrap pointers to get the underlying named type (if any). + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // For named types, PkgPath() + Name() gives a unique and stable identity. + if t.Name() != "" && t.PkgPath() != "" { + return t.PkgPath() + "." + t.Name() + } + + // Fallback for non-named kinds (slices, maps, func, etc.). + return t.String() +} + +// GetType returns a stable identifier for the given target’s type. func GetType(target any) string { - return reflect.TypeOf(target).String() + return fullTypeID(reflect.TypeOf(target)) } -// GetReturnType returns the return type of a target. +// GetReturnType returns a stable identifier for the return type of constructor-like target. +// If a target is a function, we examine its first return value (index 0), unwrap pointers, and +// build an identifier for that named type. For non-function or empty-return cases, we return "". func GetReturnType(target any) string { - return reflect.TypeOf(target).Out(0).String() + t := reflect.TypeOf(target) + if t == nil || t.Kind() != reflect.Func || t.NumOut() == 0 { + return "" + } + + return fullTypeID(t.Out(0)) } // IsConcreteMiddleware returns true if the middleware is a concrete [echo.MiddlewareFunc] implementation. diff --git a/fxhttpserver/reflect_test.go b/fxhttpserver/reflect_test.go index 672710f8..87f2d4f8 100644 --- a/fxhttpserver/reflect_test.go +++ b/fxhttpserver/reflect_test.go @@ -15,11 +15,12 @@ func TestGetType(t *testing.T) { target any expected string }{ + {nil, ""}, {123, "int"}, {"test", "string"}, {echo.MiddlewareFunc(func(next echo.HandlerFunc) echo.HandlerFunc { return next - }), "echo.MiddlewareFunc"}, + }), "github.com/labstack/echo/v4.MiddlewareFunc"}, } for _, tt := range tests { @@ -41,8 +42,12 @@ func TestGetReturnType(t *testing.T) { target any expected string }{ + {nil, ""}, {func() string { return "test" }, "string"}, {func() int { return 123 }, "int"}, + {echo.MiddlewareFunc(func(next echo.HandlerFunc) echo.HandlerFunc { + return next + }), "github.com/labstack/echo/v4.HandlerFunc"}, } for _, tt := range tests { diff --git a/fxmcpserver/.golangci.yml b/fxmcpserver/.golangci.yml new file mode 100644 index 00000000..60d036ad --- /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 + - loggercheck + - 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/CHANGELOG.md b/fxmcpserver/CHANGELOG.md new file mode 100644 index 00000000..ec00d55f --- /dev/null +++ b/fxmcpserver/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## [1.6.0](https://github.com/ankorstore/yokai/compare/fxmcpserver/v1.5.1...fxmcpserver/v1.6.0) (2025-06-05) + + +### Features + +* **fxmcpserver:** Provided streamable HTTP transport ([#357](https://github.com/ankorstore/yokai/issues/357)) ([b9b01f0](https://github.com/ankorstore/yokai/commit/b9b01f043e67c14d0bd787e62fa02cf604d298a1)) + +## [1.5.1](https://github.com/ankorstore/yokai/compare/fxmcpserver/v1.5.0...fxmcpserver/v1.5.1) (2025-05-26) + + +### Bug Fixes + +* **fxmcpserver:** Fixed MCP SSE server tracing to accept remote context ([#354](https://github.com/ankorstore/yokai/issues/354)) ([d9e879a](https://github.com/ankorstore/yokai/commit/d9e879a62d5da19a607c3f9617a4232a0fc13080)) + +## [1.5.0](https://github.com/ankorstore/yokai/compare/fxmcpserver/v1.4.0...fxmcpserver/v1.5.0) (2025-05-14) + + +### Features + +* **fxmcpserver:** Added autoconfiguration of the SSE test server endpoints ([#352](https://github.com/ankorstore/yokai/issues/352)) ([bd74d10](https://github.com/ankorstore/yokai/commit/bd74d10adc96ba1d16c7b02be99e689c5b588ef5)) + +## [1.4.0](https://github.com/ankorstore/yokai/compare/fxmcpserver/v1.3.0...fxmcpserver/v1.4.0) (2025-05-14) + + +### Features + +* **fxmcpserver:** Added tracing remote propagation ([#350](https://github.com/ankorstore/yokai/issues/350)) ([dfc463e](https://github.com/ankorstore/yokai/commit/dfc463ebd7f607326f4bb63f464d4f14cec03ced)) + +## [1.3.0](https://github.com/ankorstore/yokai/compare/fxmcpserver/v1.2.0...fxmcpserver/v1.3.0) (2025-05-08) + + +### Features + +* **fxmcpserver:** Added MCP SSE server context hooks ([#347](https://github.com/ankorstore/yokai/issues/347)) ([17dabfe](https://github.com/ankorstore/yokai/commit/17dabfebe23951215ead3a2efdb502eafe2b7751)) + +## [1.2.0](https://github.com/ankorstore/yokai/compare/fxmcpserver/v1.1.0...fxmcpserver/v1.2.0) (2025-05-07) + + +### Features + +* **fxmcpserver:** Updated SSE test client ([#342](https://github.com/ankorstore/yokai/issues/342)) ([119bd6c](https://github.com/ankorstore/yokai/commit/119bd6c15e16c776a441f6c0856e80040c4610b3)) + +## [1.1.0](https://github.com/ankorstore/yokai/compare/fxmcpserver/v1.0.0...fxmcpserver/v1.1.0) (2025-05-07) + + +### Features + +* **fxmcpserver:** Updated context handling ([#340](https://github.com/ankorstore/yokai/issues/340)) ([66811ff](https://github.com/ankorstore/yokai/commit/66811ff2c6464a2d5d30210943e638ab96f35098)) + +## 1.0.0 (2025-05-06) + + +### Features + +* **fxmcpserver:** Provided module ([#335](https://github.com/ankorstore/yokai/issues/335)) ([233a5f5](https://github.com/ankorstore/yokai/commit/233a5f56b602cbb460b18d5134bc3c948018b95c)) diff --git a/fxmcpserver/README.md b/fxmcpserver/README.md new file mode 100644 index 00000000..f98d6c5e --- /dev/null +++ b/fxmcpserver/README.md @@ -0,0 +1,886 @@ +# 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) + * [Hooks](#hooks) + * [StreamableHTTP server hooks](#streamablehttp-server-hooks) + * [SSE server hooks](#sse-server-hooks) + * [Testing](#testing) + * [StreamableHTTP test server](#streamablehttp-test-server) + * [SSE test server](#sse-test-server) + + +## 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 register MCP Streamable HTTP and SSE server context hooks +- possibility to expose the MCP server via Streamable HTTP (remote), HTTP SSE (remote) and Stdio (local) + +## 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: + stream: + expose: true # to remotely expose the MCP server via streamable HTTP (disabled by default) + address: ":8083" # exposition address (":8083" by default) + stateless: false # stateless server mode (disabled by default) + base_path: "/mcp" # base path ("/mcp" by default) + keep_alive: true # to keep the connections alive + keep_alive_interval: 10 # keep alive interval in seconds (10 by default) + 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 the connections 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) +``` +### Hooks + +This module provides hooking mechanisms for the `StreamableHTTP` and `SSE` servers requests handling. + +#### StreamableHTTP server hooks + +This module offers the possibility to provide context hooks with [MCPStreamableHTTPServerContextHook](server/stream/context.go) implementations, that will be applied on each MCP StreamableHTTP request. + +You can use the `AsMCPStreamableHTTPServerContextHook()` function to register an MCP StreamableHTTP server context hook, or `AsMCPStreamableHTTPServerContextHooks()` to register several MCP StreamableHTTP server context hooks at once. + +The dependencies of your MCP StreamableHTTP server context hooks will be autowired. + +```go +package main + +import ( + "context" + "net/http" + + "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 ExampleHook struct { + config *config.Config +} + +func NewExampleHook(config *config.Config) *ExampleHook { + return &ExampleHook{ + config: config, + } +} + +func (h *ExampleHook) Handle() server.HTTPContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, "foo", h.config.GetString("foo")) + } +} + +func main() { + fx.New( + fxconfig.FxConfigModule, + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxmetrics.FxMetricsModule, + fxgenerate.FxGenerateModule, + fxmcpserver.FxMCPServerModule, + fx.Options( + fxmcpserver.AsMCPStreamableHTTPServerContextHook(NewExampleHook), // registers the NewExampleHook as MCP StreamableHTTP server context hook + ), + ).Run() +} +``` + +#### SSE server hooks + +This module offers the possibility to provide context hooks with [MCPSSEServerContextHook](server/sse/context.go) implementations, that will be applied on each MCP SSE request. + +You can use the `AsMCPSSEServerContextHook()` function to register an MCP SSE server context hook, or `AsMCPSSEServerContextHooks()` to register several MCP SSE server context hooks at once. + +The dependencies of your MCP SSE server context hooks will be autowired. + +```go +package main + +import ( + "context" + "net/http" + + "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 ExampleHook struct { + config *config.Config +} + +func NewExampleHook(config *config.Config) *ExampleHook { + return &ExampleHook{ + config: config, + } +} + +func (h *ExampleHook) Handle() server.SSEContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, "foo", h.config.GetString("foo")) + } +} + +func main() { + fx.New( + fxconfig.FxConfigModule, + fxlog.FxLogModule, + fxtrace.FxTraceModule, + fxmetrics.FxMetricsModule, + fxgenerate.FxGenerateModule, + fxmcpserver.FxMCPServerModule, + fx.Options( + fxmcpserver.AsMCPSSEServerContextHook(NewExampleHook), // registers the NewExampleHook as MCP SSE server context hook + ), + ).Run() +} +``` + +### Testing + +This module provide `StreamableHTTP` and `SSE` test servers, to functionally test your applications. + +#### StreamableHTTP test server + +This module provides a [MCPStreamableHTTPTestServer](fxmcpservertest/stream.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.MCPStreamableHTTPTestServer + 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) + + // 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": "streamable-http", + "message": "MCP request success", + }) + + // assertion on the traces exporter + tracetest.AssertHasTraceSpan( + t, + traceExporter, + "MCP ping", + attribute.String("mcp.method", "ping"), + attribute.String("mcp.transport", "streamable-http"), + ) + + // 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). + +#### SSE test server + +This module provides a [MCPSSETestServer](fxmcpservertest/sse.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/sse.go b/fxmcpserver/fxmcpservertest/sse.go new file mode 100644 index 00000000..c821a6e1 --- /dev/null +++ b/fxmcpserver/fxmcpservertest/sse.go @@ -0,0 +1,79 @@ +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 { + sseEndpoint := cfg.GetString("modules.mcp.server.transport.sse.sse_endpoint") + if sseEndpoint == "" { + sseEndpoint = sse.DefaultSSEEndpoint + } + + messageEndpoint := cfg.GetString("modules.mcp.server.transport.sse.message_endpoint") + if messageEndpoint == "" { + messageEndpoint = sse.DefaultMessageEndpoint + } + + testSrv := server.NewTestServer( + srv, + server.WithSSEContextFunc(hdl.Handle()), + server.WithSSEEndpoint(sseEndpoint), + server.WithMessageEndpoint(messageEndpoint), + ) + + 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) { + sseEndpoint := s.config.GetString("modules.mcp.server.transport.sse.sse_endpoint") + if sseEndpoint == "" { + sseEndpoint = sse.DefaultSSEEndpoint + } + + baseURL := s.testServer.URL + sseEndpoint + + 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/sse_test.go b/fxmcpserver/fxmcpservertest/sse_test.go new file mode 100644 index 00000000..79b301c5 --- /dev/null +++ b/fxmcpserver/fxmcpservertest/sse_test.go @@ -0,0 +1,52 @@ +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/propagation" + "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() + + tmp := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + hdl := sse.NewDefaultMCPSSEServerContextHandler(gm, tp, tmp, 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/fxmcpservertest/stream.go b/fxmcpserver/fxmcpservertest/stream.go new file mode 100644 index 00000000..e6789292 --- /dev/null +++ b/fxmcpserver/fxmcpservertest/stream.go @@ -0,0 +1,73 @@ +package fxmcpservertest + +import ( + "context" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "net/http/httptest" + + "github.com/ankorstore/yokai/config" + "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 MCPStreamableHTTPTestServer struct { + config *config.Config + testServer *httptest.Server +} + +func NewMCPStreamableHTTPTestServer(cfg *config.Config, srv *server.MCPServer, hdl stream.MCPStreamableHTTPServerContextHandler) *MCPStreamableHTTPTestServer { + basePath := cfg.GetString("modules.mcp.server.transport.stream.base_path") + if basePath == "" { + basePath = stream.DefaultBasePath + } + + testSrv := server.NewTestStreamableHTTPServer( + srv, + server.WithHTTPContextFunc(hdl.Handle()), + server.WithEndpointPath(basePath), + ) + + return &MCPStreamableHTTPTestServer{ + config: cfg, + testServer: testSrv, + } +} + +func (s *MCPStreamableHTTPTestServer) Close() { + s.testServer.Close() +} + +func (s *MCPStreamableHTTPTestServer) StartClient(ctx context.Context, options ...transport.StreamableHTTPCOption) (*client.Client, error) { + basePath := s.config.GetString("modules.mcp.server.transport.stream.base_path") + if basePath == "" { + basePath = stream.DefaultBasePath + } + + baseURL := s.testServer.URL + basePath + + cli, err := client.NewStreamableHttpClient(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/stream_test.go b/fxmcpserver/fxmcpservertest/stream_test.go new file mode 100644 index 00000000..0d1b7bf8 --- /dev/null +++ b/fxmcpserver/fxmcpservertest/stream_test.go @@ -0,0 +1,52 @@ +package fxmcpservertest_test + +import ( + "context" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "testing" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/fxmcpserver/fxmcpservertest" + "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/propagation" + "go.opentelemetry.io/otel/sdk/trace" +) + +func TestMCPStreamableHTTPTestServer(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../testdata/config"), + ) + assert.NoError(t, err) + + gm := uuid.NewDefaultUuidGenerator() + + tp := trace.NewTracerProvider() + + tmp := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + hdl := stream.NewDefaultMCPStreamableHTTPServerContextHandler(gm, tp, tmp, lg) + + mcpSrv := server.NewMCPServer("test-server", "1.0.0") + + srv := fxmcpservertest.NewMCPStreamableHTTPTestServer(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/go.mod b/fxmcpserver/go.mod new file mode 100644 index 00000000..4ad1f06d --- /dev/null +++ b/fxmcpserver/go.mod @@ -0,0 +1,81 @@ +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/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 + 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.31.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.24.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.19.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..69eb2543 --- /dev/null +++ b/fxmcpserver/go.sum @@ -0,0 +1,257 @@ +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/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= +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.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4= +github.com/mark3labs/mcp-go v0.31.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.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +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..07ac4298 --- /dev/null +++ b/fxmcpserver/info.go @@ -0,0 +1,67 @@ +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" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" +) + +// MCPServerModuleInfo is the MCP server module info. +type MCPServerModuleInfo struct { + config *config.Config + registry *server.MCPServerRegistry + steamableHTTPServer *stream.MCPStreamableHTTPServer + sseServer *sse.MCPSSEServer + stdioServer *stdio.MCPStdioServer +} + +// NewMCPServerModuleInfo returns a new MCPServerModuleInfo instance. +func NewMCPServerModuleInfo( + config *config.Config, + registry *server.MCPServerRegistry, + steamableHTTPServer *stream.MCPStreamableHTTPServer, + sseServer *sse.MCPSSEServer, + stdioServer *stdio.MCPStdioServer, +) *MCPServerModuleInfo { + return &MCPServerModuleInfo{ + config: config, + registry: registry, + steamableHTTPServer: steamableHTTPServer, + sseServer: sseServer, + stdioServer: stdioServer, + } +} + +// Name returns 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 { + streamableHTTPServerInfo := i.steamableHTTPServer.Info() + sseServerInfo := i.sseServer.Info() + stdioServerInfo := i.stdioServer.Info() + mcpRegistryInfo := i.registry.Info() + + return map[string]any{ + "transports": map[string]any{ + "stream": streamableHTTPServerInfo, + "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..8732bc7c --- /dev/null +++ b/fxmcpserver/info_test.go @@ -0,0 +1,110 @@ +package fxmcpserver_test + +import ( + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "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/resourcetemplate" + "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.NewSimpleTestTool(), + }, + []fs.MCPServerPrompt{ + prompt.NewSimpleTestPrompt(), + }, + []fs.MCPServerResource{ + resource.NewSimpleTestResource(), + }, + []fs.MCPServerResourceTemplate{ + resourcetemplate.NewSimpleTestResourceTemplate(), + }, + ) + + mcpSrv := server.NewMCPServer("test-server", "1.0.0") + + streamSrv := stream.NewDefaultMCPStreamableHTTPServerFactory(cfg).Create(mcpSrv) + sseSrv := sse.NewDefaultMCPSSEServerFactory(cfg).Create(mcpSrv) + stdioSrv := stdio.NewDefaultMCPStdioServerFactory().Create(mcpSrv) + + info := fxmcpserver.NewMCPServerModuleInfo(cfg, reg, streamSrv, 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, + }, + }, + "stream": map[string]any{ + "config": map[string]any{ + "address": ":0", + "stateless": true, + "base_path": stream.DefaultBasePath, + "keep_alive": true, + "keep_alive_interval": stream.DefaultKeepAliveInterval.Seconds(), + }, + "status": map[string]any{ + "running": false, + }, + }, + }, + "capabilities": map[string]any{ + "tools": true, + "prompts": true, + "resources": true, + }, + "registrations": map[string]any{ + "tools": map[string]string{ + "simple-test-tool": "github.com/ankorstore/yokai/fxmcpserver/testdata/tool.(*SimpleTestTool).Handle.func1", + }, + "prompts": map[string]string{ + "simple-test-prompt": "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt.(*SimpleTestPrompt).Handle.func1", + }, + "resources": map[string]string{ + "simple-test-resource": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*SimpleTestResource).Handle.func1", + }, + "resourceTemplates": map[string]string{ + "simple-test-resource-template": "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate.(*SimpleTestResourceTemplate).Handle.func1", + }, + }, + } + + assert.Equal(t, expectedData, info.Data()) +} diff --git a/fxmcpserver/module.go b/fxmcpserver/module.go new file mode 100644 index 00000000..2d70f946 --- /dev/null +++ b/fxmcpserver/module.go @@ -0,0 +1,375 @@ +package fxmcpserver + +import ( + "context" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + + "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" + "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/propagation" + "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, + ProvideMCPStreamableHTTPServer, + ProvideMCPStreamableHTTPTestServer, + ProvideMCPSSEServer, + ProvideMCPSSETestServer, + ProvideMCPStdioServer, + // module overridable dependencies + fx.Annotate( + ProvideDefaultMCPServerHooksProvider, + fx.As(new(fs.MCPServerHooksProvider)), + ), + fx.Annotate( + ProvideDefaultMCPServerFactory, + fx.As(new(fs.MCPServerFactory)), + ), + fx.Annotate( + ProvideDefaultMCPStreamableHTTPServerContextHandler, + fx.As(new(stream.MCPStreamableHTTPServerContextHandler)), + ), + fx.Annotate( + ProvideDefaultMCPStreamableHTTPServerFactory, + fx.As(new(stream.MCPStreamableHTTPServerFactory)), + ), + 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 +} + +// ProvideDefaultMCPStreamableHTTPContextHandlerParam allows injection of the required dependencies in ProvideDefaultMCPStreamableHTTPServerContextHandler. +type ProvideDefaultMCPStreamableHTTPContextHandlerParam struct { + fx.In + Generator uuid.UuidGenerator + TracerProvider trace.TracerProvider + Logger *log.Logger + MCPStreamableHTTPServerContextHooks []stream.MCPStreamableHTTPServerContextHook `group:"mcp-streamable-http-server-context-hooks"` +} + +// ProvideDefaultMCPStreamableHTTPServerContextHandler provides the default sse.MCPStreamableHTTPServerContextHandler instance. +func ProvideDefaultMCPStreamableHTTPServerContextHandler(p ProvideDefaultMCPStreamableHTTPContextHandlerParam) *stream.DefaultMCPStreamableHTTPServerContextHandler { + textMapPropagator := propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) + + return stream.NewDefaultMCPStreamableHTTPServerContextHandler( + p.Generator, + p.TracerProvider, + textMapPropagator, + p.Logger, + p.MCPStreamableHTTPServerContextHooks..., + ) +} + +// ProvideDefaultMCPStreamableHTTPServerFactoryParams allows injection of the required dependencies in ProvideDefaultMCPSSEServerFactory. +type ProvideDefaultMCPStreamableHTTPServerFactoryParams struct { + fx.In + Config *config.Config +} + +// ProvideDefaultMCPStreamableHTTPServerFactory provides the default sse.MCPStreamableHTTPServerFactory instance. +func ProvideDefaultMCPStreamableHTTPServerFactory(p ProvideDefaultMCPStreamableHTTPServerFactoryParams) *stream.DefaultMCPStreamableHTTPServerFactory { + return stream.NewDefaultMCPStreamableHTTPServerFactory(p.Config) +} + +// ProvideMCPStreamableHTTPServerParam allows injection of the required dependencies in ProvideMCPStreamableHTTPServer. +type ProvideMCPStreamableHTTPServerParam struct { + fx.In + LifeCycle fx.Lifecycle + Logger *log.Logger + Config *config.Config + MCPServer *server.MCPServer + MCPStreamableHTTPServerFactory stream.MCPStreamableHTTPServerFactory + MCPStreamableHTTPServerContextHandler stream.MCPStreamableHTTPServerContextHandler +} + +// ProvideMCPStreamableHTTPServer provides the stream.MCPStreamableHTTPServer. +func ProvideMCPStreamableHTTPServer(p ProvideMCPStreamableHTTPServerParam) *stream.MCPStreamableHTTPServer { + streamableHTTPServer := p.MCPStreamableHTTPServerFactory.Create( + p.MCPServer, + server.WithHTTPContextFunc(p.MCPStreamableHTTPServerContextHandler.Handle()), + ) + + streamableHTTPServerCtx := p.Logger.WithContext(context.Background()) + + if p.Config.GetBool("modules.mcp.server.transport.stream.expose") { + p.LifeCycle.Append(fx.Hook{ + OnStart: func(context.Context) error { + if !p.Config.IsTestEnv() { + //nolint:errcheck + go streamableHTTPServer.Start(streamableHTTPServerCtx) + } + + return nil + }, + OnStop: func(ctx context.Context) error { + if !p.Config.IsTestEnv() { + return streamableHTTPServer.Stop(ctx) + } + + return nil + }, + }) + } + + return streamableHTTPServer +} + +// ProvideMCPStreamableHTTPTestServerParam allows injection of the required dependencies in ProvideMCPStreamableHTTPTestServer. +type ProvideMCPStreamableHTTPTestServerParam struct { + fx.In + Config *config.Config + MCPServer *server.MCPServer + MCPStreamableHTTPServerContextHandler stream.MCPStreamableHTTPServerContextHandler +} + +// ProvideMCPStreamableHTTPTestServer provides the fxmcpservertest.MCPStreamableHTTPTestServer. +func ProvideMCPStreamableHTTPTestServer(p ProvideMCPStreamableHTTPTestServerParam) *fxmcpservertest.MCPStreamableHTTPTestServer { + return fxmcpservertest.NewMCPStreamableHTTPTestServer(p.Config, p.MCPServer, p.MCPStreamableHTTPServerContextHandler) +} + +// ProvideDefaultMCPSSEContextHandlerParam allows injection of the required dependencies in ProvideDefaultMCPSSEServerContextHandler. +type ProvideDefaultMCPSSEContextHandlerParam struct { + fx.In + Generator uuid.UuidGenerator + TracerProvider trace.TracerProvider + Logger *log.Logger + MCPSSEServerContextHooks []sse.MCPSSEServerContextHook `group:"mcp-sse-server-context-hooks"` +} + +// ProvideDefaultMCPSSEServerContextHandler provides the default sse.MCPSSEServerContextHandler instance. +func ProvideDefaultMCPSSEServerContextHandler(p ProvideDefaultMCPSSEContextHandlerParam) *sse.DefaultMCPSSEServerContextHandler { + textMapPropagator := propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) + + return sse.NewDefaultMCPSSEServerContextHandler( + p.Generator, + p.TracerProvider, + textMapPropagator, + p.Logger, + p.MCPSSEServerContextHooks..., + ) +} + +// 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 + 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()), + ) + + sseServerCtx := p.Logger.WithContext(context.Background()) + + if p.Config.GetBool("modules.mcp.server.transport.sse.expose") { + p.LifeCycle.Append(fx.Hook{ + OnStart: func(context.Context) error { + if !p.Config.IsTestEnv() { + //nolint:errcheck + go sseServer.Start(sseServerCtx) + } + + return nil + }, + OnStop: func(ctx context.Context) error { + if !p.Config.IsTestEnv() { + return sseServer.Stop(ctx) + } + + return nil + }, + }) + } + + 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 + 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 + 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()), + ) + + stdioServerCtx := p.Logger.WithContext(context.Background()) + + if p.Config.GetBool("modules.mcp.server.transport.stdio.expose") { + p.LifeCycle.Append(fx.Hook{ + OnStart: func(context.Context) error { + if !p.Config.IsTestEnv() { + //nolint:errcheck + go stdioServer.Start(stdioServerCtx) + } + + return nil + }, + }) + } + + return stdioServer +} diff --git a/fxmcpserver/module_test.go b/fxmcpserver/module_test.go new file mode 100644 index 00000000..6e1b09ca --- /dev/null +++ b/fxmcpserver/module_test.go @@ -0,0 +1,413 @@ +package fxmcpserver_test + +import ( + "context" + "github.com/mark3labs/mcp-go/client" + "strings" + "testing" + + "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/testdata/hook" + "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" + "github.com/ankorstore/yokai/healthcheck" + "github.com/ankorstore/yokai/log/logtest" + "github.com/ankorstore/yokai/trace/tracetest" + "github.com/mark3labs/mcp-go/mcp" + "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" +) + +//nolint:maintidx,forcetypeassert +func TestMCPServerModule(t *testing.T) { + t.Setenv("APP_ENV", "test") + t.Setenv("APP_CONFIG_PATH", "testdata/config") + + var testMCPStreamableHTTPServer *fxmcpservertest.MCPStreamableHTTPTestServer + var testMCPSSEServer *fxmcpservertest.MCPSSETestServer + var provider fs.MCPServerHooksProvider + var checker *healthcheck.Checker + 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, + fxhealthcheck.FxHealthcheckModule, + fxmcpserver.FxMCPServerModule, + fx.Options( + fxmcpserver.AsMCPServerTools(tool.NewSimpleTestTool, tool.NewAdvancedTestTool), + fxmcpserver.AsMCPServerPrompts(prompt.NewSimpleTestPrompt), + fxmcpserver.AsMCPServerResources(resource.NewSimpleTestResource), + fxmcpserver.AsMCPServerResourceTemplates(resourcetemplate.NewSimpleTestResourceTemplate), + fxmcpserver.AsMCPSSEServerContextHooks(hook.NewSimpleMCPSSEServerContextHook), + fxmcpserver.AsMCPStreamableHTTPServerContextHooks(hook.NewSimpleMCPStreamableHTTPServerContextHook), + fxhealthcheck.AsCheckerProbe(fs.NewMCPServerProbe), + ), + fx.Supply(fx.Annotate(context.Background(), fx.As(new(context.Context)))), + fx.Populate( + &testMCPStreamableHTTPServer, + &testMCPSSEServer, + &provider, + &checker, + &logBuffer, + &traceExporter, + &metricsRegistry, + ), + ).RequireStart().RequireStop() + + // ensure test servers closure + defer func() { + testMCPStreamableHTTPServer.Close() + testMCPSSEServer.Close() + }() + + ctx := context.Background() + + // health check + checkResult := checker.Check(context.Background(), healthcheck.Readiness) + assert.False(t, checkResult.Success) + assert.Equal( + t, + "MCP StreamableHTTP server is not running, MCP SSE server is not running", + checkResult.ProbesResults["mcpserver"].Message, + ) + + // start test clients + testMCPStreamableHTTPClient, err := testMCPStreamableHTTPServer.StartClient(ctx) + assert.NoError(t, err) + + testMCPSSEClient, err := testMCPSSEServer.StartClient(ctx) + assert.NoError(t, err) + defer testMCPSSEClient.Close() + + // hooks provider + defaultProvider, ok := provider.(*fs.DefaultMCPServerHooksProvider) + assert.True(t, ok) + + tests := []struct { + name string + client *client.Client + transport string + }{ + { + "with StreamableHTTP transport", + testMCPStreamableHTTPClient, + "streamable-http", + }, + { + "with SSE transport", + testMCPSSEClient, + "sse", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // reset o11y + logBuffer.Reset() + traceExporter.Reset() + defaultProvider.Reset() + + // send success tools/call request + expectedRequest := `{"method":"tools/call","params":{"name":"advanced-test-tool","arguments":{"shouldFail":"false"}}}` + expectedResponse := `{"content":[{"type":"text","text":"test"}]}` + + callToolRequest := mcp.CallToolRequest{} + callToolRequest.Params.Name = "advanced-test-tool" + callToolRequest.Params.Arguments = map[string]interface{}{ + "shouldFail": "false", + } + + callToolResult, err := tt.client.CallTool(ctx, callToolRequest) + assert.NoError(t, err) + assert.False(t, callToolResult.IsError) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]any{ + "level": "info", + "mcpMethod": "tools/call", + "mcpTool": "advanced-test-tool", + "mcpRequest": expectedRequest, + "mcpResponse": expectedResponse, + "mcpTransport": tt.transport, + "message": "MCP request success", + }) + + 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.response", expectedResponse), + attribute.String("mcp.transport", tt.transport), + ) + + 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="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 error 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 = tt.client.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": tt.transport, + "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", tt.transport), + ) + + 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="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 prompts/get request + expectedRequest = `{"method":"prompts/get","params":{"name":"simple-test-prompt"}}` + expectedResponse = `{"description":"ok","messages":[{"role":"assistant","content":{"type":"text","text":"context hook value: bar"}}]}` + + getPromptRequest := mcp.GetPromptRequest{} + getPromptRequest.Params.Name = "simple-test-prompt" + + getPromptResult, err := tt.client.GetPrompt(ctx, getPromptRequest) + assert.NoError(t, err) + assert.Equal(t, mcp.RoleAssistant, getPromptResult.Messages[0].Role) + assert.Equal(t, "context hook value: bar", 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": tt.transport, + "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", tt.transport), + ) + + 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="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 = tt.client.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": tt.transport, + "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", tt.transport), + ) + + 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="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 := tt.client.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": tt.transport, + "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", tt.transport), + ) + + 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="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" + + _, err = tt.client.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": tt.transport, + "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", tt.transport), + ) + + 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="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/register.go b/fxmcpserver/register.go new file mode 100644 index 00000000..c508b1bb --- /dev/null +++ b/fxmcpserver/register.go @@ -0,0 +1,140 @@ +package fxmcpserver + +import ( + "github.com/ankorstore/yokai/fxmcpserver/server" + "github.com/ankorstore/yokai/fxmcpserver/server/sse" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "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...) +} + +// AsMCPSSEServerContextHook registers an MCP SSE server context hook. +func AsMCPSSEServerContextHook(constructor any) fx.Option { + return fx.Provide( + fx.Annotate( + constructor, + fx.As(new(sse.MCPSSEServerContextHook)), + fx.ResultTags(`group:"mcp-sse-server-context-hooks"`), + ), + ) +} + +// AsMCPSSEServerContextHooks registers several MCP SSE server context hooks. +func AsMCPSSEServerContextHooks(constructors ...any) fx.Option { + options := []fx.Option{} + + for _, constructor := range constructors { + options = append(options, AsMCPSSEServerContextHook(constructor)) + } + + return fx.Options(options...) +} + +// AsMCPStreamableHTTPServerContextHook registers an MCP StreamableHTTP server context hook. +func AsMCPStreamableHTTPServerContextHook(constructor any) fx.Option { + return fx.Provide( + fx.Annotate( + constructor, + fx.As(new(stream.MCPStreamableHTTPServerContextHook)), + fx.ResultTags(`group:"mcp-streamable-http-server-context-hooks"`), + ), + ) +} + +// AsMCPStreamableHTTPServerContextHooks registers several MCP StreamableHTTP server context hooks. +func AsMCPStreamableHTTPServerContextHooks(constructors ...any) fx.Option { + options := []fx.Option{} + + for _, constructor := range constructors { + options = append(options, AsMCPStreamableHTTPServerContextHook(constructor)) + } + + return fx.Options(options...) +} diff --git a/fxmcpserver/register_test.go b/fxmcpserver/register_test.go new file mode 100644 index 00000000..1062d270 --- /dev/null +++ b/fxmcpserver/register_test.go @@ -0,0 +1,123 @@ +package fxmcpserver_test + +import ( + "fmt" + "testing" + + "github.com/ankorstore/yokai/fxmcpserver" + "github.com/ankorstore/yokai/fxmcpserver/testdata/hook" + "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" +) + +func TestAsMCPServerTool(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPServerTool(tool.NewSimpleTestTool) + + 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.NewSimpleTestTool) + + 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.NewSimpleTestPrompt) + + 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.NewSimpleTestPrompt) + + 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.NewSimpleTestResource) + + 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.NewSimpleTestResource) + + 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(resourcetemplate.NewSimpleTestResourceTemplate) + + 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(resourcetemplate.NewSimpleTestResourceTemplate) + + assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPSSEServerContextHook(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPSSEServerContextHook(hook.NewSimpleMCPSSEServerContextHook) + + assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPSSEServerContextHooks(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPSSEServerContextHooks(hook.NewSimpleMCPSSEServerContextHook) + + assert.Equal(t, "fx.optionGroup", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPStreamableHTTPServerContextHook(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPStreamableHTTPServerContextHook(hook.NewSimpleMCPStreamableHTTPServerContextHook) + + assert.Equal(t, "fx.provideOption", fmt.Sprintf("%T", reg)) + assert.Implements(t, (*fx.Option)(nil), reg) +} + +func TestAsMCPStreamableHTTPServerContextHooks(t *testing.T) { + t.Parallel() + + reg := fxmcpserver.AsMCPStreamableHTTPServerContextHooks(hook.NewSimpleMCPStreamableHTTPServerContextHook) + + 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..1386ecd7 --- /dev/null +++ b/fxmcpserver/server/healthcheck.go @@ -0,0 +1,78 @@ +package server + +import ( + "context" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "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 + steamableHTTPServer *stream.MCPStreamableHTTPServer + sseServer *sse.MCPSSEServer + stdioServer *stdio.MCPStdioServer +} + +// NewMCPServerProbe returns a new MCPServerProbe. +func NewMCPServerProbe( + config *config.Config, + steamableHTTPServer *stream.MCPStreamableHTTPServer, + sseServer *sse.MCPSSEServer, + stdioServer *stdio.MCPStdioServer, +) *MCPServerProbe { + return &MCPServerProbe{ + config: config, + steamableHTTPServer: steamableHTTPServer, + 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.stream.expose") { + if p.steamableHTTPServer.Running() { + messages = append(messages, "MCP StreamableHTTP server is running") + } else { + success = false + messages = append(messages, "MCP StreamableHTTP server is not running") + } + } + + 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..e34965e3 --- /dev/null +++ b/fxmcpserver/server/healthcheck_test.go @@ -0,0 +1,36 @@ +package server_test + +import ( + "context" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "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") + + streamSrv := stream.NewDefaultMCPStreamableHTTPServerFactory(cfg).Create(mcpSrv) + sseSrv := sse.NewDefaultMCPSSEServerFactory(cfg).Create(mcpSrv) + stdioSrv := stdio.NewDefaultMCPStdioServerFactory().Create(mcpSrv) + + probe := fs.NewMCPServerProbe(cfg, streamSrv, sseSrv, stdioSrv) + + res := probe.Check(context.Background()) + + assert.False(t, res.Success) + assert.Equal(t, "MCP StreamableHTTP server is not running, MCP SSE server is not running", res.Message) +} diff --git a/fxmcpserver/server/provider.go b/fxmcpserver/server/provider.go new file mode 100644 index 00000000..e9f8e23e --- /dev/null +++ b/fxmcpserver/server/provider.go @@ -0,0 +1,270 @@ +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 != "" { + buckets = []float64{} + + 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. +// +//nolint:cyclop,gocognit +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) + } + } + + //nolint:exhaustive + 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.resourceURI", 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) + } + } + + //nolint:exhaustive + 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.resourceURI", 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 +} + +// Reset resets the MCP requests metrics. +func (p *DefaultMCPServerHooksProvider) Reset() { + p.requestsCounter.Reset() + p.requestsDuration.Reset() +} 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..9b4f0691 --- /dev/null +++ b/fxmcpserver/server/registry_test.go @@ -0,0 +1,71 @@ +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/resourcetemplate" + "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.NewSimpleTestTool(), + }, + []server.MCPServerPrompt{ + prompt.NewSimpleTestPrompt(), + }, + []server.MCPServerResource{ + resource.NewSimpleTestResource(), + }, + []server.MCPServerResourceTemplate{ + resourcetemplate.NewSimpleTestResourceTemplate(), + }, + ) + + 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{ + "simple-test-tool": "github.com/ankorstore/yokai/fxmcpserver/testdata/tool.(*SimpleTestTool).Handle.func1", + }, + Prompts: map[string]string{ + "simple-test-prompt": "github.com/ankorstore/yokai/fxmcpserver/testdata/prompt.(*SimpleTestPrompt).Handle.func1", + }, + Resources: map[string]string{ + "simple-test-resource": "github.com/ankorstore/yokai/fxmcpserver/testdata/resource.(*SimpleTestResource).Handle.func1", + }, + ResourceTemplates: map[string]string{ + "simple-test-resource-template": "github.com/ankorstore/yokai/fxmcpserver/testdata/resourcetemplate.(*SimpleTestResourceTemplate).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..4037473c --- /dev/null +++ b/fxmcpserver/server/sse/context.go @@ -0,0 +1,117 @@ +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" + "go.opentelemetry.io/otel/propagation" + ot "go.opentelemetry.io/otel/trace" +) + +var _ MCPSSEServerContextHandler = (*DefaultMCPSSEServerContextHandler)(nil) + +// MCPSSEServerContextHook is the interface for MCP SSE server context hooks. +type MCPSSEServerContextHook interface { + Handle() server.SSEContextFunc +} + +// 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 + textMapPropagator propagation.TextMapPropagator + logger *log.Logger + contextHooks []MCPSSEServerContextHook +} + +// NewDefaultMCPSSEServerContextHandler returns a new DefaultMCPSSEServerContextHandler instance. +func NewDefaultMCPSSEServerContextHandler( + generator uuid.UuidGenerator, + tracerProvider ot.TracerProvider, + textMapPropagator propagation.TextMapPropagator, + logger *log.Logger, + contextHooks ...MCPSSEServerContextHook, +) *DefaultMCPSSEServerContextHandler { + return &DefaultMCPSSEServerContextHandler{ + generator: generator, + tracerProvider: tracerProvider, + textMapPropagator: textMapPropagator, + logger: logger, + contextHooks: contextHooks, + } +} + +// Handle returns the handler func. +func (h *DefaultMCPSSEServerContextHandler) Handle() server.SSEContextFunc { + return func(ctx context.Context, req *http.Request) context.Context { + // start time propagation + ctx = fsc.WithStartTime(ctx, time.Now()) + + // sessionId propagation + sID := req.URL.Query().Get("sessionId") + + ctx = fsc.WithSessionID(ctx, sID) + + // requestId propagation + rID := req.Header.Get("X-Request-Id") + + if rID == "" { + rID = h.generator.Generate() + req.Header.Set("X-Request-Id", rID) + } + + ctx = fsc.WithRequestID(ctx, rID) + + // tracer propagation + ctx = h.textMapPropagator.Extract(ctx, propagation.HeaderCarrier(req.Header)) + + ctx = trace.WithContext(ctx, h.tracerProvider) + + ctx, span := trace.CtxTracer(ctx).Start( + ctx, + "MCP", + 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() + + ctx = logger.WithContext(ctx) + + // cancellation removal propagation + ctx = context.WithoutCancel(ctx) + + // hooks propagation + for _, hook := range h.contextHooks { + ctx = hook.Handle()(ctx, req) + } + + return ctx + } +} diff --git a/fxmcpserver/server/sse/context_test.go b/fxmcpserver/server/sse/context_test.go new file mode 100644 index 00000000..6cbf7ed2 --- /dev/null +++ b/fxmcpserver/server/sse/context_test.go @@ -0,0 +1,151 @@ +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/fxmcpserver/testdata/hook" + "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/propagation" + "go.opentelemetry.io/otel/sdk/trace" +) + +type generatorMock struct { + mock.Mock +} + +func (m *generatorMock) Generate() string { + return m.Called().String(0) +} + +//nolint:cyclop +func TestDefaultMCPSSEServerContextHandler_Handle(t *testing.T) { + t.Parallel() + + t.Run("with defaults", func(t *testing.T) { + t.Parallel() + + gm := new(generatorMock) + gm.On("Generate").Return("test-request-id") + + tp := trace.NewTracerProvider() + + tmp := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + handler := sse.NewDefaultMCPSSEServerContextHandler(gm, tp, tmp, 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) + }) + + t.Run("with provided session id and request id and hook", func(t *testing.T) { + t.Parallel() + + gm := new(generatorMock) + gm.AssertNotCalled(t, "Generate") + + tp := trace.NewTracerProvider() + + tmp := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + hk := hook.NewSimpleMCPSSEServerContextHook() + + handler := sse.NewDefaultMCPSSEServerContextHandler(gm, tp, tmp, lg, hk) + + 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", + }) + + //nolint:forcetypeassert + assert.Equal(t, "bar", ctx.Value("foo").(string)) + + gm.AssertExpectations(t) + }) +} diff --git a/fxmcpserver/server/sse/factory.go b/fxmcpserver/server/sse/factory.go new file mode 100644 index 00000000..98fd015c --- /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.WithStaticBasePath(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..61e033ce --- /dev/null +++ b/fxmcpserver/server/sse/server.go @@ -0,0 +1,113 @@ +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.mutex.Lock() + s.running = false + s.mutex.Unlock() + } + + 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..64ffcb34 --- /dev/null +++ b/fxmcpserver/server/sse/server_test.go @@ -0,0 +1,78 @@ +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()) + + //nolint:errcheck + go srv.Start(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", + }) + + err = srv.Stop(ctx) + assert.NoError(t, err) + + 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..a7d49467 --- /dev/null +++ b/fxmcpserver/server/stdio/context.go @@ -0,0 +1,84 @@ +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() + + ctx = logger.WithContext(ctx) + + // cancellation removal propagation + return context.WithoutCancel(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/stream/context.go b/fxmcpserver/server/stream/context.go new file mode 100644 index 00000000..9b0c0730 --- /dev/null +++ b/fxmcpserver/server/stream/context.go @@ -0,0 +1,110 @@ +package stream + +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" + "go.opentelemetry.io/otel/propagation" + ot "go.opentelemetry.io/otel/trace" +) + +var _ MCPStreamableHTTPServerContextHandler = (*DefaultMCPStreamableHTTPServerContextHandler)(nil) + +// MCPStreamableHTTPServerContextHook is the interface for MCP StreamableHTTP server context hooks. +type MCPStreamableHTTPServerContextHook interface { + Handle() server.HTTPContextFunc +} + +// MCPStreamableHTTPServerContextHandler is the interface for MCP StreamableHTTP server context handlers. +type MCPStreamableHTTPServerContextHandler interface { + Handle() server.HTTPContextFunc +} + +// DefaultMCPStreamableHTTPServerContextHandler is the default MCPStreamableHTTPServerContextHandler implementation. +type DefaultMCPStreamableHTTPServerContextHandler struct { + generator uuid.UuidGenerator + tracerProvider ot.TracerProvider + textMapPropagator propagation.TextMapPropagator + logger *log.Logger + contextHooks []MCPStreamableHTTPServerContextHook +} + +// NewDefaultMCPStreamableHTTPServerContextHandler returns a new DefaultMCPStreamableHTTPServerContextHandler instance. +func NewDefaultMCPStreamableHTTPServerContextHandler( + generator uuid.UuidGenerator, + tracerProvider ot.TracerProvider, + textMapPropagator propagation.TextMapPropagator, + logger *log.Logger, + contextHooks ...MCPStreamableHTTPServerContextHook, +) *DefaultMCPStreamableHTTPServerContextHandler { + return &DefaultMCPStreamableHTTPServerContextHandler{ + generator: generator, + tracerProvider: tracerProvider, + textMapPropagator: textMapPropagator, + logger: logger, + contextHooks: contextHooks, + } +} + +// Handle returns the handler func. +func (h *DefaultMCPStreamableHTTPServerContextHandler) Handle() server.HTTPContextFunc { + return func(ctx context.Context, req *http.Request) context.Context { + // start time propagation + ctx = fsc.WithStartTime(ctx, time.Now()) + + // requestId propagation + rID := req.Header.Get("X-Request-Id") + + if rID == "" { + rID = h.generator.Generate() + req.Header.Set("X-Request-Id", rID) + } + + ctx = fsc.WithRequestID(ctx, rID) + + // tracer propagation + ctx = h.textMapPropagator.Extract(ctx, propagation.HeaderCarrier(req.Header)) + + ctx = trace.WithContext(ctx, h.tracerProvider) + + ctx, span := trace.CtxTracer(ctx).Start( + ctx, + "MCP", + ot.WithSpanKind(ot.SpanKindServer), + ot.WithAttributes( + attribute.String("system", "mcpserver"), + attribute.String("mcp.transport", "streamable-http"), + attribute.String("mcp.requestID", rID), + ), + ) + + ctx = fsc.WithRootSpan(ctx, span) + + // logger propagation + logger := h.logger. + With(). + Str("system", "mcpserver"). + Str("mcpTransport", "streamable-http"). + Str("mcpRequestID", rID). + Logger() + + ctx = logger.WithContext(ctx) + + // cancellation removal propagation + ctx = context.WithoutCancel(ctx) + + // hooks propagation + for _, hook := range h.contextHooks { + ctx = hook.Handle()(ctx, req) + } + + return ctx + } +} diff --git a/fxmcpserver/server/stream/context_test.go b/fxmcpserver/server/stream/context_test.go new file mode 100644 index 00000000..9f4dcee2 --- /dev/null +++ b/fxmcpserver/server/stream/context_test.go @@ -0,0 +1,141 @@ +package stream_test + +import ( + "context" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "net/http" + "net/http/httptest" + "testing" + + servercontext "github.com/ankorstore/yokai/fxmcpserver/server/context" + "github.com/ankorstore/yokai/fxmcpserver/testdata/hook" + "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/propagation" + "go.opentelemetry.io/otel/sdk/trace" +) + +type generatorMock struct { + mock.Mock +} + +func (m *generatorMock) Generate() string { + return m.Called().String(0) +} + +func TestDefaultMCPStreamableHTTPServerContextHandler_Handle(t *testing.T) { + t.Parallel() + + t.Run("with defaults", func(t *testing.T) { + t.Parallel() + + gm := new(generatorMock) + gm.On("Generate").Return("test-request-id") + + tp := trace.NewTracerProvider() + + tmp := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + handler := stream.NewDefaultMCPStreamableHTTPServerContextHandler(gm, tp, tmp, lg) + + req := httptest.NewRequest(http.MethodGet, "/mcp", 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, "streamable-http", 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": "streamable-http", + "mcpRequestID": "test-request-id", + "message": "test log", + }) + + gm.AssertExpectations(t) + }) + + t.Run("with provided request id and hook", func(t *testing.T) { + t.Parallel() + + gm := new(generatorMock) + gm.AssertNotCalled(t, "Generate") + + tp := trace.NewTracerProvider() + + tmp := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + + lb := logtest.NewDefaultTestLogBuffer() + lg, err := log.NewDefaultLoggerFactory().Create(log.WithOutputWriter(lb)) + assert.NoError(t, err) + + hk := hook.NewSimpleMCPStreamableHTTPServerContextHook() + + handler := stream.NewDefaultMCPStreamableHTTPServerContextHandler(gm, tp, tmp, lg, hk) + + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + req.Header.Set("X-Request-Id", "test-request-id") + + ctx := handler.Handle()(context.Background(), req) + + 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, "streamable-http", 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": "streamable-http", + "mcpRequestID": "test-request-id", + "message": "test log", + }) + + //nolint:forcetypeassert + assert.Equal(t, "bar", ctx.Value("foo").(string)) + + gm.AssertExpectations(t) + }) +} diff --git a/fxmcpserver/server/stream/factory.go b/fxmcpserver/server/stream/factory.go new file mode 100644 index 00000000..72acd662 --- /dev/null +++ b/fxmcpserver/server/stream/factory.go @@ -0,0 +1,76 @@ +package stream + +import ( + "github.com/ankorstore/yokai/config" + "github.com/mark3labs/mcp-go/server" + "time" +) + +const ( + DefaultAddr = ":8083" + DefaultBasePath = "/mcp" + DefaultKeepAliveInterval = 10 * time.Second +) + +var _ MCPStreamableHTTPServerFactory = (*DefaultMCPStreamableHTTPServerFactory)(nil) + +// MCPStreamableHTTPServerFactory is the interface for MCP StreamableHTTP server factories. +type MCPStreamableHTTPServerFactory interface { + Create(mcpServer *server.MCPServer, options ...server.StreamableHTTPOption) *MCPStreamableHTTPServer +} + +// DefaultMCPStreamableHTTPServerFactory is the default MCPStreamableHTTPServerFactory implementation. +type DefaultMCPStreamableHTTPServerFactory struct { + config *config.Config +} + +// NewDefaultMCPStreamableHTTPServerFactory returns a new DefaultMCPStreamableHTTPServerFactory instance. +func NewDefaultMCPStreamableHTTPServerFactory(config *config.Config) *DefaultMCPStreamableHTTPServerFactory { + return &DefaultMCPStreamableHTTPServerFactory{ + config: config, + } +} + +// Create returns a new MCPStreamableHTTPServer instance. +func (f *DefaultMCPStreamableHTTPServerFactory) Create(mcpServer *server.MCPServer, options ...server.StreamableHTTPOption) *MCPStreamableHTTPServer { + addr := f.config.GetString("modules.mcp.server.transport.stream.address") + if addr == "" { + addr = DefaultAddr + } + + stateless := f.config.GetBool("modules.mcp.server.transport.stream.stateless") + + basePath := f.config.GetString("modules.mcp.server.transport.stream.base_path") + if basePath == "" { + basePath = DefaultBasePath + } + + keepAlive := f.config.GetBool("modules.mcp.server.transport.stream.keep_alive") + + keepAliveInterval := DefaultKeepAliveInterval + keepAliveIntervalConfig := f.config.GetInt("modules.mcp.server.transport.stream.keep_alive_interval") + if keepAliveIntervalConfig != 0 { + keepAliveInterval = time.Duration(keepAliveIntervalConfig) * time.Second + } + + srvConfig := MCPStreamableHTTPServerConfig{ + Address: addr, + Stateless: stateless, + BasePath: basePath, + KeepAlive: keepAlive, + KeepAliveInterval: keepAliveInterval, + } + + srvOptions := []server.StreamableHTTPOption{ + server.WithStateLess(srvConfig.Stateless), + server.WithEndpointPath(srvConfig.BasePath), + } + + if srvConfig.KeepAlive { + srvOptions = append(srvOptions, server.WithHeartbeatInterval(srvConfig.KeepAliveInterval)) + } + + srvOptions = append(srvOptions, options...) + + return NewMCPStreamableHTTPServer(mcpServer, srvConfig, srvOptions...) +} diff --git a/fxmcpserver/server/stream/factory_test.go b/fxmcpserver/server/stream/factory_test.go new file mode 100644 index 00000000..1fe6de07 --- /dev/null +++ b/fxmcpserver/server/stream/factory_test.go @@ -0,0 +1,35 @@ +package stream_test + +import ( + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "testing" + + "github.com/ankorstore/yokai/config" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestDefaultMCPStreamableHTTPServerFactory_Create(t *testing.T) { + t.Parallel() + + cfg, err := config.NewDefaultConfigFactory().Create( + config.WithFilePaths("../../testdata/config"), + ) + assert.NoError(t, err) + + mcpSrv := &server.MCPServer{} + + fac := stream.NewDefaultMCPStreamableHTTPServerFactory(cfg) + + srv := fac.Create(mcpSrv) + + assert.IsType(t, (*server.StreamableHTTPServer)(nil), srv.Server()) + + assert.Equal(t, ":0", srv.Config().Address) + assert.True(t, srv.Config().Stateless) + assert.Equal(t, stream.DefaultBasePath, srv.Config().BasePath) + assert.True(t, srv.Config().KeepAlive) + assert.Equal(t, stream.DefaultKeepAliveInterval, srv.Config().KeepAliveInterval) + + assert.False(t, srv.Running()) +} diff --git a/fxmcpserver/server/stream/server.go b/fxmcpserver/server/stream/server.go new file mode 100644 index 00000000..c6d426f5 --- /dev/null +++ b/fxmcpserver/server/stream/server.go @@ -0,0 +1,110 @@ +package stream + +import ( + "context" + "github.com/ankorstore/yokai/log" + "github.com/mark3labs/mcp-go/server" + "sync" + "time" +) + +// MCPStreamableHTTPServerConfig is the MCP StreamableHTTP server configuration. +type MCPStreamableHTTPServerConfig struct { + Address string + Stateless bool + BasePath string + KeepAlive bool + KeepAliveInterval time.Duration +} + +// MCPStreamableHTTPServer is the MCP StreamableHTTP server. +type MCPStreamableHTTPServer struct { + server *server.StreamableHTTPServer + config MCPStreamableHTTPServerConfig + mutex sync.RWMutex + running bool +} + +// NewMCPStreamableHTTPServer returns a new MCPStreamableHTTPServer instance. +func NewMCPStreamableHTTPServer(mcpServer *server.MCPServer, config MCPStreamableHTTPServerConfig, opts ...server.StreamableHTTPOption) *MCPStreamableHTTPServer { + streamableHTTPServer := server.NewStreamableHTTPServer(mcpServer, opts...) + + return &MCPStreamableHTTPServer{ + server: streamableHTTPServer, + config: config, + } +} + +// Server returns the MCPStreamableHTTPServer underlying server. +func (s *MCPStreamableHTTPServer) Server() *server.StreamableHTTPServer { + return s.server +} + +// Config returns the MCPStreamableHTTPServer config. +func (s *MCPStreamableHTTPServer) Config() MCPStreamableHTTPServerConfig { + return s.config +} + +// Start starts the MCPStreamableHTTPServer. +func (s *MCPStreamableHTTPServer) Start(ctx context.Context) error { + logger := log.CtxLogger(ctx) + + logger.Info().Msgf("starting MCP StreamableHTTP 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 StreamableHTTP server") + + s.mutex.Lock() + s.running = false + s.mutex.Unlock() + } + + return err +} + +// Stop stops the MCPStreamableHTTPServer. +func (s *MCPStreamableHTTPServer) Stop(ctx context.Context) error { + logger := log.CtxLogger(ctx) + + logger.Info().Msg("stopping MCP StreamableHTTP 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 StreamableHTTP server") + } + + return err +} + +// Running returns true if the MCPStreamableHTTPServer is running. +func (s *MCPStreamableHTTPServer) Running() bool { + s.mutex.Lock() + defer s.mutex.Unlock() + + return s.running +} + +// Info returns the MCPStreamableHTTPServer information. +func (s *MCPStreamableHTTPServer) Info() map[string]any { + return map[string]any{ + "config": map[string]any{ + "address": s.config.Address, + "stateless": s.config.Stateless, + "base_path": s.config.BasePath, + "keep_alive": s.config.KeepAlive, + "keep_alive_interval": s.config.KeepAliveInterval.Seconds(), + }, + "status": map[string]any{ + "running": s.Running(), + }, + } +} diff --git a/fxmcpserver/server/stream/server_test.go b/fxmcpserver/server/stream/server_test.go new file mode 100644 index 00000000..a9ed57d0 --- /dev/null +++ b/fxmcpserver/server/stream/server_test.go @@ -0,0 +1,76 @@ +package stream_test + +import ( + "context" + "github.com/ankorstore/yokai/fxmcpserver/server/stream" + "testing" + "time" + + "github.com/ankorstore/yokai/config" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/log/logtest" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" +) + +func TestMCPStreamableHTTPServer(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 := stream.NewDefaultMCPStreamableHTTPServerFactory(cfg).Create(mcpSrv) + + assert.False(t, srv.Running()) + + assert.Equal( + t, + map[string]any{ + "config": map[string]any{ + "address": ":0", + "stateless": true, + "base_path": stream.DefaultBasePath, + "keep_alive": true, + "keep_alive_interval": stream.DefaultKeepAliveInterval.Seconds(), + }, + "status": map[string]any{ + "running": false, + }, + }, + srv.Info(), + ) + + ctx := lg.WithContext(context.Background()) + + //nolint:errcheck + go srv.Start(ctx) + + time.Sleep(1 * time.Millisecond) + + assert.True(t, srv.Running()) + + logtest.AssertHasLogRecord(t, lb, map[string]any{ + "level": "info", + "message": "starting MCP StreamableHTTP server on :0", + }) + + err = srv.Stop(ctx) + assert.NoError(t, err) + + time.Sleep(1 * time.Millisecond) + + assert.False(t, srv.Running()) + + logtest.AssertHasLogRecord(t, lb, map[string]any{ + "level": "info", + "message": "stopping MCP StreamableHTTP server", + }) +} diff --git a/fxmcpserver/server/util.go b/fxmcpserver/server/util.go new file mode 100644 index 00000000..ea2637bb --- /dev/null +++ b/fxmcpserver/server/util.go @@ -0,0 +1,25 @@ +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, " ", ""), ",") +} diff --git a/fxmcpserver/server/util_test.go b/fxmcpserver/server/util_test.go new file mode 100644 index 00000000..50927626 --- /dev/null +++ b/fxmcpserver/server/util_test.go @@ -0,0 +1,35 @@ +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 ")) +} 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 new file mode 100644 index 00000000..74585533 --- /dev/null +++ b/fxmcpserver/testdata/config/config.yaml @@ -0,0 +1,43 @@ +app: + name: test + version: 0.1.0 +modules: + mcp: + server: + name: "test-server" + version: 1.0.0 + capabilities: + resources: true + prompts: true + tools: true + transport: + stream: + expose: true + address: ":0" + stateless: true + base_path: "/mcp" + keep_alive: true + keep_alive_interval: 10 + sse: + expose: true + address: ":0" + base_url: "" + base_path: "" + sse_endpoint: "/sse" + message_endpoint: "/message" + keep_alive: true + keep_alive_interval: 10 + stdio: + expose: false + log: + request: true + response: true + trace: + request: true + response: true + metrics: + collect: + enabled: true + namespace: foo + subsystem: bar + buckets: 0.1, 1, 10 diff --git a/fxmcpserver/testdata/hook/sse.go b/fxmcpserver/testdata/hook/sse.go new file mode 100644 index 00000000..9b7addb1 --- /dev/null +++ b/fxmcpserver/testdata/hook/sse.go @@ -0,0 +1,20 @@ +package hook + +import ( + "context" + "net/http" + + "github.com/mark3labs/mcp-go/server" +) + +type SimpleMCPSSEServerContextHook struct{} + +func NewSimpleMCPSSEServerContextHook() *SimpleMCPSSEServerContextHook { + return &SimpleMCPSSEServerContextHook{} +} + +func (p *SimpleMCPSSEServerContextHook) Handle() server.SSEContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, "foo", "bar") + } +} diff --git a/fxmcpserver/testdata/hook/stream.go b/fxmcpserver/testdata/hook/stream.go new file mode 100644 index 00000000..9a9a0e69 --- /dev/null +++ b/fxmcpserver/testdata/hook/stream.go @@ -0,0 +1,20 @@ +package hook + +import ( + "context" + "net/http" + + "github.com/mark3labs/mcp-go/server" +) + +type SimpleMCPStreamableHTTPServerContextHook struct{} + +func NewSimpleMCPStreamableHTTPServerContextHook() *SimpleMCPStreamableHTTPServerContextHook { + return &SimpleMCPStreamableHTTPServerContextHook{} +} + +func (p *SimpleMCPStreamableHTTPServerContextHook) Handle() server.HTTPContextFunc { + return func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, "foo", "bar") + } +} diff --git a/fxmcpserver/testdata/prompt/simple.go b/fxmcpserver/testdata/prompt/simple.go new file mode 100644 index 00000000..bbd9c43f --- /dev/null +++ b/fxmcpserver/testdata/prompt/simple.go @@ -0,0 +1,42 @@ +package prompt + +import ( + "context" + "fmt" + + "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) { + //nolint:forcetypeassert + value := ctx.Value("foo").(string) + + return mcp.NewGetPromptResult( + "ok", + []mcp.PromptMessage{ + mcp.NewPromptMessage( + mcp.RoleAssistant, + mcp.NewTextContent(fmt.Sprintf("context hook value: %s", value)), + ), + }, + ), nil + } +} diff --git a/fxmcpserver/testdata/resource/simple.go b/fxmcpserver/testdata/resource/simple.go new file mode 100644 index 00000000..68fbb24b --- /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: "simple test resource", + }, + }, 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..57020774 --- /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.GetArguments()["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/fxworker/CHANGELOG.md b/fxworker/CHANGELOG.md index 623f5807..78636866 100644 --- a/fxworker/CHANGELOG.md +++ b/fxworker/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.1.1](https://github.com/ankorstore/yokai/compare/fxworker/v1.1.0...fxworker/v1.1.1) (2025-09-02) + + +### Bug Fixes + +* **fxworker:** Fix resolution collision by using full import path in type IDs ([#365](https://github.com/ankorstore/yokai/issues/365)) ([bb92938](https://github.com/ankorstore/yokai/commit/bb92938031f7cbab1e04ec6bf844d870c204bcac)) + ## [1.1.0](https://github.com/ankorstore/yokai/compare/fxworker/v1.0.0...fxworker/v1.1.0) (2024-03-15) diff --git a/fxworker/reflect.go b/fxworker/reflect.go index 4c5bb840..01a491f2 100644 --- a/fxworker/reflect.go +++ b/fxworker/reflect.go @@ -4,12 +4,39 @@ import ( "reflect" ) -// GetType returns the type of a target. +// fullTypeID builds a stable identifier for a type in the form ".". +func fullTypeID(t reflect.Type) string { + if t == nil { + return "" + } + + // Unwrap pointers to get the underlying named type (if any). + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // For named types, PkgPath() + Name() gives a unique and stable identity. + if t.Name() != "" && t.PkgPath() != "" { + return t.PkgPath() + "." + t.Name() + } + + // Fallback for non-named kinds (slices, maps, func, etc.). + return t.String() +} + +// GetType returns a stable identifier for the given target’s type. func GetType(target any) string { - return reflect.TypeOf(target).String() + return fullTypeID(reflect.TypeOf(target)) } -// GetReturnType returns the return type of a target. +// GetReturnType returns a stable identifier for the return type of constructor-like target. +// If a target is a function, we examine its first return value (index 0), unwrap pointers, and +// build an identifier for that named type. For non-function or empty-return cases, we return "". func GetReturnType(target any) string { - return reflect.TypeOf(target).Out(0).String() + t := reflect.TypeOf(target) + if t == nil || t.Kind() != reflect.Func || t.NumOut() == 0 { + return "" + } + + return fullTypeID(t.Out(0)) } diff --git a/fxworker/reflect_test.go b/fxworker/reflect_test.go index ad44cc9e..2f57c20e 100644 --- a/fxworker/reflect_test.go +++ b/fxworker/reflect_test.go @@ -15,9 +15,10 @@ func TestGetType(t *testing.T) { target any expected string }{ + {nil, ""}, {123, "int"}, {"test", "string"}, - {workers.NewClassicWorker(), "*workers.ClassicWorker"}, + {workers.NewClassicWorker(), "github.com/ankorstore/yokai/worker/testdata/workers.ClassicWorker"}, } for _, tt := range tests { @@ -39,6 +40,7 @@ func TestGetReturnType(t *testing.T) { target any expected string }{ + {nil, ""}, {func() string { return "test" }, "string"}, {func() int { return 123 }, "int"}, } diff --git a/fxworker/registry_test.go b/fxworker/registry_test.go index 0227cdbd..19f18355 100644 --- a/fxworker/registry_test.go +++ b/fxworker/registry_test.go @@ -31,8 +31,8 @@ func TestResolveCheckerProbesRegistrationsSuccess(t *testing.T) { workers.NewCancellableWorker(), }, Definitions: []fxworker.WorkerDefinition{ - fxworker.NewWorkerDefinition("*workers.ClassicWorker"), - fxworker.NewWorkerDefinition("*workers.CancellableWorker"), + fxworker.NewWorkerDefinition("github.com/ankorstore/yokai/worker/testdata/workers.ClassicWorker"), + fxworker.NewWorkerDefinition("github.com/ankorstore/yokai/worker/testdata/workers.CancellableWorker"), }, } diff --git a/httpclient/CHANGELOG.md b/httpclient/CHANGELOG.md index 385b4b6b..55c545e8 100644 --- a/httpclient/CHANGELOG.md +++ b/httpclient/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.5.0](https://github.com/ankorstore/yokai/compare/httpclient/v1.4.0...httpclient/v1.5.0) (2025-03-06) + + +### Features + +* **httpclient:** Added test server helper ([#323](https://github.com/ankorstore/yokai/issues/323)) ([b2dd342](https://github.com/ankorstore/yokai/commit/b2dd342ecef06b24a290a9723bd839e5032f08cc)) + ## [1.4.0](https://github.com/ankorstore/yokai/compare/httpclient/v1.3.1...httpclient/v1.4.0) (2024-03-15) diff --git a/httpclient/README.md b/httpclient/README.md index 37db9cf6..7e0fc662 100644 --- a/httpclient/README.md +++ b/httpclient/README.md @@ -9,15 +9,14 @@ > Http client module based on [net/http](https://pkg.go.dev/net/http). - * [Installation](#installation) * [Documentation](#documentation) - * [Requests](#requests) - * [Transports](#transports) - * [BaseTransport](#basetransport) - * [LoggerTransport](#loggertransport) - * [MetricsTransport](#metricstransport) - + * [Requests](#requests) + * [Transports](#transports) + * [BaseTransport](#basetransport) + * [LoggerTransport](#loggertransport) + * [MetricsTransport](#metricstransport) + * [Testing](#testing) ## Installation @@ -224,4 +223,96 @@ map[string]string{ Then if the request path is `/foo/1/bar?page=2`, the metric path label will be masked with `/foo/{fooId}/bar?page={pageId}`. +### Testing + +This module provides a [httpclienttest.NewTestHTTPServer()](httpclienttest/server.go) helper for testing your clients against a test server, that allows you: + +- to define test HTTP roundtrips: a couple of test aware functions to define the request and the response behavior +- to configure several test HTTP roundtrips if you need to test successive calls + +To use it: + +```go +package main_test + +import ( + "errors" + "net/http" + "testing" + + "github.com/ankorstore/yokai/httpclient" + "github.com/ankorstore/yokai/httpclient/httpclienttest" + "github.com/stretchr/testify/assert" +) + +func TestHTTPClient(t *testing.T) { + t.Parallel() + + // client + client, err := httpclient.NewDefaultHttpClientFactory().Create() + assert.NoError(t, err) + + // test server preparation + testServer := httpclienttest.NewTestHTTPServer( + t, + // configures a roundtrip for the 1st client call + httpclienttest.WithTestHTTPRoundTrip( + // func to configure / assert on the client request + func(tb testing.TB, req *http.Request) error { + tb.Helper() + + // performs some assertions + assert.Equal(tb, "/foo", req.URL.Path) + + // returning an error here will make the test fail, if needed + return nil + }, + // func to configure / assert on the response for the client + func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + // prepares the response for the client + w.Header.Set("foo", "bar") + + // performs some assertions + assert.Equal(tb, "bar", w.Header.Get("foo")) + + // returning an error here will make the test fail, if needed + return nil + }, + ), + // configures a roundtrip for the 2nd client call + httpclienttest.WithTestHTTPRoundTrip( + // func to configure / assert on the client request + func(tb testing.TB, req *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/bar", req.URL.Path) + + return nil + }, + // func to configure / assert on the response for the client + func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + w.WriteHeader(http.StatusInternalServerError) + + return nil + }, + ), + ) + + // 1st client call + resp, err := client.Get(testServer.URL + "/foo") + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "bar", resp.Header.Get("foo")) + + // 2nd client call + resp, err = client.Get(testServer.URL + "/bar") + assert.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) +} +``` +You can find more complete examples in the [tests](httpclienttest/server_test.go). diff --git a/httpclient/httpclienttest/option.go b/httpclient/httpclienttest/option.go new file mode 100644 index 00000000..33ba72c5 --- /dev/null +++ b/httpclient/httpclienttest/option.go @@ -0,0 +1,22 @@ +package httpclienttest + +type TestHTTPServerOptions struct { + RoundtripsStack []TestHTTPRoundTrip +} + +func DefaultTestHTTPServerOptions() TestHTTPServerOptions { + return TestHTTPServerOptions{ + RoundtripsStack: []TestHTTPRoundTrip{}, + } +} + +type TestHTTPServerOptionFunc func(o *TestHTTPServerOptions) + +func WithTestHTTPRoundTrip(reqFunc TestHTTPRequestFunc, responseFunc TestHTTPResponseFunc) TestHTTPServerOptionFunc { + return func(o *TestHTTPServerOptions) { + o.RoundtripsStack = append(o.RoundtripsStack, TestHTTPRoundTrip{ + RequestFunc: reqFunc, + ResponseFunc: responseFunc, + }) + } +} diff --git a/httpclient/httpclienttest/option_test.go b/httpclient/httpclienttest/option_test.go new file mode 100644 index 00000000..1ee9107c --- /dev/null +++ b/httpclient/httpclienttest/option_test.go @@ -0,0 +1,32 @@ +package httpclienttest_test + +import ( + "net/http" + "testing" + + "github.com/ankorstore/yokai/httpclient/httpclienttest" + "github.com/stretchr/testify/assert" +) + +func TestTestHTTPServerOptions(t *testing.T) { + t.Parallel() + + defaultOptions := httpclienttest.DefaultTestHTTPServerOptions() + assert.Len(t, defaultOptions.RoundtripsStack, 0) + + option := httpclienttest.WithTestHTTPRoundTrip( + func(tb testing.TB, req *http.Request) error { + tb.Helper() + + return nil + }, + func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + return nil + }, + ) + + option(&defaultOptions) + assert.Len(t, defaultOptions.RoundtripsStack, 1) +} diff --git a/httpclient/httpclienttest/server.go b/httpclient/httpclienttest/server.go new file mode 100644 index 00000000..b5304b75 --- /dev/null +++ b/httpclient/httpclienttest/server.go @@ -0,0 +1,60 @@ +package httpclienttest + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +type TestHTTPRequestFunc func(tb testing.TB, req *http.Request) error +type TestHTTPResponseFunc func(tb testing.TB, w http.ResponseWriter) error + +type TestHTTPRoundTrip struct { + RequestFunc TestHTTPRequestFunc + ResponseFunc TestHTTPResponseFunc +} + +func NewTestHTTPServer(tb testing.TB, options ...TestHTTPServerOptionFunc) *httptest.Server { + tb.Helper() + + var mu sync.Mutex + + serverOptions := DefaultTestHTTPServerOptions() + for _, opt := range options { + opt(&serverOptions) + } + + if len(serverOptions.RoundtripsStack) == 0 { + tb.Error("test HTTP server: empty roundtrips stack") + + return nil + } + + stackPosition := 0 + + return httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + if stackPosition >= len(serverOptions.RoundtripsStack) { + tb.Error("test HTTP server: roundtrips stack exhausted") + + return + } + + err := serverOptions.RoundtripsStack[stackPosition].RequestFunc(tb, r) + assert.NoError(tb, err) + + err = serverOptions.RoundtripsStack[stackPosition].ResponseFunc(tb, w) + assert.NoError(tb, err) + + stackPosition++ + }, + ), + ) +} diff --git a/httpclient/httpclienttest/server_test.go b/httpclient/httpclienttest/server_test.go new file mode 100644 index 00000000..9ea20f77 --- /dev/null +++ b/httpclient/httpclienttest/server_test.go @@ -0,0 +1,370 @@ +package httpclienttest_test + +import ( + "errors" + "net/http" + "sync" + "testing" + + "github.com/ankorstore/yokai/httpclient/httpclienttest" + "github.com/stretchr/testify/assert" +) + +//nolint:goconst,maintidx +func TestTestHTTPServer(t *testing.T) { + t.Parallel() + + t.Run("test success with single roundtrip", func(t *testing.T) { + t.Parallel() + + testReqFn := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/foo", r.URL.Path) + + return nil + } + + testRespFn := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + w.Header().Set("foo", "bar") + + w.WriteHeader(http.StatusOK) + + return nil + } + + mt := new(testing.T) + + testServer := httpclienttest.NewTestHTTPServer(mt, httpclienttest.WithTestHTTPRoundTrip(testReqFn, testRespFn)) + defer testServer.Close() + + resp, err := testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.False(t, mt.Failed()) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "bar", resp.Header.Get("foo")) + + err = resp.Body.Close() + assert.NoError(t, err) + }) + + t.Run("test success with multiple sequential roundtrips stack", func(t *testing.T) { + t.Parallel() + + testReqFn1 := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/foo", r.URL.Path) + + return nil + } + + testRespFn1 := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + w.Header().Set("foo", "foo") + + return nil + } + + testReqFn2 := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/bar", r.URL.Path) + + return nil + } + + testRespFn2 := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + w.Header().Set("bar", "bar") + + return nil + } + + mt := new(testing.T) + + testServer := httpclienttest.NewTestHTTPServer( + mt, + httpclienttest.WithTestHTTPRoundTrip(testReqFn1, testRespFn1), + httpclienttest.WithTestHTTPRoundTrip(testReqFn2, testRespFn2), + ) + defer testServer.Close() + + resp, err := testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.False(t, mt.Failed()) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "foo", resp.Header.Get("foo")) + + err = resp.Body.Close() + assert.NoError(t, err) + + resp, err = testServer.Client().Get(testServer.URL + "/bar") + assert.NoError(t, err) + + assert.False(t, mt.Failed()) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "bar", resp.Header.Get("bar")) + + err = resp.Body.Close() + assert.NoError(t, err) + }) + + t.Run("test success with multiple concurrent roundtrips stack", func(t *testing.T) { + t.Parallel() + + testReqFn := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/foo", r.URL.Path) + + return nil + } + + testRespFn := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + w.Header().Set("foo", "foo") + + return nil + } + + mt := new(testing.T) + + testServer := httpclienttest.NewTestHTTPServer( + mt, + httpclienttest.WithTestHTTPRoundTrip(testReqFn, testRespFn), + httpclienttest.WithTestHTTPRoundTrip(testReqFn, testRespFn), + ) + + var wg sync.WaitGroup + wg.Add(2) + + go func(tb testing.TB, twg *sync.WaitGroup) { + tb.Helper() + + defer twg.Done() + + resp, err := testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.False(t, mt.Failed()) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "foo", resp.Header.Get("foo")) + + err = resp.Body.Close() + assert.NoError(t, err) + }(mt, &wg) + + go func(tb testing.TB, twg *sync.WaitGroup) { + tb.Helper() + + defer twg.Done() + + resp, err := testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.False(t, mt.Failed()) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "foo", resp.Header.Get("foo")) + + err = resp.Body.Close() + assert.NoError(t, err) + }(mt, &wg) + + wg.Wait() + + testServer.Close() + }) + + t.Run("test error with empty roundtrips stack", func(t *testing.T) { + t.Parallel() + + mt := new(testing.T) + + httpclienttest.NewTestHTTPServer(mt) + + assert.True(t, mt.Failed()) + }) + + t.Run("test error with exhausted roundtrip stack", func(t *testing.T) { + t.Parallel() + + testReqFn := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/foo", r.URL.Path) + + return nil + } + + testRespFn := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + w.Header().Set("foo", "bar") + + return nil + } + + mt := new(testing.T) + + testServer := httpclienttest.NewTestHTTPServer(mt, httpclienttest.WithTestHTTPRoundTrip(testReqFn, testRespFn)) + defer testServer.Close() + + resp, err := testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.False(t, mt.Failed()) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "bar", resp.Header.Get("foo")) + + err = resp.Body.Close() + assert.NoError(t, err) + + resp, err = testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.True(t, mt.Failed()) + + err = resp.Body.Close() + assert.NoError(t, err) + }) + + t.Run("test error with request func error return", func(t *testing.T) { + t.Parallel() + + testReqFn := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/foo", r.URL.Path) + + return errors.New("request error foo") + } + + testRespFn := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + return nil + } + + mt := new(testing.T) + + testServer := httpclienttest.NewTestHTTPServer(mt, httpclienttest.WithTestHTTPRoundTrip(testReqFn, testRespFn)) + defer testServer.Close() + + resp, err := testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.True(t, mt.Failed()) + + err = resp.Body.Close() + assert.NoError(t, err) + }) + + t.Run("test error with request func error assertion", func(t *testing.T) { + t.Parallel() + + testReqFn := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/foo", r.URL.Path) + + return nil + } + + testRespFn := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + return nil + } + + mt := new(testing.T) + + testServer := httpclienttest.NewTestHTTPServer(mt, httpclienttest.WithTestHTTPRoundTrip(testReqFn, testRespFn)) + defer testServer.Close() + + resp, err := testServer.Client().Get(testServer.URL + "/invalid") + assert.NoError(t, err) + + assert.True(t, mt.Failed()) + + err = resp.Body.Close() + assert.NoError(t, err) + }) + + t.Run("test error with response func error return", func(t *testing.T) { + t.Parallel() + + testReqFn := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/foo", r.URL.Path) + + return nil + } + + testRespFn := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + return errors.New("response error foo") + } + + mt := new(testing.T) + + testServer := httpclienttest.NewTestHTTPServer(mt, httpclienttest.WithTestHTTPRoundTrip(testReqFn, testRespFn)) + defer testServer.Close() + + resp, err := testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.True(t, mt.Failed()) + + err = resp.Body.Close() + assert.NoError(t, err) + }) + + t.Run("test error with response func error assertion", func(t *testing.T) { + t.Parallel() + + testReqFn := func(tb testing.TB, r *http.Request) error { + tb.Helper() + + assert.Equal(tb, "/foo", r.URL.Path) + + return nil + } + + testRespFn := func(tb testing.TB, w http.ResponseWriter) error { + tb.Helper() + + assert.True(tb, false) + + return nil + } + + mt := new(testing.T) + + testServer := httpclienttest.NewTestHTTPServer(mt, httpclienttest.WithTestHTTPRoundTrip(testReqFn, testRespFn)) + defer testServer.Close() + + resp, err := testServer.Client().Get(testServer.URL + "/foo") + assert.NoError(t, err) + + assert.True(t, mt.Failed()) + + err = resp.Body.Close() + assert.NoError(t, err) + }) +} diff --git a/mkdocs.yml b/mkdocs.yml index c3fc876a..76bda253 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,17 +72,21 @@ nav: - "Getting started": - "gRPC application": getting-started/grpc-application.md - "HTTP application": getting-started/http-application.md + - "MCP application": getting-started/mcp-application.md - "Worker application": getting-started/worker-application.md - "Tutorials": - "gRPC application": tutorials/grpc-application.md - "HTTP application": tutorials/http-application.md + - "MCP application": tutorials/mcp-application.md - "Worker application": tutorials/worker-application.md - "Demos": - "gRPC application": demos/grpc-application.md - "HTTP application": demos/http-application.md + - "MCP application": demos/mcp-application.md - "Worker application": demos/worker-application.md - "Modules": - "Core": modules/fxcore.md + - "Clock": modules/fxclock.md - "Config": modules/fxconfig.md - "Cron": modules/fxcron.md - "Generate": modules/fxgenerate.md @@ -91,6 +95,7 @@ nav: - "HTTP Client": modules/fxhttpclient.md - "HTTP Server": modules/fxhttpserver.md - "Log": modules/fxlog.md + - "MCP Server": modules/fxmcpserver.md - "Metrics": modules/fxmetrics.md - "ORM": modules/fxorm.md - "SQL": modules/fxsql.md diff --git a/release-please-config.json b/release-please-config.json index 1127d273..cb819e3b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -57,6 +57,11 @@ "component": "worker", "tag-separator": "/" }, + "fxclock": { + "release-type": "go", + "component": "fxclock", + "tag-separator": "/" + }, "fxcore": { "release-type": "go", "component": "fxcore", @@ -102,6 +107,11 @@ "component": "fxlog", "tag-separator": "/" }, + "fxmcpserver": { + "release-type": "go", + "component": "fxmcpserver", + "tag-separator": "/" + }, "fxmetrics": { "release-type": "go", "component": "fxmetrics", diff --git a/trace/CHANGELOG.md b/trace/CHANGELOG.md index 8988563c..61df8033 100644 --- a/trace/CHANGELOG.md +++ b/trace/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.4.0](https://github.com/ankorstore/yokai/compare/trace/v1.3.0...trace/v1.4.0) (2025-03-05) + + +### Features + +* **trace:** Disabled otlp-grpc block mode ([#321](https://github.com/ankorstore/yokai/issues/321)) ([e84e2ce](https://github.com/ankorstore/yokai/commit/e84e2ce249d0b317f4f615ad3bddad4c3cd33102)) + ## [1.3.0](https://github.com/ankorstore/yokai/compare/trace/v1.2.0...trace/v1.3.0) (2024-03-27) diff --git a/trace/otlp.go b/trace/otlp.go index e3733c4e..7f4dc60a 100644 --- a/trace/otlp.go +++ b/trace/otlp.go @@ -18,7 +18,6 @@ func NewOtlpGrpcClientConnection(ctx context.Context, host string, dialOptions . dialContextOptions := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), } dialContextOptions = append(dialContextOptions, dialOptions...) diff --git a/trace/otlp_test.go b/trace/otlp_test.go index d688138e..f520f2da 100644 --- a/trace/otlp_test.go +++ b/trace/otlp_test.go @@ -34,6 +34,7 @@ func TestNewOtlpGrpcInsecureConnectionSuccess(t *testing.T) { context.Background(), "bufnet", grpc.WithContextDialer(bufDialer), + grpc.WithBlock(), ) assert.NoError(t, err) @@ -51,7 +52,11 @@ func TestNewOtlpGrpcInsecureConnectionError(t *testing.T) { ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(1*time.Microsecond)) defer cancel() - _, err := trace.NewOtlpGrpcClientConnection(ctx, "https://example.com") + _, err := trace.NewOtlpGrpcClientConnection( + ctx, + "https://example.com", + grpc.WithBlock(), + ) assert.Error(t, err) assert.Contains(t, err.Error(), "context deadline exceeded") }