From e3be406ebafcb7726e4d3d57c42b71628da6c8cb Mon Sep 17 00:00:00 2001 From: Ryan D McGuire Date: Wed, 27 Aug 2025 17:02:53 -0400 Subject: [PATCH] Refactors DemoMCP tool handler to integrate with DemoGRPC for fact retrieval and updates server instructions. --- go.mod | 4 +++ go.sum | 10 ++++++ pkg/demo/demo.go | 4 +++ pkg/demo/demomcp/server.go | 65 ++++++++++++++++++++++++++++++++++++++ pkg/demo/demomcp/tool.go | 58 ++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 pkg/demo/demomcp/server.go create mode 100644 pkg/demo/demomcp/tool.go diff --git a/go.mod b/go.mod index 571a1f1..f7aabbf 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,13 @@ require ( gitea.libretechconsulting.com/rmcguire/go-app v0.11.0 github.com/go-resty/resty/v2 v2.16.5 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 + github.com/modelcontextprotocol/go-sdk v0.3.0 github.com/rs/zerolog v1.34.0 go.opentelemetry.io/otel/trace v1.37.0 google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a google.golang.org/grpc v1.74.2 google.golang.org/protobuf v1.36.7 + k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d ) require ( @@ -47,6 +49,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/cel-go v0.26.0 // indirect + github.com/google/jsonschema-go v0.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -58,6 +61,7 @@ require ( github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect diff --git a/go.sum b/go.sum index 54368fa..467fb14 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= 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.2.0 h1:Uh19091iHC56//WOsAd1oRg6yy1P9BpSvpjOL6RcjLQ= +github.com/google/jsonschema-go v0.2.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 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/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= @@ -65,6 +67,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ 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/modelcontextprotocol/go-sdk v0.3.0 h1:/1XC6+PpdKfE4CuFJz8/goo0An31bu8n8G8d3BkeJoY= +github.com/modelcontextprotocol/go-sdk v0.3.0/go.mod h1:71VUZVa8LL6WARvSgLJ7DMpDWSeomT4uBv8g97mGBvo= 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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -103,6 +107,8 @@ github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4Ho github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g= github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= +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/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= @@ -152,6 +158,8 @@ golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= @@ -168,3 +176,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV 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= +k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= +k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/pkg/demo/demo.go b/pkg/demo/demo.go index e745e49..bf41970 100644 --- a/pkg/demo/demo.go +++ b/pkg/demo/demo.go @@ -12,6 +12,7 @@ import ( "gitea.libretechconsulting.com/rmcguire/go-server-with-otel/pkg/config" "gitea.libretechconsulting.com/rmcguire/go-server-with-otel/pkg/demo/demogrpc" "gitea.libretechconsulting.com/rmcguire/go-server-with-otel/pkg/demo/demohttp" + "gitea.libretechconsulting.com/rmcguire/go-server-with-otel/pkg/demo/demomcp" "gitea.libretechconsulting.com/rmcguire/go-server-with-otel/pkg/service" ) @@ -20,6 +21,7 @@ type DemoService struct { config *config.ServiceConfig http *demohttp.DemoHTTPServer grpc *demogrpc.DemoGRPCServer + mcp *demomcp.DemoMCPServer } func (d *DemoService) Init(ctx context.Context, config *config.ServiceConfig, @@ -31,6 +33,7 @@ func (d *DemoService) Init(ctx context.Context, config *config.ServiceConfig, // This is, after all, a demo app. Just implement the interface. d.http = demohttp.NewDemoHTTPServer(ctx, config) d.grpc = demogrpc.NewDemoGRPCServer(ctx, config) + d.mcp = demomcp.NewDemoMCPServer(ctx, config) // TODO: This should actually do shutdown stuff return func(_ context.Context) (string, error) { @@ -49,6 +52,7 @@ func (d *DemoService) GetHTTP() *optshttp.AppHTTP { return &optshttp.AppHTTP{ Ctx: d.ctx, Funcs: d.http.GetHandleFuncs(), + Handlers: d.mcp.GetHandlers(), HealthChecks: d.http.GetHealthCheckFuncs(), } } diff --git a/pkg/demo/demomcp/server.go b/pkg/demo/demomcp/server.go new file mode 100644 index 0000000..e9cc328 --- /dev/null +++ b/pkg/demo/demomcp/server.go @@ -0,0 +1,65 @@ +/* +Package demomcp contains a simple reference implementation of returning a +HTTPHandler for an MCP server with a single tool used to retrieve random facts +*/ +package demomcp + +import ( + "context" + "net/http" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "gitea.libretechconsulting.com/rmcguire/go-server-with-otel/pkg/config" + "gitea.libretechconsulting.com/rmcguire/go-server-with-otel/pkg/demo/demogrpc" +) + +var DemoMCPImpl = &mcp.Implementation{ + Name: "Demo MCP Server", + Title: "Demo MCP", +} + +type DemoMCPServer struct { + ctx context.Context + cfg *config.ServiceConfig + server *mcp.Server + demoGRPC *demogrpc.DemoGRPCServer +} + +func NewDemoMCPServer(ctx context.Context, cfg *config.ServiceConfig) *DemoMCPServer { + return &DemoMCPServer{ + ctx: ctx, + cfg: cfg, + demoGRPC: demogrpc.NewDemoGRPCServer(ctx, cfg), + server: mcp.NewServer(DemoMCPImpl, &mcp.ServerOptions{ + Instructions: "Call this demo MCP tool if the user asks useless questions or wants a random fact", + HasTools: true, + }), + } +} + +func (d *DemoMCPServer) GetHandlers() []opts.HTTPHandler { + // NOTE: Add other tools here + d.addDemoRandomFactTool() + + demoHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return d.server + }, &mcp.StreamableHTTPOptions{}) + + return []opts.HTTPHandler{ + { + Prefix: "/api/mcp", + Handler: demoHandler, + }, + } +} + +func (d *DemoMCPServer) GetHealthCheckFuncs() []opts.HealthCheckFunc { + return []opts.HealthCheckFunc{ + func(ctx context.Context) error { + // TODO: Implement real health checks + return nil + }, + } +} diff --git a/pkg/demo/demomcp/tool.go b/pkg/demo/demomcp/tool.go new file mode 100644 index 0000000..086da84 --- /dev/null +++ b/pkg/demo/demomcp/tool.go @@ -0,0 +1,58 @@ +package demomcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "k8s.io/utils/ptr" + + demo "gitea.libretechconsulting.com/rmcguire/go-server-with-otel/api/demo/app/v1alpha1" +) + +var DemoTool = &mcp.Tool{ + Name: "Demo Tool", + Title: "Demo Random Fact Tool", + Description: "Returns a random fact for demo or boredom purposes", + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: ptr.To(true), + }, +} + +type DemoToolParams struct { + Language string `json:"language,omitempty" jsonschema:"Random Fact Language Abbreviation (e.g. en)"` +} + +func (d *DemoMCPServer) addDemoRandomFactTool() { + d.server.AddTool( + mcp.ToolFor(DemoTool, d.demoToolHandler), + ) +} + +func (d *DemoMCPServer) demoToolHandler(ctx context.Context, req *mcp.CallToolRequest, args *DemoToolParams) ( + *mcp.CallToolResult, any, error, +) { + fact, err := d.demoGRPC.GetDemo(ctx, &demo.GetDemoRequest{ + Language: &args.Language, + }) + if err != nil { + return &mcp.CallToolResult{ + IsError: true, + Meta: mcp.Meta{ + "error": err, + }, + }, nil, err + } + + return &mcp.CallToolResult{ + Meta: mcp.Meta{ + "language": fact.GetLanguage(), + "source": fact.GetSource(), + }, + Content: []mcp.Content{ + &mcp.TextContent{ + Text: fact.GetFact(), + }, + }, + }, nil, nil +}