Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions horizon/cmd/mcp-server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"os"
"strings"

"github.com/Meesho/BharatMLStack/horizon/internal/configs"
mcpserver "github.com/Meesho/BharatMLStack/horizon/internal/mcp"
onlinefeaturestore "github.com/Meesho/BharatMLStack/horizon/internal/online-feature-store"
ofsConfig "github.com/Meesho/BharatMLStack/horizon/internal/online-feature-store/config"
ofsHandler "github.com/Meesho/BharatMLStack/horizon/internal/online-feature-store/handler"
"github.com/Meesho/BharatMLStack/horizon/pkg/etcd"
"github.com/Meesho/BharatMLStack/horizon/pkg/infra"
"github.com/Meesho/BharatMLStack/horizon/pkg/logger"
"github.com/mark3labs/mcp-go/server"
"github.com/rs/zerolog/log"
)

// AppConfig holds the application configuration for the MCP server.
type AppConfig struct {
Configs configs.Configs
DynamicConfigs configs.DynamicConfigs
}

func (cfg *AppConfig) GetStaticConfig() interface{} {
return &cfg.Configs
}

func (cfg *AppConfig) GetDynamicConfig() interface{} {
return &cfg.DynamicConfigs
}

func main() {
var appConfig AppConfig

configs.InitConfig(&appConfig)
logger.Init(appConfig.Configs)
infra.InitDBConnectors(appConfig.Configs)
etcd.InitFromAppName(&ofsConfig.FeatureRegistry{}, appConfig.Configs.OnlineFeatureStoreAppName, appConfig.Configs)
onlinefeaturestore.Init(appConfig.Configs)

configHandler := ofsHandler.NewConfigHandler(1)
if configHandler == nil {
log.Fatal().Msg("failed to initialize online-feature-store config handler")
}

mcpSrv := mcpserver.NewServer(configHandler)

addr := os.Getenv("MCP_ADDR")
if addr == "" {
addr = ":8080"
}

var httpOpts []server.StreamableHTTPOption

if strings.EqualFold(os.Getenv("MCP_TLS_ENABLED"), "true") {
certFile := os.Getenv("MCP_TLS_CERT_FILE")
keyFile := os.Getenv("MCP_TLS_KEY_FILE")
if certFile == "" || keyFile == "" {
log.Fatal().Msg("MCP_TLS_CERT_FILE and MCP_TLS_KEY_FILE must be set when MCP_TLS_ENABLED=true")
}
httpOpts = append(httpOpts, server.WithTLSCert(certFile, keyFile))
log.Info().Str("addr", addr).Msg("Starting Horizon MCP server with TLS (Streamable HTTP)")
} else {
log.Info().Str("addr", addr).Msg("Starting Horizon MCP server (Streamable HTTP)")
}

httpServer := server.NewStreamableHTTPServer(mcpSrv, httpOpts...)
if err := httpServer.Start(addr); err != nil {
log.Fatal().Err(err).Msg("MCP server error")
}
}
7 changes: 7 additions & 0 deletions horizon/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/google/go-github/v53 v53.2.0
github.com/google/uuid v1.6.0
github.com/maolinc/copier v0.0.0-20230308122822-96b2f568544f
github.com/mark3labs/mcp-go v0.44.0
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.34.0
github.com/spf13/viper v1.20.1
Expand Down Expand Up @@ -48,6 +49,8 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
Expand Down Expand Up @@ -83,11 +86,13 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
Expand All @@ -109,6 +114,8 @@ require (
github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.etcd.io/etcd/api/v3 v3.5.12 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.12 // indirect
Expand Down
15 changes: 15 additions & 0 deletions horizon/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
Expand All @@ -45,6 +47,8 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4Yn
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 h1:SmbUK/GxpAspRjSQbB6ARvH+ArzlNzTtHydNyXUQ6zg=
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0/go.mod h1:vuD/xvJT9Y+ZVZRv4HQ42cMyPFIYqpc7AbB4Gvt/DlY=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
Expand Down Expand Up @@ -151,10 +155,13 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
Expand All @@ -170,8 +177,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/maolinc/copier v0.0.0-20230308122822-96b2f568544f h1:sRTOY+RyQBvYIXUD64+jD+rrdJ3DmrKu/QD3s+CwQeI=
github.com/maolinc/copier v0.0.0-20230308122822-96b2f568544f/go.mod h1:kZ+zAWCoPv9+nPxdwQjK7cdfhlKZH2+q3PjvdpsXR7Q=
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
Expand Down Expand Up @@ -245,8 +256,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
Expand Down
Binary file modified horizon/horizon
Binary file not shown.
52 changes: 52 additions & 0 deletions horizon/internal/mcp/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package mcp

import (
"github.com/Meesho/BharatMLStack/horizon/internal/online-feature-store/handler"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)

// NewServer creates and configures a new MCP server with all
// Horizon online-feature-store discovery tools registered.
func NewServer(configHandler handler.Config) *server.MCPServer {
s := server.NewMCPServer(
"horizon-ofs-mcp",
"1.0.0",
server.WithToolCapabilities(false),
server.WithRecovery(),
)

tools := NewToolHandlers(configHandler)
registerTools(s, tools)
return s
}

func registerTools(s *server.MCPServer, t *ToolHandlers) {
listEntities := mcp.NewTool("list_entities",
mcp.WithDescription("List all registered entity labels in the online feature store"),
)
s.AddTool(listEntities, t.ListEntities)

getEntityDetails := mcp.NewTool("get_entity_details",
mcp.WithDescription("Get detailed configuration for all entities including keys and cache settings"),
)
s.AddTool(getEntityDetails, t.GetEntityDetails)

listFeatureGroups := mcp.NewTool("list_feature_groups",
mcp.WithDescription("List feature groups for a given entity with their labels and data types"),
mcp.WithString("entity_label",
mcp.Required(),
mcp.Description("The entity label to list feature groups for"),
),
)
s.AddTool(listFeatureGroups, t.ListFeatureGroups)

getFeatureGroupDetails := mcp.NewTool("get_feature_group_details",
mcp.WithDescription("Get detailed configuration for feature groups including features, versions, TTL, and cache settings"),
mcp.WithString("entity_label",
mcp.Required(),
mcp.Description("The entity label to retrieve feature group details for"),
),
)
s.AddTool(getFeatureGroupDetails, t.GetFeatureGroupDetails)
}
104 changes: 104 additions & 0 deletions horizon/internal/mcp/tools.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package mcp

import (
"context"
"encoding/json"
"fmt"

"github.com/Meesho/BharatMLStack/horizon/internal/online-feature-store/handler"
"github.com/mark3labs/mcp-go/mcp"
)

// ToolHandlers holds the handler.Config dependency and implements
// all MCP tool handler functions for Horizon discovery tools.
type ToolHandlers struct {
config handler.Config
}

// NewToolHandlers creates a new ToolHandlers with the given config handler.
func NewToolHandlers(config handler.Config) *ToolHandlers {
return &ToolHandlers{config: config}
}

// ListEntities returns all registered entity labels.
func (t *ToolHandlers) ListEntities(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
entities, err := t.config.GetAllEntities()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to list entities: %v", err)), nil
}

data, err := json.Marshal(entities)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal entities: %v", err)), nil
}
return mcp.NewToolResultText(string(data)), nil
}

