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
8 changes: 8 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ func startHTTPServer(cfg *config.Config, queryClient overlockv1beta1.QueryClient
environmentHandler := handler.NewEnvironmentHandler(queryClient, cfg.APITimeout)
mcp.AddTool(srv, environmentTool, environmentHandler.Handle)

// Register get-environments tool
environmentsTool := &mcp.Tool{
Name: "get-environments",
Description: "Get list of all environments in the Overlock Network with optional filtering and pagination",
InputSchema: schema.CreateEnvironmentsToolInputSchema(),
}
mcp.AddTool(srv, environmentsTool, environmentHandler.HandleList)

// Create the HTTP handler for MCP
httpHandler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
return srv
Expand Down
34 changes: 34 additions & 0 deletions internal/schema/get_environments_tool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package schema

import (
"github.com/modelcontextprotocol/go-sdk/jsonschema"
)

// CreateEnvironmentsToolInputSchema creates the JSON schema for the get-environments tool input
// This schema matches the QueryListEnvironmentRequest from the Overlock API
func CreateEnvironmentsToolInputSchema() *jsonschema.Schema {
zero := 0.0
thousand := 1000.0

return &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"creator": {
Type: "string",
Description: "Filter environments by creator address (optional)",
},
"limit": {
Type: "integer",
Description: "Maximum number of environments to return (default: 100, max: 1000)",
Minimum: &zero,
Maximum: &thousand,
},
"offset": {
Type: "integer",
Description: "Number of environments to skip for pagination (default: 0)",
Minimum: &zero,
},
},
AdditionalProperties: &jsonschema.Schema{},
}
}
47 changes: 47 additions & 0 deletions internal/schema/get_environments_tool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package schema

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCreateEnvironmentsToolInputSchema(t *testing.T) {
schema := CreateEnvironmentsToolInputSchema()

require.NotNil(t, schema)
assert.Equal(t, "object", schema.Type)

// Check required properties exist
assert.Contains(t, schema.Properties, "creator")
assert.Contains(t, schema.Properties, "limit")
assert.Contains(t, schema.Properties, "offset")

// Check creator property
creatorProp := schema.Properties["creator"]
assert.Equal(t, "string", creatorProp.Type)
assert.Equal(t, "Filter environments by creator address (optional)", creatorProp.Description)

// Check limit property
limitProp := schema.Properties["limit"]
assert.Equal(t, "integer", limitProp.Type)
assert.Equal(t, "Maximum number of environments to return (default: 100, max: 1000)", limitProp.Description)
assert.NotNil(t, limitProp.Minimum)
assert.Equal(t, 0.0, *limitProp.Minimum)
assert.NotNil(t, limitProp.Maximum)
assert.Equal(t, 1000.0, *limitProp.Maximum)

// Check offset property
offsetProp := schema.Properties["offset"]
assert.Equal(t, "integer", offsetProp.Type)
assert.Equal(t, "Number of environments to skip for pagination (default: 0)", offsetProp.Description)
assert.NotNil(t, offsetProp.Minimum)
assert.Equal(t, 0.0, *offsetProp.Minimum)

// Check no required fields (all optional)
assert.Empty(t, schema.Required)

// Check additional properties
assert.NotNil(t, schema.AdditionalProperties)
}
147 changes: 146 additions & 1 deletion pkg/handler/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/Oudwins/zog"
"github.com/cosmos/cosmos-sdk/types/query"
"github.com/modelcontextprotocol/go-sdk/mcp"
overlockv1beta1 "github.com/overlock-network/api/go/node/overlock/crossplane/v1beta1"
"github.com/rs/zerolog/log"
Expand All @@ -18,7 +19,14 @@ type EnvironmentInput struct {
Id int `json:"id,omitempty"`
}

