Skip to content
Merged
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
42 changes: 25 additions & 17 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,16 @@ func loadContentOrStdin(val string, stdin io.Reader) (string, error) {
return val, nil
}

func loadEnvAndOpts(envFlag, fdsFlag string, stdin io.Reader) (*tools.Config, []cel.EnvOption, error) {
// loadEnvAndOpts resolves the environment configuration and CEL options from the
// command flags. When requireEnv is false, a missing environment yields a nil
// config, which results in a standard CEL environment suitable for standalone
// expressions.
func loadEnvAndOpts(envFlag, fdsFlag string, stdin io.Reader, requireEnv bool) (*tools.Config, []cel.EnvOption, error) {
envContent, err := loadContentOrStdin(envFlag, stdin)
if err != nil {
return nil, nil, err
}
if envContent == "" {
if envContent == "" && requireEnv {
return nil, nil, errors.New("environment configuration is required (use -env <path|json>)")
}

Expand Down Expand Up @@ -139,7 +143,7 @@ func runCompile(args []string, stdout, stderr io.Writer, stdin io.Reader) error
exprFlag := fs.String("expr", "", "CEL expression string")

fs.Usage = func() {
fmt.Fprintln(stderr, "Usage: cel-expr compile -env <path|json> [-expr <expr>] [expression]")
fmt.Fprintln(stderr, "Usage: cel-expr compile [-env <path|json>] [-expr <expr>] [expression]")
fmt.Fprintln(stderr, "\nFlags:")
fs.PrintDefaults()
}
Expand All @@ -159,7 +163,7 @@ func runCompile(args []string, stdout, stderr io.Writer, stdin io.Reader) error
return errors.New("expression is required (use -expr <expr> or pass as argument)")
}

cfg, opts, err := loadEnvAndOpts(*envFlag, *fdsFlag, stdin)
cfg, opts, err := loadEnvAndOpts(*envFlag, *fdsFlag, stdin, false)
if err != nil {
return err
}
Expand Down Expand Up @@ -189,7 +193,7 @@ func runEval(args []string, stdout, stderr io.Writer, stdin io.Reader) error {
expectedFlag := fs.String("expected", "", "Expected output value for single test case binding (JSON literal or string)")

fs.Usage = func() {
fmt.Fprintln(stderr, "Usage: cel-expr eval -env <path|json> [-tests <path|json>] [-bindings <path|json>] [expression]")
fmt.Fprintln(stderr, "Usage: cel-expr eval [-env <path|json>] [-tests <path|json>] [-bindings <path|json>] [expression]")
fmt.Fprintln(stderr, "\nFlags:")
fs.PrintDefaults()
}
Expand Down Expand Up @@ -219,14 +223,18 @@ func runEval(args []string, stdout, stderr io.Writer, stdin io.Reader) error {
if err := json.Unmarshal([]byte(rawTests), &testCases); err != nil {
return fmt.Errorf("failed parsing test cases JSON: %w", err)
}
} else if *bindingsFlag != "" {
rawBindings, err := loadFileOrRawJSON(*bindingsFlag)
if err != nil {
return fmt.Errorf("loading bindings: %w", err)
}
var bindings map[string]any
if err := json.Unmarshal([]byte(rawBindings), &bindings); err != nil {
return fmt.Errorf("failed parsing bindings JSON: %w", err)
} else if *bindingsFlag != "" || *expectedFlag != "" {
// Bindings are optional here: a standalone expression may only declare
// an expected result.
bindings := map[string]any{}
if *bindingsFlag != "" {
rawBindings, err := loadFileOrRawJSON(*bindingsFlag)
if err != nil {
return fmt.Errorf("loading bindings: %w", err)
}
if err := json.Unmarshal([]byte(rawBindings), &bindings); err != nil {
return fmt.Errorf("failed parsing bindings JSON: %w", err)
}
}

var expected any
Expand All @@ -250,7 +258,7 @@ func runEval(args []string, stdout, stderr io.Writer, stdin io.Reader) error {
})
}

cfg, opts, err := loadEnvAndOpts(*envFlag, *fdsFlag, stdin)
cfg, opts, err := loadEnvAndOpts(*envFlag, *fdsFlag, stdin, false)
if err != nil {
return err
}
Expand Down Expand Up @@ -303,7 +311,7 @@ func runEnv(args []string, stdout, stderr io.Writer, stdin io.Reader) error {
return err
}

cfg, opts, err := loadEnvAndOpts(*envFlag, *fdsFlag, stdin)
cfg, opts, err := loadEnvAndOpts(*envFlag, *fdsFlag, stdin, true)
if err != nil {
return err
}
Expand Down Expand Up @@ -339,7 +347,7 @@ func runPrompt(args []string, stdout, stderr io.Writer, stdin io.Reader) error {
fs.StringVar(promptFlag, "user_prompt", "", "Alias for -prompt")

fs.Usage = func() {
fmt.Fprintln(stderr, "Usage: cel-expr prompt -env <path|json> [-prompt <requirement>] [requirement text]")
fmt.Fprintln(stderr, "Usage: cel-expr prompt [-env <path|json>] [-prompt <requirement>] [requirement text]")
fmt.Fprintln(stderr, "\nFlags:")
fs.PrintDefaults()
}
Expand All @@ -359,7 +367,7 @@ func runPrompt(args []string, stdout, stderr io.Writer, stdin io.Reader) error {
return errors.New("prompt requirement is required (use -prompt <text> or pass as arguments)")
}

cfg, opts, err := loadEnvAndOpts(*envFlag, *fdsFlag, stdin)
cfg, opts, err := loadEnvAndOpts(*envFlag, *fdsFlag, stdin, false)
if err != nil {
return err
}
Expand Down
15 changes: 13 additions & 2 deletions cmd/cli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ func TestCLI_Compile(t *testing.T) {
wantErr: true,
},
{
name: "compile missing env",
name: "compile standalone expression without env",
args: []string{"compile", "-expr", "1 + 1"},
wantErr: true,
wantErr: false,
},
{
name: "compile invalid expression syntax",
Expand Down Expand Up @@ -183,6 +183,17 @@ func TestCLI_Eval_DefaultEmptyBindings(t *testing.T) {
}
}

func TestCLI_Eval_StandaloneWithoutEnv(t *testing.T) {
var stdout, stderr bytes.Buffer
err := run([]string{"eval", "-expected", "true", "'hello'.size() == 5"}, &stdout, &stderr, nil)
if err != nil {
t.Fatalf("run(eval standalone) failed: %v (stderr: %s)", err, stderr.String())
}
if !strings.Contains(stdout.String(), `"status": "pass"`) {
t.Errorf("expected pass status, got: %s", stdout.String())
}
}

func TestCLI_Eval_Errors(t *testing.T) {
envJSON := `{"variables":[{"name":"x","type":"int"}]}`

Expand Down
47 changes: 21 additions & 26 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,19 @@ type CreateEnvConfigArgs struct {

// GeneratePromptArgs is the arguments for the cel_generate_prompt tool.
type GeneratePromptArgs struct {
EnvConfig *tools.Config `json:"envConfig" jsonschema_description:"The JSON string representing the CEL environment schema."`
EnvConfig *tools.Config `json:"envConfig,omitempty" jsonschema_description:"The JSON string representing the CEL environment schema. May be omitted for standalone expressions which only use the CEL standard library."`
UserPrompt string `json:"userPrompt" jsonschema_description:"The user prompt to generate the CEL expression for."`
}

// CompileArgs is the arguments for the cel_compile tool.
type CompileArgs struct {
EnvConfig *tools.Config `json:"envConfig" jsonschema_description:"The JSON string representing the CEL environment schema."`
EnvConfig *tools.Config `json:"envConfig,omitempty" jsonschema_description:"The JSON string representing the CEL environment schema. May be omitted for standalone expressions which only use the CEL standard library."`
Expr string `json:"expr" jsonschema_description:"The CEL expression to compile."`
}

// EvaluateArgs is the arguments for the cel_evaluate tool.
type EvaluateArgs struct {
EnvConfig *tools.Config `json:"envConfig" jsonschema_description:"The JSON string representing the CEL environment schema."`
EnvConfig *tools.Config `json:"envConfig,omitempty" jsonschema_description:"The JSON string representing the CEL environment schema. May be omitted for standalone expressions which only use the CEL standard library."`
Expr string `json:"expr" jsonschema_description:"The CEL expression to evaluate."`
TestCases []tools.TestCase `json:"testCases" jsonschema_description:"The test cases for evaluation."`
}
Expand Down Expand Up @@ -187,45 +187,40 @@ func (h *toolsHandler) handleCreateEnvConfig(ctx context.Context, request *mcp.C
}, nil, nil
}

func (h *toolsHandler) handleCompile(ctx context.Context, request *mcp.CallToolRequest, args CompileArgs) (*mcp.CallToolResult, any, error) {
envConfig := args.EnvConfig
if envConfig == nil {
envConfig = h.fixedEnv
// resolveEnvConfig returns the environment configuration to use for a request.
//
// Request-supplied configurations take precedence over the server's fixed
// environment. When neither is present an empty configuration is returned so
// that standalone expressions relying only on the CEL standard library may
// still be compiled and evaluated.
func (h *toolsHandler) resolveEnvConfig(envConfig *tools.Config) *tools.Config {
if envConfig != nil {
return envConfig
}
if envConfig == nil {
return nil, nil, fmt.Errorf("environment configuration is required")
if h.fixedEnv != nil {
return h.fixedEnv
}
res, err := tools.CompileCEL(args.Expr, envConfig, h.opts...)
return &tools.Config{}
}

func (h *toolsHandler) handleCompile(ctx context.Context, request *mcp.CallToolRequest, args CompileArgs) (*mcp.CallToolResult, any, error) {
res, err := tools.CompileCEL(args.Expr, h.resolveEnvConfig(args.EnvConfig), h.opts...)
if err != nil {
return nil, nil, err
}
return nil, res, nil
}

func (h *toolsHandler) handleEvaluate(ctx context.Context, request *mcp.CallToolRequest, args EvaluateArgs) (*mcp.CallToolResult, any, error) {
envConfig := args.EnvConfig
if envConfig == nil {
envConfig = h.fixedEnv
}
if envConfig == nil {
return nil, nil, fmt.Errorf("environment configuration is required")
}
res, err := tools.EvaluateCEL(args.Expr, envConfig, args.TestCases, h.opts...)
res, err := tools.EvaluateCEL(args.Expr, h.resolveEnvConfig(args.EnvConfig), args.TestCases, h.opts...)
if err != nil {
return nil, nil, err
}
return nil, res, nil
}

func (h *toolsHandler) handleGeneratePrompt(ctx context.Context, request *mcp.CallToolRequest, args GeneratePromptArgs) (*mcp.CallToolResult, any, error) {
envConfig := args.EnvConfig
if envConfig == nil {
envConfig = h.fixedEnv
}
if envConfig == nil {
return nil, nil, fmt.Errorf("environment configuration is required")
}
res, err := tools.GeneratePrompt(envConfig, args.UserPrompt, h.opts...)
res, err := tools.GeneratePrompt(h.resolveEnvConfig(args.EnvConfig), args.UserPrompt, h.opts...)
if err != nil {
return nil, nil, err
}
Expand Down
72 changes: 72 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,78 @@ func TestHandleEvaluate(t *testing.T) {
}
}

func TestStandaloneExpressionEvaluation(t *testing.T) {
ctx := context.Background()
s := NewServer(nil)

t1, t2 := mcp.NewInMemoryTransports()
if _, err := s.Connect(ctx, t1, nil); err != nil {
t.Fatalf("Connect failed: %v", err)
}

client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
session, err := client.Connect(ctx, t2, nil)
if err != nil {
t.Fatalf("client.Connect failed: %v", err)
}
defer session.Close()

// Neither an envConfig nor test case bindings are supplied since the
// expression is standalone.
evalRes, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "cel_evaluate",
Arguments: map[string]any{
"expr": "'hello'.size() == 5",
"testCases": []any{
map[string]any{
"testCase": "standalone",
"expected": true,
},
},
},
})
if err != nil {
t.Fatalf("CallTool cel_evaluate failed: %v", err)
}
if evalRes.IsError {
t.Fatalf("cel_evaluate failed: %v", evalRes.Content)
}

compileRes, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "cel_compile",
Arguments: map[string]any{"expr": "1 + 1"},
})
if err != nil {
t.Fatalf("CallTool cel_compile failed: %v", err)
}
if compileRes.IsError {
t.Fatalf("cel_compile failed: %v", compileRes.Content)
}
}

func TestHandleEvaluateStandalone(t *testing.T) {
ctx := context.Background()

h := &toolsHandler{}
_, out, err := h.handleEvaluate(ctx, &mcp.CallToolRequest{}, EvaluateArgs{
Expr: "1 + 1",
TestCases: []tools.TestCase{
{TestCase: "standalone", Expected: 2.0},
},
})
if err != nil {
t.Fatalf("handleEvaluate failed: %v", err)
}

tr := out.(*tools.EvaluateExprOutput)
if len(tr.TestResults) != 1 {
t.Fatalf("expected 1 evaluation result, got %d", len(tr.TestResults))
}
if tr.TestResults[0].Status != "pass" {
t.Errorf("expected 'pass', got '%s'", tr.TestResults[0].Status)
}
}

func TestHandleGeneratePrompt(t *testing.T) {
ctx := context.Background()

Expand Down
7 changes: 7 additions & 0 deletions internal/tools/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,14 @@ func ConfigFromJSON(configJSON string) (*Config, error) {
}

// EnvFromConfig takes a Config and converts it to a cel-go Env.
//
// A nil config is treated as an empty configuration, which yields a standard
// CEL environment without additional variables, functions, or types. This
// supports standalone expressions which only rely on the CEL standard library.
func EnvFromConfig(envConfig *Config, opts ...cel.EnvOption) (*cel.Env, error) {
if envConfig == nil {
envConfig = &Config{}
}
celConfig, err := envConfig.ToCELConfig()
if err != nil {
return nil, err
Expand Down
Loading
Loading