diff --git a/README.md b/README.md index 506a924..cf679e2 100644 --- a/README.md +++ b/README.md @@ -89,13 +89,30 @@ func main() { ), ) - // Add the tool to the server with a handler + // Example of a tool with array parameter + arrayExampleTool := tools.NewTool("array_example", + tools.WithDescription("Example tool with array parameter"), + tools.WithArray("values", + tools.Description("Array of string values"), + tools.Required(), + tools.Items(map[string]interface{}{ + "type": "string", + }), + ), + ) + + // Add the tools to the server with handlers ctx := context.Background() err := mcpServer.AddTool(ctx, echoTool, handleEcho) if err != nil { logger.Fatalf("Error adding tool: %v", err) } + err = mcpServer.AddTool(ctx, arrayExampleTool, handleArrayExample) + if err != nil { + logger.Fatalf("Error adding array example tool: %v", err) + } + // Write server status to stderr instead of stdout to maintain clean JSON protocol fmt.Fprintf(os.Stderr, "Starting Echo Server...\n") fmt.Fprintf(os.Stderr, "Send JSON-RPC messages via stdin to interact with the server.\n") @@ -126,6 +143,26 @@ func handleEcho(ctx context.Context, request server.ToolCallRequest) (interface{ }, }, nil } + +// Array example tool handler +func handleArrayExample(ctx context.Context, request server.ToolCallRequest) (interface{}, error) { + // Extract the values parameter + values, ok := request.Parameters["values"].([]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid 'values' parameter") + } + + // Convert values to string array + stringValues := make([]string, len(values)) + for i, v := range values { + stringValues[i] = v.(string) + } + + // Return the array response in the format expected by the MCP protocol + return map[string]interface{}{ + "content": stringValues, + }, nil +} ``` ## What is MCP? diff --git a/agent-sdk-test b/agent-sdk-test new file mode 100755 index 0000000..46b6659 Binary files /dev/null and b/agent-sdk-test differ diff --git a/array-parameter-test b/array-parameter-test new file mode 100755 index 0000000..c1bf0d0 Binary files /dev/null and b/array-parameter-test differ diff --git a/bin/multi-protocol-server b/bin/multi-protocol-server index 1177f82..983cb30 100755 Binary files a/bin/multi-protocol-server and b/bin/multi-protocol-server differ diff --git a/bin/sse-server b/bin/sse-server index 3a3bab2..7096639 100755 Binary files a/bin/sse-server and b/bin/sse-server differ diff --git a/bin/stdio-server b/bin/stdio-server index 0bdda71..45d8414 100755 Binary files a/bin/stdio-server and b/bin/stdio-server differ diff --git a/examples/agent-sdk-test/main.go b/examples/agent-sdk-test/main.go new file mode 100644 index 0000000..36efec6 --- /dev/null +++ b/examples/agent-sdk-test/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/FreePeak/cortex/pkg/server" + "github.com/FreePeak/cortex/pkg/tools" +) + +func main() { + // Create a logger that writes to stderr + logger := log.New(os.Stderr, "[agent-sdk-test] ", log.LstdFlags) + + // Create the server + mcpServer := server.NewMCPServer("Agent SDK Test", "1.0.0", logger) + + // Configure HTTP address + mcpServer.SetAddress(":9095") + + // Create a tool with array parameter (compatible with OpenAI Agent SDK) + queryTool := tools.NewTool("query_database", + tools.WithDescription("Execute SQL query on a database"), + tools.WithString("query", + tools.Description("SQL query to execute"), + tools.Required(), + ), + tools.WithArray("params", + tools.Description("Query parameters"), + tools.Items(map[string]interface{}{ + "type": "string", + }), + ), + ) + + // Add tool to the server + ctx := context.Background() + err := mcpServer.AddTool(ctx, queryTool, handleQuery) + if err != nil { + logger.Fatalf("Error adding tool: %v", err) + } + + // Start HTTP server in a goroutine + go func() { + logger.Printf("Starting Agent SDK Test server on %s", mcpServer.GetAddress()) + logger.Printf("Use the following URL in your OpenAI Agent SDK configuration: http://localhost:9095/sse") + + if err := mcpServer.ServeHTTP(); err != nil { + logger.Fatalf("HTTP server error: %v", err) + } + }() + + // Wait for shutdown signal + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + + // Shutdown gracefully + logger.Println("Shutting down server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := mcpServer.Shutdown(shutdownCtx); err != nil { + logger.Fatalf("Server shutdown error: %v", err) + } + + logger.Println("Server shutdown complete") +} + +// Handler for the query tool +func handleQuery(ctx context.Context, request server.ToolCallRequest) (interface{}, error) { + // Extract the query parameter + query, ok := request.Parameters["query"].(string) + if !ok { + return nil, fmt.Errorf("missing or invalid 'query' parameter") + } + + // Get optional parameters + var params []interface{} + if paramsVal, ok := request.Parameters["params"]; ok { + params, _ = paramsVal.([]interface{}) + } + + // In a real implementation, you would execute the query with the parameters + // For this example, we'll just return mock data + + // Log the request + log.Printf("Query received: %s", query) + log.Printf("Parameters: %v", params) + + // Return a mock response + return map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "text", + "text": fmt.Sprintf("Executed query: %s\nParameters: %v\n\nID\tName\tValue\n1\tItem1\t100\n2\tItem2\t200", query, params), + }, + }, + }, nil +} diff --git a/examples/array-parameter-test/main.go b/examples/array-parameter-test/main.go new file mode 100644 index 0000000..00ba663 --- /dev/null +++ b/examples/array-parameter-test/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + + "github.com/FreePeak/cortex/pkg/server" + "github.com/FreePeak/cortex/pkg/tools" + "github.com/FreePeak/cortex/pkg/types" +) + +func main() { + // Create a logger that writes to stderr + logger := log.New(os.Stderr, "[array-test] ", log.LstdFlags) + + // Create the server + mcpServer := server.NewMCPServer("Array Parameter Test", "1.0.0", logger) + + // Create a tool with array parameter + arrayTool := tools.NewTool("array_test", + tools.WithDescription("Test tool with array parameter"), + tools.WithArray("string_array", + tools.Description("Array of strings"), + tools.Required(), + tools.Items(map[string]interface{}{ + "type": "string", + }), + ), + tools.WithArray("number_array", + tools.Description("Array of numbers"), + tools.Items(map[string]interface{}{ + "type": "number", + }), + ), + ) + + // Add the tool to the server + ctx := context.Background() + err := mcpServer.AddTool(ctx, arrayTool, handleArrayTest) + if err != nil { + logger.Fatalf("Error adding tool: %v", err) + } + + // Print tool schema for debugging + printToolSchema(arrayTool) + + // Write server status to stderr + fmt.Fprintf(os.Stderr, "Starting Array Parameter Test Server...\n") + fmt.Fprintf(os.Stderr, "Send JSON-RPC messages via stdin to interact with the server.\n") + fmt.Fprintf(os.Stderr, `Try: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"array_test","parameters":{"string_array":["a","b","c"]}}}\n`) + + // Serve over stdio + if err := mcpServer.ServeStdio(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +// Handler for the array test tool +func handleArrayTest(ctx context.Context, request server.ToolCallRequest) (interface{}, error) { + // Extract the string array parameter + stringArray, ok := request.Parameters["string_array"].([]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid 'string_array' parameter") + } + + // Get the optional number array parameter + var numberArray []interface{} + if val, ok := request.Parameters["number_array"]; ok { + numberArray, _ = val.([]interface{}) + } + + // Return the arrays in the response + return map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "text", + "text": fmt.Sprintf("Received string array: %v\nReceived number array: %v", stringArray, numberArray), + }, + }, + }, nil +} + +// Print the tool schema +func printToolSchema(tool *types.Tool) { + schema := map[string]interface{}{ + "type": "object", + "properties": make(map[string]interface{}), + } + + for _, param := range tool.Parameters { + paramSchema := map[string]interface{}{ + "type": param.Type, + "description": param.Description, + } + + if param.Type == "array" && param.Items != nil { + paramSchema["items"] = param.Items + } + + schema["properties"].(map[string]interface{})[param.Name] = paramSchema + } + + schemaJSON, _ := json.MarshalIndent(schema, "", " ") + fmt.Fprintf(os.Stderr, "Tool schema:\n%s\n", schemaJSON) +} diff --git a/go.sum b/go.sum index 40c2a59..4964aa5 100644 --- a/go.sum +++ b/go.sum @@ -8,10 +8,13 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/domain/types.go b/internal/domain/types.go index dc5c73d..0292c73 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -50,6 +50,7 @@ type ToolParameter struct { Description string Type string Required bool + Items map[string]interface{} } // ToolCall represents a request to execute a tool. diff --git a/internal/infrastructure/logging/logger.go b/internal/infrastructure/logging/logger.go index 50e8d30..b3cbfdc 100644 --- a/internal/infrastructure/logging/logger.go +++ b/internal/infrastructure/logging/logger.go @@ -40,6 +40,8 @@ type Config struct { Development bool OutputPaths []string InitialFields Fields + LogToFile bool + LogDir string } // DefaultConfig returns a default configuration for the logger @@ -105,7 +107,10 @@ func getStdioSafeOutputs() []string { // Create a log file in the logs directory logsDir := "logs" if _, err := os.Stat(logsDir); os.IsNotExist(err) { - os.MkdirAll(logsDir, 0755) + if err := os.MkdirAll(logsDir, 0755); err != nil { + // If we can't create the directory, just use stderr + return []string{"stderr"} + } } // Create a timestamped log file diff --git a/internal/interfaces/rest/server.go b/internal/interfaces/rest/server.go index 77d20a4..6c73d58 100644 --- a/internal/interfaces/rest/server.go +++ b/internal/interfaces/rest/server.go @@ -358,6 +358,12 @@ func (s *MCPServer) processToolsList(ctx context.Context, request domain.JSONRPC "type": param.Type, "description": param.Description, } + + // Add items schema for array parameters + if param.Type == "array" && param.Items != nil { + paramObj["items"] = param.Items + } + properties[param.Name] = paramObj if param.Required { diff --git a/internal/interfaces/stdio/server.go b/internal/interfaces/stdio/server.go index 05760d8..43d5a75 100644 --- a/internal/interfaces/stdio/server.go +++ b/internal/interfaces/stdio/server.go @@ -440,6 +440,12 @@ func (p *MessageProcessor) handleToolsList(ctx context.Context, params interface "type": param.Type, "description": param.Description, } + + // Add items schema for array parameters + if param.Type == "array" && param.Items != nil { + paramObj["items"] = param.Items + } + properties[param.Name] = paramObj if param.Required { diff --git a/pkg/builder/server_builder.go b/pkg/builder/server_builder.go index 7ab2e9a..0f66754 100644 --- a/pkg/builder/server_builder.go +++ b/pkg/builder/server_builder.go @@ -223,6 +223,7 @@ func (a *toolRepositoryAdapter) GetTool(ctx context.Context, name string) (*inte Description: param.Description, Type: param.Type, Required: param.Required, + Items: param.Items, } } @@ -249,6 +250,7 @@ func (a *toolRepositoryAdapter) ListTools(ctx context.Context) ([]*internalDomai Description: param.Description, Type: param.Type, Required: param.Required, + Items: param.Items, } } } @@ -269,6 +271,7 @@ func (a *toolRepositoryAdapter) AddTool(ctx context.Context, tool *internalDomai Description: param.Description, Type: param.Type, Required: param.Required, + Items: param.Items, } } diff --git a/pkg/server/server.go b/pkg/server/server.go index c461562..36482fd 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -140,6 +140,7 @@ func (s *MCPServer) RegisterProvider(ctx context.Context, provider plugin.Provid Description: param.Description, Type: param.Type, Required: param.Required, + Items: param.Items, } } @@ -292,6 +293,7 @@ func convertToInternalTool(tool *types.Tool) *domain.Tool { Description: param.Description, Type: param.Type, Required: param.Required, + Items: param.Items, } } diff --git a/pkg/tools/helper.go b/pkg/tools/helper.go index 1155040..3c0b5d9 100644 --- a/pkg/tools/helper.go +++ b/pkg/tools/helper.go @@ -49,6 +49,13 @@ func Required() ParameterOption { } } +// Items sets the schema for items in an array parameter. +func Items(schema map[string]interface{}) ParameterOption { + return func(p *types.ToolParameter) { + p.Items = schema + } +} + // Type functions for creating parameters // WithString adds a string parameter to a tool. diff --git a/pkg/types/types.go b/pkg/types/types.go index 5143cd0..31bfe8e 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -52,6 +52,7 @@ type ToolParameter struct { Description string Type string Required bool + Items map[string]interface{} } // ToolCall represents a request to execute a tool.