// GetEntityDetails returns full configuration for all entities
// including keys, in-memory cache, and distributed cache settings.
func (t *ToolHandlers) GetEntityDetails(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
entities, err := t.config.RetrieveEntities()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to retrieve entity details: %v", err)), nil
}

data, err := json.Marshal(entities)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal entity details: %v", err)), nil
}
return mcp.NewToolResultText(string(data)), nil
}

// ListFeatureGroups returns feature groups for a given entity
// with their labels and data types.
func (t *ToolHandlers) ListFeatureGroups(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
entityLabel, err := request.RequireString("entity_label")
if err != nil {
return mcp.NewToolResultError("entity_label is required"), nil
}

featureGroups, err := t.config.RetrieveFeatureGroups(entityLabel)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to list feature groups: %v", err)), nil
}

type fgSummary struct {
Label string `json:"label"`
DataType string `json:"data-type"`
}
var summaries []fgSummary
if featureGroups != nil {
for _, fg := range *featureGroups {
summaries = append(summaries, fgSummary{
Label: fg.FeatureGroupLabel,
DataType: string(fg.DataType),
})
}
}
Comment on lines +69 to +77
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

json.Marshal(nil) produces "null", not "[]" β€” initialize the slice.

When featureGroups is nil or empty, summaries stays nil, and json.Marshal(nil) outputs null. MCP tool consumers likely expect an empty JSON array.

Proposed fix
-	var summaries []fgSummary
-	if featureGroups != nil {
+	summaries := make([]fgSummary, 0)
+	if featureGroups != nil {
πŸ€– Prompt for AI Agents
In `@horizon/internal/mcp/tools.go` around lines 69 - 77, The summaries slice is
left nil when featureGroups is nil/empty which causes json.Marshal(summaries) to
emit "null" instead of "[]"; initialize summaries as an empty slice (e.g.,
make([]fgSummary, 0) or []fgSummary{}) before appending so that the variable
used later (summaries) always marshals to an empty JSON array; update the block
around the summaries declaration and the loop that iterates over featureGroups
(referencing the summaries variable and fgSummary type) to ensure summaries is
non-nil even when no featureGroups are present.


data, err := json.Marshal(summaries)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal feature groups: %v", err)), nil
}
return mcp.NewToolResultText(string(data)), nil
}

// GetFeatureGroupDetails returns full feature group configuration
// including features, active version, TTL, and cache settings.
func (t *ToolHandlers) GetFeatureGroupDetails(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
entityLabel, err := request.RequireString("entity_label")
if err != nil {
return mcp.NewToolResultError("entity_label is required"), nil
}

featureGroups, err := t.config.RetrieveFeatureGroups(entityLabel)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to retrieve feature group details: %v", err)), nil
}

data, err := json.Marshal(featureGroups)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to marshal feature group details: %v", err)), nil
}
return mcp.NewToolResultText(string(data)), nil
}
Binary file added horizon/mcp-server
Binary file not shown.
Binary file added online-feature-store/api-server
Binary file not shown.
Loading
Loading