diff --git a/cmd/invoke_tool_test.go b/cmd/invoke_tool_test.go new file mode 100644 index 0000000000..4fa47817ef --- /dev/null +++ b/cmd/invoke_tool_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInvokeTool(t *testing.T) { + // Create a temporary tools file + tmpDir := t.TempDir() + + toolsFileContent := ` +sources: + my-sqlite: + kind: sqlite + database: test.db +tools: + hello-sqlite: + kind: sqlite-sql + source: my-sqlite + description: "hello tool" + statement: "SELECT 'hello' as greeting" + echo-tool: + kind: sqlite-sql + source: my-sqlite + description: "echo tool" + statement: "SELECT ? as msg" + parameters: + - name: message + type: string + description: message to echo +` + + toolsFilePath := filepath.Join(tmpDir, "tools.yaml") + if err := os.WriteFile(toolsFilePath, []byte(toolsFileContent), 0644); err != nil { + t.Fatalf("failed to write tools file: %v", err) + } + + tcs := []struct { + desc string + args []string + want string + wantErr bool + errStr string + }{ + { + desc: "success - basic tool call", + args: []string{"invoke", "hello-sqlite", "--tools-file", toolsFilePath}, + want: `"greeting": "hello"`, + }, + { + desc: "success - tool call with parameters", + args: []string{"invoke", "echo-tool", `{"message": "world"}`, "--tools-file", toolsFilePath}, + want: `"msg": "world"`, + }, + { + desc: "error - tool not found", + args: []string{"invoke", "non-existent", "--tools-file", toolsFilePath}, + wantErr: true, + errStr: `tool "non-existent" not found`, + }, + { + desc: "error - invalid JSON params", + args: []string{"invoke", "echo-tool", `invalid-json`, "--tools-file", toolsFilePath}, + wantErr: true, + errStr: `params must be a valid JSON string`, + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, got, err := invokeCommandWithContext(context.Background(), tc.args) + if (err != nil) != tc.wantErr { + t.Fatalf("got error %v, wantErr %v", err, tc.wantErr) + } + if tc.wantErr && !strings.Contains(err.Error(), tc.errStr) { + t.Fatalf("got error %v, want error containing %q", err, tc.errStr) + } + if !tc.wantErr && !strings.Contains(got, tc.want) { + t.Fatalf("got %q, want it to contain %q", got, tc.want) + } + }) + } +} + +func TestInvokeTool_AuthUnsupported(t *testing.T) { + tmpDir := t.TempDir() + toolsFileContent := ` +sources: + my-bq: + kind: bigquery + project: my-project + useClientOAuth: true +tools: + bq-tool: + kind: bigquery-sql + source: my-bq + description: "bq tool" + statement: "SELECT 1" +` + toolsFilePath := filepath.Join(tmpDir, "auth_tools.yaml") + if err := os.WriteFile(toolsFilePath, []byte(toolsFileContent), 0644); err != nil { + t.Fatalf("failed to write tools file: %v", err) + } + + args := []string{"invoke", "bq-tool", "--tools-file", toolsFilePath} + _, _, err := invokeCommandWithContext(context.Background(), args) + if err == nil { + t.Fatal("expected error for tool requiring client auth, but got nil") + } + if !strings.Contains(err.Error(), "client authorization is not supported") { + t.Fatalf("unexpected error message: %v", err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 8812d59cde..d0d11e1a07 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,6 +34,7 @@ import ( "github.com/fsnotify/fsnotify" yaml "github.com/goccy/go-yaml" "github.com/googleapis/genai-toolbox/internal/auth" + "github.com/googleapis/genai-toolbox/internal/cli/invoke" "github.com/googleapis/genai-toolbox/internal/embeddingmodels" "github.com/googleapis/genai-toolbox/internal/log" "github.com/googleapis/genai-toolbox/internal/prebuiltconfigs" @@ -365,37 +366,42 @@ func NewCommand(opts ...Option) *Command { baseCmd.SetErr(cmd.errStream) flags := cmd.Flags() + persistentFlags := cmd.PersistentFlags() + flags.StringVarP(&cmd.cfg.Address, "address", "a", "127.0.0.1", "Address of the interface the server will listen on.") flags.IntVarP(&cmd.cfg.Port, "port", "p", 5000, "Port the server will listen on.") flags.StringVar(&cmd.tools_file, "tools_file", "", "File path specifying the tool configuration. Cannot be used with --tools-files, or --tools-folder.") // deprecate tools_file _ = flags.MarkDeprecated("tools_file", "please use --tools-file instead") - flags.StringVar(&cmd.tools_file, "tools-file", "", "File path specifying the tool configuration. Cannot be used with --tools-files, or --tools-folder.") - flags.StringSliceVar(&cmd.tools_files, "tools-files", []string{}, "Multiple file paths specifying tool configurations. Files will be merged. Cannot be used with --tools-file, or --tools-folder.") - flags.StringVar(&cmd.tools_folder, "tools-folder", "", "Directory path containing YAML tool configuration files. All .yaml and .yml files in the directory will be loaded and merged. Cannot be used with --tools-file, or --tools-files.") - flags.Var(&cmd.cfg.LogLevel, "log-level", "Specify the minimum level logged. Allowed: 'DEBUG', 'INFO', 'WARN', 'ERROR'.") - flags.Var(&cmd.cfg.LoggingFormat, "logging-format", "Specify logging format to use. Allowed: 'standard' or 'JSON'.") - flags.BoolVar(&cmd.cfg.TelemetryGCP, "telemetry-gcp", false, "Enable exporting directly to Google Cloud Monitoring.") - flags.StringVar(&cmd.cfg.TelemetryOTLP, "telemetry-otlp", "", "Enable exporting using OpenTelemetry Protocol (OTLP) to the specified endpoint (e.g. 'http://127.0.0.1:4318')") - flags.StringVar(&cmd.cfg.TelemetryServiceName, "telemetry-service-name", "toolbox", "Sets the value of the service.name resource attribute for telemetry data.") + persistentFlags.StringVar(&cmd.tools_file, "tools-file", "", "File path specifying the tool configuration. Cannot be used with --tools-files, or --tools-folder.") + persistentFlags.StringSliceVar(&cmd.tools_files, "tools-files", []string{}, "Multiple file paths specifying tool configurations. Files will be merged. Cannot be used with --tools-file, or --tools-folder.") + persistentFlags.StringVar(&cmd.tools_folder, "tools-folder", "", "Directory path containing YAML tool configuration files. All .yaml and .yml files in the directory will be loaded and merged. Cannot be used with --tools-file, or --tools-files.") + persistentFlags.Var(&cmd.cfg.LogLevel, "log-level", "Specify the minimum level logged. Allowed: 'DEBUG', 'INFO', 'WARN', 'ERROR'.") + persistentFlags.Var(&cmd.cfg.LoggingFormat, "logging-format", "Specify logging format to use. Allowed: 'standard' or 'JSON'.") + persistentFlags.BoolVar(&cmd.cfg.TelemetryGCP, "telemetry-gcp", false, "Enable exporting directly to Google Cloud Monitoring.") + persistentFlags.StringVar(&cmd.cfg.TelemetryOTLP, "telemetry-otlp", "", "Enable exporting using OpenTelemetry Protocol (OTLP) to the specified endpoint (e.g. 'http://127.0.0.1:4318')") + persistentFlags.StringVar(&cmd.cfg.TelemetryServiceName, "telemetry-service-name", "toolbox", "Sets the value of the service.name resource attribute for telemetry data.") // Fetch prebuilt tools sources to customize the help description prebuiltHelp := fmt.Sprintf( "Use a prebuilt tool configuration by source type. Allowed: '%s'. Can be specified multiple times.", strings.Join(prebuiltconfigs.GetPrebuiltSources(), "', '"), ) - flags.StringSliceVar(&cmd.prebuiltConfigs, "prebuilt", []string{}, prebuiltHelp) + persistentFlags.StringSliceVar(&cmd.prebuiltConfigs, "prebuilt", []string{}, prebuiltHelp) flags.BoolVar(&cmd.cfg.Stdio, "stdio", false, "Listens via MCP STDIO instead of acting as a remote HTTP server.") flags.BoolVar(&cmd.cfg.DisableReload, "disable-reload", false, "Disables dynamic reloading of tools file.") flags.BoolVar(&cmd.cfg.UI, "ui", false, "Launches the Toolbox UI web server.") // TODO: Insecure by default. Might consider updating this for v1.0.0 flags.StringSliceVar(&cmd.cfg.AllowedOrigins, "allowed-origins", []string{"*"}, "Specifies a list of origins permitted to access this server. Defaults to '*'.") flags.StringSliceVar(&cmd.cfg.AllowedHosts, "allowed-hosts", []string{"*"}, "Specifies a list of hosts permitted to access this server. Defaults to '*'.") - flags.StringSliceVar(&cmd.cfg.UserAgentMetadata, "user-agent-metadata", []string{}, "Appends additional metadata to the User-Agent.") + persistentFlags.StringSliceVar(&cmd.cfg.UserAgentMetadata, "user-agent-metadata", []string{}, "Appends additional metadata to the User-Agent.") // wrap RunE command so that we have access to original Command object cmd.RunE = func(*cobra.Command, []string) error { return run(cmd) } + // Register subcommands for tool invocation + baseCmd.AddCommand(invoke.NewCommand(cmd)) + return cmd } @@ -919,70 +925,23 @@ func resolveWatcherInputs(toolsFile string, toolsFiles []string, toolsFolder str return watchDirs, watchedFiles } -func run(cmd *Command) error { - ctx, cancel := context.WithCancel(cmd.Context()) - defer cancel() +func (cmd *Command) Config() server.ServerConfig { + return cmd.cfg +} - // watch for sigterm / sigint signals - signals := make(chan os.Signal, 1) - signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) - go func(sCtx context.Context) { - var s os.Signal - select { - case <-sCtx.Done(): - // this should only happen when the context supplied when testing is canceled - return - case s = <-signals: - } - switch s { - case syscall.SIGINT: - cmd.logger.DebugContext(sCtx, "Received SIGINT signal to shutdown.") - case syscall.SIGTERM: - cmd.logger.DebugContext(sCtx, "Sending SIGTERM signal to shutdown.") - } - cancel() - }(ctx) +func (cmd *Command) Out() io.Writer { + return cmd.outStream +} - // If stdio, set logger's out stream (usually DEBUG and INFO logs) to errStream - loggerOut := cmd.outStream - if cmd.cfg.Stdio { - loggerOut = cmd.errStream - } +func (cmd *Command) Logger() log.Logger { + return cmd.logger +} - // Handle logger separately from config - switch strings.ToLower(cmd.cfg.LoggingFormat.String()) { - case "json": - logger, err := log.NewStructuredLogger(loggerOut, cmd.errStream, cmd.cfg.LogLevel.String()) - if err != nil { - return fmt.Errorf("unable to initialize logger: %w", err) - } - cmd.logger = logger - case "standard": - logger, err := log.NewStdLogger(loggerOut, cmd.errStream, cmd.cfg.LogLevel.String()) - if err != nil { - return fmt.Errorf("unable to initialize logger: %w", err) - } - cmd.logger = logger - default: - return fmt.Errorf("logging format invalid") - } - - ctx = util.WithLogger(ctx, cmd.logger) - - // Set up OpenTelemetry - otelShutdown, err := telemetry.SetupOTel(ctx, cmd.cfg.Version, cmd.cfg.TelemetryOTLP, cmd.cfg.TelemetryGCP, cmd.cfg.TelemetryServiceName) +func (cmd *Command) LoadConfig(ctx context.Context) error { + logger, err := util.LoggerFromContext(ctx) if err != nil { - errMsg := fmt.Errorf("error setting up OpenTelemetry: %w", err) - cmd.logger.ErrorContext(ctx, errMsg.Error()) - return errMsg + return err } - defer func() { - err := otelShutdown(ctx) - if err != nil { - errMsg := fmt.Errorf("error shutting down OpenTelemetry: %w", err) - cmd.logger.ErrorContext(ctx, errMsg.Error()) - } - }() var allToolsFiles []ToolsFile @@ -992,12 +951,12 @@ func run(cmd *Command) error { slices.Sort(cmd.prebuiltConfigs) sourcesList := strings.Join(cmd.prebuiltConfigs, ", ") logMsg := fmt.Sprintf("Using prebuilt tool configurations for: %s", sourcesList) - cmd.logger.InfoContext(ctx, logMsg) + logger.InfoContext(ctx, logMsg) for _, configName := range cmd.prebuiltConfigs { buf, err := prebuiltconfigs.Get(configName) if err != nil { - cmd.logger.ErrorContext(ctx, err.Error()) + logger.ErrorContext(ctx, err.Error()) return err } @@ -1005,7 +964,7 @@ func run(cmd *Command) error { parsed, err := parseToolsFile(ctx, buf) if err != nil { errMsg := fmt.Errorf("unable to parse prebuilt tool configuration for '%s': %w", configName, err) - cmd.logger.ErrorContext(ctx, errMsg.Error()) + logger.ErrorContext(ctx, errMsg.Error()) return errMsg } allToolsFiles = append(allToolsFiles, parsed) @@ -1031,7 +990,7 @@ func run(cmd *Command) error { (cmd.tools_file != "" && cmd.tools_folder != "") || (len(cmd.tools_files) > 0 && cmd.tools_folder != "") { errMsg := fmt.Errorf("--tools-file, --tools-files, and --tools-folder flags cannot be used simultaneously") - cmd.logger.ErrorContext(ctx, errMsg.Error()) + logger.ErrorContext(ctx, errMsg.Error()) return errMsg } @@ -1040,18 +999,18 @@ func run(cmd *Command) error { if len(cmd.tools_files) > 0 { // Use tools-files - cmd.logger.InfoContext(ctx, fmt.Sprintf("Loading and merging %d tool configuration files", len(cmd.tools_files))) + logger.InfoContext(ctx, fmt.Sprintf("Loading and merging %d tool configuration files", len(cmd.tools_files))) customTools, err = loadAndMergeToolsFiles(ctx, cmd.tools_files) } else if cmd.tools_folder != "" { // Use tools-folder - cmd.logger.InfoContext(ctx, fmt.Sprintf("Loading and merging all YAML files from directory: %s", cmd.tools_folder)) + logger.InfoContext(ctx, fmt.Sprintf("Loading and merging all YAML files from directory: %s", cmd.tools_folder)) customTools, err = loadAndMergeToolsFolder(ctx, cmd.tools_folder) } else { // Use single file (tools-file or default `tools.yaml`) buf, readFileErr := os.ReadFile(cmd.tools_file) if readFileErr != nil { errMsg := fmt.Errorf("unable to read tool file at %q: %w", cmd.tools_file, readFileErr) - cmd.logger.ErrorContext(ctx, errMsg.Error()) + logger.ErrorContext(ctx, errMsg.Error()) return errMsg } customTools, err = parseToolsFile(ctx, buf) @@ -1061,7 +1020,7 @@ func run(cmd *Command) error { } if err != nil { - cmd.logger.ErrorContext(ctx, err.Error()) + logger.ErrorContext(ctx, err.Error()) return err } allToolsFiles = append(allToolsFiles, customTools) @@ -1083,7 +1042,7 @@ func run(cmd *Command) error { // This will error if custom tools collide with prebuilt tools finalToolsFile, err := mergeToolsFiles(allToolsFiles...) if err != nil { - cmd.logger.ErrorContext(ctx, err.Error()) + logger.ErrorContext(ctx, err.Error()) return err } @@ -1094,15 +1053,91 @@ func run(cmd *Command) error { cmd.cfg.ToolsetConfigs = finalToolsFile.Toolsets cmd.cfg.PromptConfigs = finalToolsFile.Prompts - instrumentation, err := telemetry.CreateTelemetryInstrumentation(versionString) + return nil +} + +func (cmd *Command) Setup(ctx context.Context) (context.Context, func(context.Context) error, error) { + // If stdio, set logger's out stream (usually DEBUG and INFO logs) to errStream + loggerOut := cmd.outStream + if cmd.cfg.Stdio { + loggerOut = cmd.errStream + } + + // Handle logger separately from config + logger, err := log.NewLogger(cmd.cfg.LoggingFormat.String(), cmd.cfg.LogLevel.String(), loggerOut, cmd.errStream) + if err != nil { + return ctx, nil, fmt.Errorf("unable to initialize logger: %w", err) + } + cmd.logger = logger + + ctx = util.WithLogger(ctx, cmd.logger) + + // Set up OpenTelemetry + otelShutdown, err := telemetry.SetupOTel(ctx, cmd.cfg.Version, cmd.cfg.TelemetryOTLP, cmd.cfg.TelemetryGCP, cmd.cfg.TelemetryServiceName) + if err != nil { + errMsg := fmt.Errorf("error setting up OpenTelemetry: %w", err) + cmd.logger.ErrorContext(ctx, errMsg.Error()) + return ctx, nil, errMsg + } + + shutdownFunc := func(ctx context.Context) error { + err := otelShutdown(ctx) + if err != nil { + errMsg := fmt.Errorf("error shutting down OpenTelemetry: %w", err) + cmd.logger.ErrorContext(ctx, errMsg.Error()) + return err + } + return nil + } + + instrumentation, err := telemetry.CreateTelemetryInstrumentation(cmd.cfg.Version) if err != nil { errMsg := fmt.Errorf("unable to create telemetry instrumentation: %w", err) cmd.logger.ErrorContext(ctx, errMsg.Error()) - return errMsg + return ctx, shutdownFunc, errMsg } ctx = util.WithInstrumentation(ctx, instrumentation) + return ctx, shutdownFunc, nil +} + +func run(cmd *Command) error { + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + // watch for sigterm / sigint signals + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) + go func(sCtx context.Context) { + var s os.Signal + select { + case <-sCtx.Done(): + // this should only happen when the context supplied when testing is canceled + return + case s = <-signals: + } + switch s { + case syscall.SIGINT: + cmd.logger.DebugContext(sCtx, "Received SIGINT signal to shutdown.") + case syscall.SIGTERM: + cmd.logger.DebugContext(sCtx, "Sending SIGTERM signal to shutdown.") + } + cancel() + }(ctx) + + ctx, shutdown, err := cmd.Setup(ctx) + if err != nil { + return err + } + defer func() { + _ = shutdown(ctx) + }() + + if err := cmd.LoadConfig(ctx); err != nil { + return err + } + // start server s, err := server.NewServer(ctx, cmd.cfg) if err != nil { @@ -1142,6 +1177,9 @@ func run(cmd *Command) error { }() } + // Determine if Custom Files are configured (re-check as loadAndMergeConfig might have updated defaults) + isCustomConfigured := cmd.tools_file != "" || len(cmd.tools_files) > 0 || cmd.tools_folder != "" + if isCustomConfigured && !cmd.cfg.DisableReload { watchDirs, watchedFiles := resolveWatcherInputs(cmd.tools_file, cmd.tools_files, cmd.tools_folder) // start watching the file(s) or folder for changes to trigger dynamic reloading diff --git a/docs/en/how-to/invoke_tool.md b/docs/en/how-to/invoke_tool.md new file mode 100644 index 0000000000..c144be96c6 --- /dev/null +++ b/docs/en/how-to/invoke_tool.md @@ -0,0 +1,74 @@ +--- +title: "Invoke Tools via CLI" +type: docs +weight: 10 +description: > + Learn how to invoke your tools directly from the command line using the `invoke` command. +--- + +The `invoke` command allows you to invoke tools defined in your configuration directly from the CLI. This is useful for: + +- **Ephemeral Invocation:** Executing a tool without spinning up a full MCP server/client. +- **Debugging:** Isolating tool execution logic and testing with various parameter combinations. + +{{< notice tip >}} +**Keep configurations minimal:** The `invoke` command initializes *all* resources (sources, tools, etc.) defined in your configuration files during execution. To ensure fast response times, consider using a minimal configuration file containing only the tools you need for the specific invocation. +{{< notice tip >}} + +## Prerequisites + +- You have the `toolbox` binary installed or built. +- You have a valid tool configuration file (e.g., `tools.yaml`). + +## Basic Usage + +The basic syntax for the command is: + +```bash +toolbox [--tools-file | --prebuilt ] invoke [params] +``` + +- ``: The name of the tool you want to call. This must match the name defined in your `tools.yaml`. +- `[params]`: (Optional) A JSON string representing the arguments for the tool. + +## Examples + +### 1. Calling a Tool without Parameters + +If your tool takes no parameters, simply provide the tool name: + +```bash +toolbox --tools-file tools.yaml invoke my-simple-tool +``` + +### 2. Calling a Tool with Parameters + +For tools that require arguments, pass them as a JSON string. Ensure you escape quotes correctly for your shell. + +**Example: A tool that takes parameters** + +Assuming a tool named `mytool` taking `a` and `b`: + +```bash +toolbox --tools-file tools.yaml invoke mytool '{"a": 10, "b": 20}' +``` + +**Example: A tool that queries a database** + +```bash +toolbox --tools-file tools.yaml invoke db-query '{"sql": "SELECT * FROM users LIMIT 5"}' +``` + +### 3. Using Prebuilt Configurations + +You can also use the `--prebuilt` flag to load prebuilt toolsets. + +```bash +toolbox --prebuilt cloudsql-postgres invoke cloudsql-postgres-list-instances +``` + +## Troubleshooting + +- **Tool not found:** Ensure the `` matches exactly what is in your YAML file and that the file is correctly loaded via `--tools-file`. +- **Invalid parameters:** Double-check your JSON syntax. The error message will usually indicate if the JSON parsing failed or if the parameters didn't match the tool's schema. +- **Auth errors:** The `invoke` command currently does not support flows requiring client-side authorization (like OAuth flow initiation via the CLI). It works best for tools using service-side authentication (e.g., Application Default Credentials). diff --git a/docs/en/reference/cli.md b/docs/en/reference/cli.md index 329fbd1b93..150171f3aa 100644 --- a/docs/en/reference/cli.md +++ b/docs/en/reference/cli.md @@ -30,6 +30,21 @@ description: > | | `--user-agent-metadata` | Appends additional metadata to the User-Agent. | | | `-v` | `--version` | version for toolbox | | +## Sub Commands + +### `invoke` + +Executes a tool directly with the provided parameters. This is useful for testing tool configurations and parameters without needing a full client setup. + +**Syntax:** + +```bash +toolbox invoke [params] +``` + +- ``: The name of the tool to execute (as defined in your configuration). +- `[params]`: (Optional) A JSON string containing the parameters for the tool. + ## Examples ### Transport Configuration diff --git a/internal/cli/invoke/command.go b/internal/cli/invoke/command.go new file mode 100644 index 0000000000..22ab8e55d3 --- /dev/null +++ b/internal/cli/invoke/command.go @@ -0,0 +1,161 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package invoke + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/googleapis/genai-toolbox/internal/log" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/server/resources" + "github.com/googleapis/genai-toolbox/internal/util/parameters" + "github.com/spf13/cobra" +) + +// RootCommand defines the interface for required by invoke subcommand. +// This allows subcommands to access shared resources and functionality without +// direct coupling to the root command's implementation. +type RootCommand interface { + // Config returns a copy of the current server configuration. + Config() server.ServerConfig + + // Out returns the writer used for standard output. + Out() io.Writer + + // LoadConfig loads and merges the configuration from files, folders, and prebuilts. + LoadConfig(ctx context.Context) error + + // Setup initializes the runtime environment, including logging and telemetry. + // It returns the updated context and a shutdown function to be called when finished. + Setup(ctx context.Context) (context.Context, func(context.Context) error, error) + + // Logger returns the logger instance. + Logger() log.Logger +} + +func NewCommand(rootCmd RootCommand) *cobra.Command { + cmd := &cobra.Command{ + Use: "invoke [params]", + Short: "Execute a tool directly", + Long: `Execute a tool directly with parameters. +Params must be a JSON string. +Example: + toolbox invoke my-tool '{"param1": "value1"}'`, + Args: cobra.MinimumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + return runInvoke(c, args, rootCmd) + }, + } + return cmd +} + +func runInvoke(cmd *cobra.Command, args []string, rootCmd RootCommand) error { + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + ctx, shutdown, err := rootCmd.Setup(ctx) + if err != nil { + return err + } + defer func() { + _ = shutdown(ctx) + }() + + // Load and merge tool configurations + if err := rootCmd.LoadConfig(ctx); err != nil { + return err + } + + // Initialize Resources + sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := server.InitializeConfigs(ctx, rootCmd.Config()) + if err != nil { + errMsg := fmt.Errorf("failed to initialize resources: %w", err) + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + resourceMgr := resources.NewResourceManager(sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap) + + // Execute Tool + toolName := args[0] + tool, ok := resourceMgr.GetTool(toolName) + if !ok { + errMsg := fmt.Errorf("tool %q not found", toolName) + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + var paramsInput string + if len(args) > 1 { + paramsInput = args[1] + } + + params := make(map[string]any) + if paramsInput != "" { + if err := json.Unmarshal([]byte(paramsInput), ¶ms); err != nil { + errMsg := fmt.Errorf("params must be a valid JSON string: %w", err) + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + } + + parsedParams, err := parameters.ParseParams(tool.GetParameters(), params, nil) + if err != nil { + errMsg := fmt.Errorf("invalid parameters: %w", err) + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + parsedParams, err = tool.EmbedParams(ctx, parsedParams, resourceMgr.GetEmbeddingModelMap()) + if err != nil { + errMsg := fmt.Errorf("error embedding parameters: %w", err) + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + // Client Auth not supported for ephemeral CLI call + requiresAuth, err := tool.RequiresClientAuthorization(resourceMgr) + if err != nil { + errMsg := fmt.Errorf("failed to check auth requirements: %w", err) + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + if requiresAuth { + errMsg := fmt.Errorf("client authorization is not supported") + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + result, err := tool.Invoke(ctx, resourceMgr, parsedParams, "") + if err != nil { + errMsg := fmt.Errorf("tool execution failed: %w", err) + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + // Print Result + output, err := json.MarshalIndent(result, "", " ") + if err != nil { + errMsg := fmt.Errorf("failed to marshal result: %w", err) + rootCmd.Logger().ErrorContext(ctx, errMsg.Error()) + return errMsg + } + fmt.Fprintln(rootCmd.Out(), string(output)) + + return nil +} diff --git a/internal/log/log.go b/internal/log/log.go index 2c9d1a9273..710dc2ee95 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -22,6 +22,18 @@ import ( "strings" ) +// NewLogger creates a new logger based on the provided format and level. +func NewLogger(format, level string, out, err io.Writer) (Logger, error) { + switch strings.ToLower(format) { + case "json": + return NewStructuredLogger(out, err, level) + case "standard": + return NewStdLogger(out, err, level) + default: + return nil, fmt.Errorf("logging format invalid: %s", format) + } +} + // StdLogger is the standard logger type StdLogger struct { outLogger *slog.Logger