From 627a93f2ee50b831de99ccd000e5fd9ec6484738 Mon Sep 17 00:00:00 2001 From: Sai Nageswar S Date: Mon, 13 Apr 2026 12:58:02 +0200 Subject: [PATCH 1/2] Adding MCP support --- README.md | 79 ++++++++++++++++ go.mod | 21 +++-- go.sum | 46 +++++---- server/builder.go | 100 ++++++++++++++++++++ server/mcp_server.go | 11 +++ server/mcp_server_test.go | 193 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 424 insertions(+), 26 deletions(-) create mode 100644 server/mcp_server.go create mode 100644 server/mcp_server_test.go diff --git a/README.md b/README.md index 7a5b473..c8711cb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ * [Auth & JWT](#auth--jwt) * [Cloud Abstractions](#cloud-abstractions) * [Zero‑Config SSL/TLS](#zero-config-ssltls) + * [MCP Tools](#mcp-tools) * [Temporal Workers](#temporal-workers) 6. [CLI Reference](#cli-reference) 7. [Examples](#examples) @@ -67,6 +68,7 @@ The result: you write business logic, not boilerplate. * **JWT Auth & Middleware Stack** – observability, logging, panic recovery pre‑wired. * **Cloud Providers** – interchangeable Azure / GCP helpers for storage & secrets. * **Zero‑Config SSL** – automatic Let’s Encrypt certificates with exponential back‑off and optional cloud-backed cache (SslCloudCache) for stateless containers. +* **MCP Tool Server** – expose [Model Context Protocol](https://modelcontextprotocol.io) tools alongside gRPC/REST on the same HTTP port, with full DI support. * **Temporal Workflow Support** – run long-lived, fault-tolerant background jobs with native Temporal integration and DI-based worker registration. * **Fluent Dependency Injection** – chainable, lifecycle-aware registration for gRPC services, Temporal workflows/activities, SSL providers, cloud abstractions, and more, all via a single builder API. * **Bootstrap CLI** – scaffold full service, models, repos, services, Dockerfile, build scripts. @@ -407,6 +409,83 @@ boot, _ := server.New(). * Multiple replicas of your service instantly share the same certs – no race conditions, no volume mounts. * Exponential back-off is applied automatically while waiting for DNS / IP propagation. +### MCP Tools + +go-api-boot supports exposing [Model Context Protocol](https://modelcontextprotocol.io) (MCP) tools on the same HTTP port, served via the **Streamable HTTP** transport at `/mcp` (configurable). This lets AI agents and LLM clients discover and call your tools over a standard protocol. + +```go +import ( + "context" + "github.com/SaiNageswarS/go-api-boot/server" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type GreetInput struct { + Name string `json:"name" jsonschema:"the name to greet"` +} +type GreetOutput struct { + Greeting string `json:"greeting"` +} + +func SayHi(_ context.Context, _ *mcp.CallToolRequest, in GreetInput) ( + *mcp.CallToolResult, GreetOutput, error, +) { + return nil, GreetOutput{Greeting: "Hi " + in.Name}, nil +} + +func main() { + boot, _ := server.New(). + GRPCPort(":50051"). + HTTPPort(":8080"). + WithMCP( + &mcp.Implementation{Name: "my-service", Version: "v1.0.0"}, + nil, + ). + ConfigureMCP(func(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "greet", + Description: "say hello", + }, SayHi) + }). + Build() + + boot.Serve(context.Background()) +} +``` + +**With Dependency Injection** – implement the `MCPConfigurator` interface and register via `AddMCPConfigurator`: + +```go +type MyMCPTools struct { + repo *UserRepository +} + +func (m *MyMCPTools) ConfigureMCP(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{Name: "getUser", Description: "fetch user"}, m.getUser) +} + +// Factory for DI +func ProvideMyMCPTools(repo *UserRepository) *MyMCPTools { + return &MyMCPTools{repo: repo} +} + +// Register +boot, _ := server.New(). + GRPCPort(":50051").HTTPPort(":8080"). + Provide(userRepo). + WithMCP(&mcp.Implementation{Name: "my-service", Version: "v1.0.0"}, nil). + AddMCPConfigurator(ProvideMyMCPTools). + Build() +``` + +**Key features:** +* Tools, resources, and prompts via the official [go-sdk](https://github.com/modelcontextprotocol/go-sdk). +* Served on the same HTTP port – no extra listener. +* Full dependency injection for MCP configurators. +* Configurable path (`MCPPath("/custom")`) and HTTP options (`MCPHTTPOptions(...)`). + +--- + ### Temporal Workers go-api-boot provides first-class support for running **Temporal workers** alongside your gRPC/HTTP services using the same dependency injection system. You can: diff --git a/go.mod b/go.mod index 04ec75f..8cbdcbe 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/SaiNageswarS/go-api-boot -go 1.24.4 +go 1.25.0 require ( cloud.google.com/go/storage v1.43.0 @@ -13,6 +13,7 @@ require ( github.com/googleapis/gax-go/v2 v2.13.0 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/jinzhu/copier v0.3.2 + github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/nexus-rpc/sdk-go v0.3.0 github.com/ollama/ollama v0.9.3 github.com/prometheus/client_golang v1.18.0 @@ -41,10 +42,11 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/mock v1.6.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -55,8 +57,11 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/robfig/cron v1.2.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect @@ -64,7 +69,7 @@ require ( go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect go.temporal.io/api v1.46.0 // indirect - golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect @@ -91,10 +96,10 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect - golang.org/x/crypto v0.37.0 - golang.org/x/net v0.39.0 - golang.org/x/sync v0.13.0 - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/crypto v0.48.0 + golang.org/x/net v0.50.0 + golang.org/x/sync v0.19.0 + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.5 // indirect ) diff --git a/go.sum b/go.sum index 64b33e8..4a6e0da 100644 --- a/go.sum +++ b/go.sum @@ -75,8 +75,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= @@ -106,6 +106,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ 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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= @@ -145,6 +147,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= +github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/nexus-rpc/sdk-go v0.3.0 h1:Y3B0kLYbMhd4C2u00kcYajvmOrfozEtTV/nHSnV57jA= github.com/nexus-rpc/sdk-go v0.3.0/go.mod h1:TpfkM2Cw0Rlk9drGkoiSMpFqflKTiQLWUNyKJjF8mKQ= github.com/ollama/ollama v0.9.3 h1:LipCesw/hc2zbPLPmu5pnUp/L/P2FwQQ3JmceEEJbgc= @@ -175,6 +179,10 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE= github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= @@ -201,6 +209,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +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= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -239,8 +249,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 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= @@ -263,11 +273,11 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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= @@ -275,8 +285,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= @@ -290,18 +300,18 @@ golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -315,8 +325,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= -golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/server/builder.go b/server/builder.go index e584491..008cbd8 100644 --- a/server/builder.go +++ b/server/builder.go @@ -15,6 +15,7 @@ import ( grpc_auth "github.com/grpc-ecosystem/go-grpc-middleware/auth" grpc_zap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap" grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/cors" "go.temporal.io/sdk/client" @@ -48,6 +49,14 @@ type Builder struct { activityRegs []reflect.Value workflowRegs []interface{} temporalClientOpts *client.Options + + // MCP server + mcpImpl *mcp.Implementation + mcpOpts *mcp.ServerOptions + mcpPath string + mcpHTTPOpts *mcp.StreamableHTTPOptions + mcpConfigFns []func(*mcp.Server) + mcpConfiguratorFn []reflect.Value } type registration struct { @@ -146,6 +155,67 @@ func (b *Builder) RegisterTemporalActivity(factory any) *Builder { return b } +// ---- MCP server -------------------------------------------------------------- + +// WithMCP enables an MCP (Model Context Protocol) server on the HTTP port. +// The server is served via the Streamable HTTP transport at the configured path +// (default "/mcp"). Use AddMCPConfigurator or ConfigureMCP to add tools, +// resources and prompts to the server. +func (b *Builder) WithMCP(impl *mcp.Implementation, opts *mcp.ServerOptions) *Builder { + b.mcpImpl = impl + b.mcpOpts = opts + return b +} + +// MCPPath sets the HTTP path where the MCP server is mounted. +// Default is "/mcp". +func (b *Builder) MCPPath(path string) *Builder { + b.mcpPath = path + return b +} + +// MCPHTTPOptions sets the StreamableHTTPOptions for the MCP handler. +// If nil, sensible defaults are used. +func (b *Builder) MCPHTTPOptions(opts *mcp.StreamableHTTPOptions) *Builder { + b.mcpHTTPOpts = opts + return b +} + +// ConfigureMCP registers a function that is called with the MCP server +// during Build. Use this to add tools, resources, and prompts directly: +// +// builder.ConfigureMCP(func(s *mcp.Server) { +// mcp.AddTool(s, &mcp.Tool{Name: "greet"}, SayHi) +// }) +func (b *Builder) ConfigureMCP(fn func(*mcp.Server)) *Builder { + b.mcpConfigFns = append(b.mcpConfigFns, fn) + return b +} + +// AddMCPConfigurator registers a factory function whose return type implements +// MCPConfigurator. Dependencies are injected the same way as for gRPC services +// and REST controllers. Example: +// +// builder.AddMCPConfigurator(func(repo *UserRepo) *MyMCPTools { +// return &MyMCPTools{repo: repo} +// }) +func (b *Builder) AddMCPConfigurator(factory any) *Builder { + v := reflect.ValueOf(factory) + if v.Kind() != reflect.Func { + logger.Fatal("AddMCPConfigurator expects a factory function", zap.Any("received", factory)) + } + + outType := v.Type().Out(0) + mcpConfiguratorType := reflect.TypeOf((*MCPConfigurator)(nil)).Elem() + if !outType.Implements(mcpConfiguratorType) { + logger.Fatal("factory must return a type implementing MCPConfigurator", + zap.String("returnType", outType.String())) + } + + b.mcpConfiguratorFn = append(b.mcpConfiguratorFn, v) + return b +} + // ----- dependency injection -------------------------------------------------- func (b *Builder) Provide(value any) *Builder { @@ -266,6 +336,36 @@ func (b *Builder) Build() (*BootServer, error) { mux.Handle("/static/", http.StripPrefix("/static/", fileServer)) } + // Mount MCP server if configured + if b.mcpImpl != nil { + mcpSrv := mcp.NewServer(b.mcpImpl, b.mcpOpts) + + // Apply direct configuration functions + for _, fn := range b.mcpConfigFns { + fn(mcpSrv) + } + + // Apply DI-based configurators + for _, factory := range b.mcpConfiguratorFn { + cfgVal, cfgErr := invokeFactory(ctn, factory) + if cfgErr != nil { + return nil, fmt.Errorf("MCP configurator DI failed: %w", cfgErr) + } + cfgVal.Interface().(MCPConfigurator).ConfigureMCP(mcpSrv) + } + + mcpPath := b.mcpPath + if mcpPath == "" { + mcpPath = "/mcp" + } + mcpHandler := mcp.NewStreamableHTTPHandler( + func(_ *http.Request) *mcp.Server { return mcpSrv }, + b.mcpHTTPOpts, + ) + mux.Handle(mcpPath, mcpHandler) + logger.Info("Registered MCP server", zap.String("path", mcpPath)) + } + // HTTP server with optimized timeouts var readTimeout, writeTimeout, idleTimeout time.Duration readTimeout = 5 * time.Minute diff --git a/server/mcp_server.go b/server/mcp_server.go new file mode 100644 index 0000000..846192c --- /dev/null +++ b/server/mcp_server.go @@ -0,0 +1,11 @@ +package server + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +// MCPConfigurator is implemented by types that configure an MCP server +// with tools, resources, prompts, etc. It is the MCP equivalent of +// RestController – register a factory via Builder.AddMCPConfigurator +// and go-api-boot will inject dependencies and call ConfigureMCP during Build. +type MCPConfigurator interface { + ConfigureMCP(s *mcp.Server) +} diff --git a/server/mcp_server_test.go b/server/mcp_server_test.go new file mode 100644 index 0000000..df43eaa --- /dev/null +++ b/server/mcp_server_test.go @@ -0,0 +1,193 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── helpers ────────────────────────────────────────────────────────────────── + +type greetInput struct { + Name string `json:"name" jsonschema:"the name to greet"` +} + +type greetOutput struct { + Greeting string `json:"greeting"` +} + +func greetHandler(_ context.Context, _ *mcp.CallToolRequest, in greetInput) ( + *mcp.CallToolResult, greetOutput, error, +) { + return nil, greetOutput{Greeting: "Hello " + in.Name}, nil +} + +// mcpTestConfigurator implements MCPConfigurator with an injected dep. +type mcpTestConfigurator struct { + d *dep +} + +func (c *mcpTestConfigurator) ConfigureMCP(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "di_tool", + Description: "tool using DI dep", + }, func(_ context.Context, _ *mcp.CallToolRequest, in greetInput) ( + *mcp.CallToolResult, greetOutput, error, + ) { + return nil, greetOutput{Greeting: "dep=" + string(rune(c.d.id+'0'))}, nil + }) +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +func TestBuilder_WithMCP_RegistersHandler(t *testing.T) { + boot, err := New(). + GRPCPort(":0"). + HTTPPort(":0"). + WithMCP( + &mcp.Implementation{Name: "test-server", Version: "v0.1.0"}, + nil, + ). + ConfigureMCP(func(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "greet", + Description: "say hello", + }, greetHandler) + }). + Build() + + require.NoError(t, err) + defer boot.Shutdown(context.Background()) + + // The MCP handler is mounted on /mcp on the HTTP server. + // Send a POST with a JSON-RPC initialize request to verify it responds. + initReq := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"v1"}}}` + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(initReq)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + w := httptest.NewRecorder() + boot.http.Handler.ServeHTTP(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + // Should get a valid JSON-RPC response (200 or 202). + assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode, + "unexpected status: %d, body: %s", resp.StatusCode, string(body)) + + // Verify the response contains server info. + assert.Contains(t, string(body), "test-server") +} + +func TestBuilder_WithMCP_CustomPath(t *testing.T) { + boot, err := New(). + GRPCPort(":0"). + HTTPPort(":0"). + WithMCP( + &mcp.Implementation{Name: "custom-path", Version: "v1.0.0"}, + nil, + ). + MCPPath("/custom-mcp"). + Build() + + require.NoError(t, err) + defer boot.Shutdown(context.Background()) + + // Default /mcp should not have a handler. + req := httptest.NewRequest(http.MethodPost, "/custom-mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"v1"}}}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + w := httptest.NewRecorder() + boot.http.Handler.ServeHTTP(w, req) + + // Should get a valid response at the custom path. + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode, + "unexpected status: %d, body: %s", resp.StatusCode, string(body)) +} + +func TestBuilder_AddMCPConfigurator_DI(t *testing.T) { + d := &dep{id: 5} + + boot, err := New(). + GRPCPort(":0"). + HTTPPort(":0"). + Provide(d). + WithMCP( + &mcp.Implementation{Name: "di-server", Version: "v1.0.0"}, + nil, + ). + AddMCPConfigurator(func(dd *dep) *mcpTestConfigurator { + return &mcpTestConfigurator{d: dd} + }). + Build() + + require.NoError(t, err) + defer boot.Shutdown(context.Background()) + + // Initialize session first. + initReq := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"v1"}}}` + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(initReq)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + w := httptest.NewRecorder() + boot.http.Handler.ServeHTTP(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode, + "status: %d, body: %s", resp.StatusCode, string(body)) + + // Verify the response includes the di_tool in capabilities. + assert.Contains(t, string(body), "di-server") +} + +func TestBuilder_WithMCP_ToolListViaInMemory(t *testing.T) { + // Use the MCP SDK's in-memory transport to verify tools are registered. + mcpSrv := mcp.NewServer( + &mcp.Implementation{Name: "test", Version: "v1"}, + nil, + ) + mcp.AddTool(mcpSrv, &mcp.Tool{ + Name: "greet", + Description: "say hello", + }, greetHandler) + + st, ct := mcp.NewInMemoryTransports() + + _, err := mcpSrv.Connect(context.Background(), st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + session, err := client.Connect(context.Background(), ct, nil) + require.NoError(t, err) + defer session.Close() + + // List tools. + result, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, result.Tools, 1) + assert.Equal(t, "greet", result.Tools[0].Name) + + // Call the tool. + callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "greet", + Arguments: map[string]any{"name": "World"}, + }) + require.NoError(t, err) + require.False(t, callResult.IsError) + + // The structured output should contain our greeting. + structJSON, _ := json.Marshal(callResult.StructuredContent) + assert.Contains(t, string(structJSON), "Hello World") +} From bc418693c81edfbacf0cb301845afb1be3bb4d01 Mon Sep 17 00:00:00 2001 From: Sai Nageswar S Date: Mon, 13 Apr 2026 13:44:19 +0200 Subject: [PATCH 2/2] Adding tests --- server/mcp_server_test.go | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/server/mcp_server_test.go b/server/mcp_server_test.go index df43eaa..0595b27 100644 --- a/server/mcp_server_test.go +++ b/server/mcp_server_test.go @@ -191,3 +191,48 @@ func TestBuilder_WithMCP_ToolListViaInMemory(t *testing.T) { structJSON, _ := json.Marshal(callResult.StructuredContent) assert.Contains(t, string(structJSON), "Hello World") } + +func TestBuilder_MCPHTTPOptions_SetsOptions(t *testing.T) { + opts := &mcp.StreamableHTTPOptions{Stateless: true} + b := New().MCPHTTPOptions(opts) + assert.Equal(t, opts, b.mcpHTTPOpts) +} + +func TestBuilder_AddMCPConfigurator_NonFunction_CallsFatal(t *testing.T) { + mockLogger := withMockLogger(func() { + New().AddMCPConfigurator("not a function") + }) + + assert.True(t, mockLogger.isFatalCalled, "expected logger.Fatal to be called") + assert.Equal(t, "AddMCPConfigurator expects a factory function", mockLogger.fatalMsg) +} + +func TestBuilder_AddMCPConfigurator_NonConfigurator_CallsFatal(t *testing.T) { + mockLogger := withMockLogger(func() { + // Factory returns *dep which does not implement MCPConfigurator + New().AddMCPConfigurator(func() *dep { + return &dep{id: 1} + }) + }) + + assert.True(t, mockLogger.isFatalCalled, "expected logger.Fatal to be called") + assert.Equal(t, "factory must return a type implementing MCPConfigurator", mockLogger.fatalMsg) +} + +func TestBuilder_AddMCPConfigurator_DIFails_ReturnsError(t *testing.T) { + // Register a configurator that requires *dep which is NOT provided + _, err := New(). + GRPCPort(":0"). + HTTPPort(":0"). + WithMCP( + &mcp.Implementation{Name: "test", Version: "v1"}, + nil, + ). + AddMCPConfigurator(func(d *dep) *mcpTestConfigurator { + return &mcpTestConfigurator{d: d} + }). + Build() + + assert.Error(t, err, "expected Build() to fail when MCP configurator dependency is missing") + assert.Contains(t, err.Error(), "MCP configurator DI failed") +}