// EnvironmentHandler handles the show-environment tool requests
// EnvironmentsListInput represents the input parameters for the get-environments tool
type EnvironmentsListInput struct {
Creator string `json:"creator,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}

// EnvironmentHandler handles both show-environment and get-environments tool requests
type EnvironmentHandler struct {
chainClient overlockv1beta1.QueryClient
timeout time.Duration
Expand Down Expand Up @@ -190,3 +198,140 @@ func (h *EnvironmentHandler) Handle(ctx context.Context, session *mcp.ServerSess
},
}, nil
}

// HandleList processes the get-environments tool call
func (h *EnvironmentHandler) HandleList(ctx context.Context, session *mcp.ServerSession, params *mcp.CallToolParams) (*mcp.CallToolResult, error) {
// Create a logger with request context
logger := log.With().
Str("tool", "get-environments").
Str("request_id", fmt.Sprintf("%p", params)).
Logger()

start := time.Now()
logger.Info().Msg("Processing get-environments request")

// Apply timeout to the context
timeoutCtx, cancel := context.WithTimeout(ctx, h.timeout)
defer cancel()

// Define validation schema using Zog with default values
schema := zog.Struct(zog.Shape{
"creator": zog.String().Default(""),
"limit": zog.Int().LTE(1000).Default(100),
"offset": zog.Int().Default(0),
})

// Validate input parameters (always parse to apply defaults)
var input EnvironmentsListInput
arguments := params.Arguments
if arguments == nil {
arguments = make(map[string]interface{})
}

logger.Debug().Interface("arguments", arguments).Msg("Validating input arguments")
// Parse and validate the arguments
errs := schema.Parse(arguments, &input)
if errs != nil {
logger.Error().Interface("errors", errs).Msg("Input validation failed")
return nil, fmt.Errorf("validation failed: %v", errs)
}
logger.Debug().Interface("parsed_input", input).Msg("Input validation successful")

// Set default pagination
req := &overlockv1beta1.QueryListEnvironmentRequest{
Pagination: &query.PageRequest{
Limit: 100,
Offset: 0,
},
}

// Apply validated parameters
if input.Creator != "" {
req.Creator = input.Creator
}
if input.Limit > 0 {
req.Pagination.Limit = uint64(input.Limit)
}
req.Pagination.Offset = uint64(input.Offset)

// Log request parameters
logger.Info().
Uint64("limit", req.Pagination.Limit).
Uint64("offset", req.Pagination.Offset).
Str("creator", req.Creator).
Msg("Fetching environments from blockchain")

// Check if chain client is available
if h.chainClient == nil {
logger.Error().Msg("gRPC client is not available")
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: "Error: gRPC connection to blockchain is not available. Please check the connection and try again.",
},
},
}, nil
}

// Fetch environments from the chain using circuit breaker protection
result, err := h.circuitBreaker.Execute(func() (interface{}, error) {
return h.chainClient.ListEnvironment(timeoutCtx, req)
})

if err != nil {
logger.Info().Err(err).Msg("Failed to connect to gRPC server - blockchain service unavailable")
// Check if it's a circuit breaker error
if err == gobreaker.ErrOpenState {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: "Blockchain service is currently unavailable (circuit breaker protection active). Please try again later.",
},
},
}, nil
}
// Return a user-friendly response instead of propagating the error
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: "Unable to connect to blockchain service. The service may be temporarily unavailable. Please check your connection and try again later.",
},
},
}, nil
}

chainResponse, ok := result.(*overlockv1beta1.QueryListEnvironmentResponse)
if !ok || chainResponse == nil {
logger.Error().Msg("Received invalid response from blockchain service")
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: "Received invalid response from blockchain service. Please try again later.",
},
},
}, nil
}

// Log successful response
environmentCount := len(chainResponse.Environments)
duration := time.Since(start)
logger.Info().
Int("environment_count", environmentCount).
Dur("duration", duration).
Msg("Successfully fetched environments")

// Use the official API response directly
responseJSON, err := json.MarshalIndent(chainResponse, "", " ")
if err != nil {
logger.Error().Err(err).Msg("Failed to marshal response")
return nil, fmt.Errorf("failed to marshal environments response: %w", err)
}

return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: string(responseJSON),
},
},
}, nil
}
Loading