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
+
+[](https://github.com/ankorstore/yokai/actions/workflows/fxclock-ci.yml)
+[](https://goreportcard.com/report/github.com/ankorstore/yokai/fxclock)
+[](https://app.codecov.io/gh/ankorstore/yokai/tree/main/fxclock)
+[](https://deps.dev/go/github.com%2Fankorstore%2Fyokai%2Ffxclock)
+[](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


-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:
+
+
+
+
+### 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
+
+[](https://github.com/ankorstore/yokai/actions/workflows/fxmcpserver-ci.yml)
+[](https://goreportcard.com/report/github.com/ankorstore/yokai/fxmcpserver)
+[](https://app.codecov.io/gh/ankorstore/yokai/tree/main/fxmcpserver)
+[](https://deps.dev/go/github.com%2Fankorstore%2Fyokai%2Ffxmcpserver)
+[](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
+
+[](https://github.com/ankorstore/yokai/actions/workflows/fxclock-ci.yml)
+[](https://goreportcard.com/report/github.com/ankorstore/yokai/fxclock)
+[](https://app.codecov.io/gh/ankorstore/yokai/tree/main/fxclock)
+[](https://deps.dev/go/github.com%2Fankorstore%2Fyokai%2Ffxclock)
+[](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 @@
{{ end }}
+ {{ if .tasksExpose }}
+
+
+ {{ end }}
{{ if .modulesExpose }}
@@ -125,8 +144,8 @@
-
-
+
{{ if or .overviewAppEnvExpose .overviewAppDebugExpose .overviewAppVersionExpose }}
-
-
-
- {{ .overviewInfo.AppName }}
-
-
-
- {{ if and .overviewAppDescriptionExpose .overviewInfo.AppDescription }}
-
- Description |
- {{ .overviewInfo.AppDescription }} |
-
- {{ end }}
- {{ if .overviewAppEnvExpose }}
-
- Env |
- {{ .overviewInfo.AppEnv }} |
-
- {{ end }}
- {{ if .overviewAppDebugExpose }}
-
- Debug |
- {{ .overviewInfo.AppDebug }} |
-
- {{ end }}
- {{ if .overviewAppVersionExpose }}
-
- Version |
- {{ .overviewInfo.AppVersion }} |
-
- {{ end }}
-
-
-
+
+
+
+ {{ .overviewInfo.AppName }}
+
+
+
+ {{ if and .overviewAppDescriptionExpose .overviewInfo.AppDescription }}
+
+ Description |
+ {{ .overviewInfo.AppDescription }} |
+
+ {{ end }}
+ {{ if .overviewAppEnvExpose }}
+
+ Env |
+ {{ .overviewInfo.AppEnv }} |
+
+ {{ end }}
+ {{ if .overviewAppDebugExpose }}
+
+ Debug |
+ {{ .overviewInfo.AppDebug }} |
+
+ {{ end }}
+ {{ if .overviewAppVersionExpose }}
+
+ Version |
+ {{ .overviewInfo.AppVersion }} |
+
+ {{ end }}
+
+
+
{{ end }}
{{ if or .overviewLogLevelExpose .overviewLogOutputExpose }}
-
-
-
-
- Logs
-
-
-
- {{ if or .overviewLogLevelExpose }}
-
- Level |
- {{ .overviewInfo.LogLevel }} |
-
- {{ end }}
- {{ if or .overviewLogOutputExpose }}
-
- Output |
- {{ .overviewInfo.LogOutput }} |
-
- {{ end }}
-
-
-
+
+
+
+
+ Logs
+
+
+
+ {{ if or .overviewLogLevelExpose }}
+
+ Level |
+ {{ .overviewInfo.LogLevel }} |
+
+ {{ end }}
+ {{ if or .overviewLogOutputExpose }}
+
+ Output |
+ {{ .overviewInfo.LogOutput }} |
+
+ {{ end }}
+
+
+
{{ end }}
{{ if or .overviewTraceSamplerExpose .overviewTraceProcessorExpose }}
-
-
-
-
- Traces
-
-
-
- {{ if or .overviewTraceSamplerExpose }}
-
- Sampler |
- {{ .overviewInfo.TraceSampler }} |
-
- {{ end }}
- {{ if or .overviewTraceProcessorExpose }}
-
- Processor |
- {{ .overviewInfo.TraceProcessor }} |
-
- {{ end }}
-
-
-
+
+
+
+
+ Traces
+
+
+
+ {{ if or .overviewTraceSamplerExpose }}
+
+ Sampler |
+ {{ .overviewInfo.TraceSampler }} |
+
+ {{ end }}
+ {{ if or .overviewTraceProcessorExpose }}
+
+ Processor |
+ {{ .overviewInfo.TraceProcessor }} |
+
+ {{ end }}
+
+
+
{{ end }}
{{ if ne (len .overviewInfo.ExtraInfos ) 0 }}
-
-
-
-
- Extra information
-
-
-
- {{ range $infoName, $infoValue := .overviewInfo.ExtraInfos }}
-
- {{ $infoName }} |
- {{ $infoValue }} |
-
- {{ end }}
-
-
-
+
+
+
+
+ Extra information
+
+
+
+ {{ range $infoName, $infoValue := .overviewInfo.ExtraInfos }}
+
+ {{ $infoName }} |
+ {{ $infoValue }} |
+
+ {{ end }}
+
+
+
{{ end }}
+
+
+
+
+
+
+
{% taskResultMessage %}
+
+
+
+
+
+
+
{% 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 '
' + this.error + '
'
+ return '
' + this.error + '
';
} 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
+
+[](https://github.com/ankorstore/yokai/actions/workflows/fxmcpserver-ci.yml)
+[](https://goreportcard.com/report/github.com/ankorstore/yokai/fxmcpserver)
+[](https://app.codecov.io/gh/ankorstore/yokai/tree/main/fxmcpserver)
+[](https://deps.dev/go/github.com%2Fankorstore%2Fyokai%2Ffxmcpserver)
+[](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")
}