From 10e51ca0699d136fae94236d473800c1e6fb3c09 Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Sun, 16 Feb 2025 10:32:30 -0500 Subject: [PATCH 01/10] :art: Add RFC draft to refactor transports --- ttmp/2025-02-14/01-rfc-transport-layer.md | 579 ++++++++++++++++++++++ ttmp/2025-02-14/changelog.md | 11 + 2 files changed, 590 insertions(+) create mode 100644 ttmp/2025-02-14/01-rfc-transport-layer.md create mode 100644 ttmp/2025-02-14/changelog.md diff --git a/ttmp/2025-02-14/01-rfc-transport-layer.md b/ttmp/2025-02-14/01-rfc-transport-layer.md new file mode 100644 index 0000000..fe0360d --- /dev/null +++ b/ttmp/2025-02-14/01-rfc-transport-layer.md @@ -0,0 +1,579 @@ +# RFC: Clean Transport Layer for MCP + +## Status +- Status: Draft +- Date: 2025-02-14 +- Authors: Claude +- Related Issues: N/A + +## Abstract + +This RFC proposes a clean, unified transport layer for the MCP protocol. The current implementation mixes concerns between transport, request handling, and business logic. The proposed design separates these concerns and provides a clear interface for implementing new transport mechanisms. + +## Background + +The current MCP implementation has two transport mechanisms: +1. Server-Sent Events (SSE) for real-time communication +2. Standard IO (stdio) for command-line usage + +## Proposal + +### 1. Core Interfaces + +#### Transport Interface + +```go +// Transport handles the low-level communication between client and server +type Transport interface { + // Listen starts accepting requests and forwards them to the handler + Listen(ctx context.Context, handler RequestHandler) error + + // Send transmits a response back to the client + Send(ctx context.Context, response *Response) error + + // Close cleanly shuts down the transport + Close(ctx context.Context) error + + // Info returns metadata about the transport + Info() TransportInfo +} + +// TransportInfo provides metadata about the transport +type TransportInfo struct { + Type string // "sse", "stdio", etc. + RemoteAddr string // Remote address if applicable + Capabilities map[string]bool // Transport capabilities + Metadata map[string]string // Additional transport metadata +} +``` + +#### Request Handler Interface + +```go +// RequestHandler processes incoming requests and notifications +type RequestHandler interface { + // HandleRequest processes a request and returns a response + HandleRequest(ctx context.Context, req *Request) (*Response, error) + + // HandleNotification processes a notification (no response expected) + HandleNotification(ctx context.Context, notif *Notification) error +} + +// Request represents an incoming JSON-RPC request +type Request struct { + ID string + Method string + Params json.RawMessage + Headers map[string]string +} + +// Response represents an outgoing JSON-RPC response +type Response struct { + ID string + Result json.RawMessage + Error *ResponseError + Headers map[string]string +} + +// Notification represents an incoming notification +type Notification struct { + Method string + Params json.RawMessage + Headers map[string]string +} +``` + +### 2. Transport Options + +```go +// Common options for all transports +type TransportOptions struct { + // Common options + MaxMessageSize int64 + ReadTimeout time.Duration + WriteTimeout time.Duration + MaxConcurrentRequests int + Logger zerolog.Logger + + // Transport specific options + SSE *SSEOptions + Stdio *StdioOptions +} + +// SSE-specific options +type SSEOptions struct { + // HTTP server configuration + Addr string + TLSConfig *tls.Config + ReadBufferSize int + WriteBufferSize int + + // SSE specific settings + HeartbeatInterval time.Duration + RetryInterval time.Duration + + // Connection management + MaxConnections int + IdleTimeout time.Duration + + // Middleware + Middleware []func(http.Handler) http.Handler +} + +// Stdio-specific options +type StdioOptions struct { + // Buffer sizes + ReadBufferSize int + WriteBufferSize int + + // Process management + Command string + Args []string + WorkingDir string + Environment map[string]string + + // Signal handling + SignalHandlers map[os.Signal]func() +} + +// Option constructors for each transport type +func WithSSEOptions(opts SSEOptions) TransportOption +func WithStdioOptions(opts StdioOptions) TransportOption +``` + +### 3. SSE Transport Implementation + +```go +// SSETransport implements Transport using Server-Sent Events +type SSETransport struct { + opts TransportOptions + server *http.Server + upgrader *sse.Upgrader + clients sync.Map + logger zerolog.Logger +} + +// NewSSETransport creates a new SSE transport +func NewSSETransport(addr string, opts ...TransportOption) (*SSETransport, error) + +// Implementation example +func (t *SSETransport) Listen(ctx context.Context, handler RequestHandler) error { + // Set up HTTP server + t.server = &http.Server{ + Addr: t.addr, + Handler: t.createHandler(handler), + } + + // Start server + go func() { + if err := t.server.ListenAndServe(); err != http.ErrServerClosed { + t.logger.Error().Err(err).Msg("SSE server error") + } + }() + + // Wait for context cancellation + <-ctx.Done() + return t.Close(ctx) +} +``` + +### 4. Stdio Transport Implementation + +```go +// StdioTransport implements Transport using standard input/output +type StdioTransport struct { + opts TransportOptions + stdin *bufio.Reader + stdout *bufio.Writer + logger zerolog.Logger +} + +// NewStdioTransport creates a new stdio transport +func NewStdioTransport(opts ...TransportOption) (*StdioTransport, error) + +// Implementation example +func (t *StdioTransport) Listen(ctx context.Context, handler RequestHandler) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + + default: + // Read request + req, err := t.readRequest() + if err != nil { + if err == io.EOF { + return nil + } + t.logger.Error().Err(err).Msg("Failed to read request") + continue + } + + // Handle request + go t.handleRequest(ctx, req, handler) + } + } +} +``` + +### 5. Error Handling + +```go +// ResponseError represents a JSON-RPC error response +type ResponseError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Common error codes +const ( + ErrCodeParse = -32700 + ErrCodeInvalidRequest = -32600 + ErrCodeMethodNotFound = -32601 + ErrCodeInvalidParams = -32602 + ErrCodeInternal = -32603 + ErrCodeTransport = -32500 + ErrCodeTimeout = -32501 +) + +// Error constructors +func NewParseError(msg string) *ResponseError +func NewInvalidRequestError(msg string) *ResponseError +func NewMethodNotFoundError(msg string) *ResponseError +func NewInvalidParamsError(msg string) *ResponseError +func NewInternalError(msg string) *ResponseError +func NewTransportError(msg string) *ResponseError +func NewTimeoutError(msg string) *ResponseError +``` + +## Implementation + +The implementation will follow these steps: + +1. Create new transport package with core interfaces +2. Implement SSE transport +3. Implement stdio transport +4. Add tests for both implementations +5. Create migration guide for existing code +6. Update server to use new transport layer + +### Testing Strategy + +1. Unit tests for each transport implementation +2. Integration tests with mock handlers +3. Benchmark tests for performance comparison +4. Stress tests for concurrent handling +5. Error handling tests + +Example test: +```go +func TestSSETransport(t *testing.T) { + // Create transport + transport, err := NewSSETransport(":0", WithLogger(zerolog.Nop())) + require.NoError(t, err) + + // Create mock handler + handler := &MockRequestHandler{} + + // Start transport + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + err := transport.Listen(ctx, handler) + require.NoError(t, err) + }() + + // Test requests + // ... +} +``` + +## Migration Guide + +1. Update imports to use new transport package +2. Replace direct transport usage with interface +3. Update error handling to use new error types +4. Add transport options where needed +5. Update tests to use new interfaces + +Example: +```go +// Before +transport := client.NewSSETransport(logger, "http://localhost:8080") + +// After +transport, err := NewSSETransport( + "localhost:8080", + WithLogger(logger), + WithMaxMessageSize(1024*1024), +) +``` + +## Migration Plan + +### Phase 1: Package Structure (Week 1) + +1. Create new package structure: +``` +pkg/ + transport/ + transport.go # Core interfaces + options.go # Transport options + errors.go # Error types + sse/ + transport.go # SSE implementation + options.go # SSE specific options + stdio/ + transport.go # Stdio implementation + options.go # Stdio specific options + testing/ + mock.go # Mock transport for testing +``` + +2. Move existing transport code: +```bash +# Create new directories +mkdir -p pkg/transport/{sse,stdio,testing} + +# Move existing files +mv pkg/client/sse.go pkg/transport/sse/transport.go +mv pkg/client/stdio.go pkg/transport/stdio/transport.go +``` + +### Phase 2: Interface Implementation (Week 2-3) + +1. Update SSE Transport: + +```go +// Old implementation +type SSETransport struct { + mu sync.Mutex + baseURL string + client *http.Client + sseClient *sse.Client + events chan *sse.Event + responses chan *sse.Event + notifications chan *sse.Event + closeOnce sync.Once + logger zerolog.Logger + initialized bool + sessionID string + endpoint string + notificationHandler func(*protocol.Response) +} + +// New implementation +type SSETransport struct { + opts TransportOptions + server *http.Server + upgrader *sse.Upgrader + clients sync.Map + logger zerolog.Logger + handler RequestHandler +} + +// Migration steps: +1. Create new struct with required fields +2. Implement Transport interface +3. Add SSE-specific functionality +4. Update error handling +5. Add tests +``` + +2. Update Stdio Transport: + +```go +// Old implementation +type StdioTransport struct { + mu sync.Mutex + scanner *bufio.Scanner + writer *json.Encoder + cmd *exec.Cmd + logger zerolog.Logger + notificationHandler func(*protocol.Response) +} + +// New implementation +type StdioTransport struct { + opts TransportOptions + stdin *bufio.Reader + stdout *bufio.Writer + cmd *exec.Cmd + logger zerolog.Logger + handler RequestHandler +} + +// Migration steps: +1. Create new struct with required fields +2. Implement Transport interface +3. Add process management +4. Update error handling +5. Add tests +``` + +### Phase 3: Client Updates (Week 4) + +1. Update Client struct: + +```go +// Old implementation +type Client struct { + mu sync.Mutex + logger zerolog.Logger + transport Transport + nextID int + // ... +} + +// New implementation +type Client struct { + mu sync.Mutex + logger zerolog.Logger + transport transport.Transport + nextID int + // ... +} + +// Migration steps: +1. Update import paths +2. Implement RequestHandler interface +3. Update error handling +4. Add transport options +``` + +2. Update client creation: + +```go +// Old +client := NewClient(logger, NewSSETransport(baseURL)) + +// New +transport, err := sse.NewTransport( + transport.WithSSEOptions(sse.Options{ + Addr: baseURL, + HeartbeatInterval: 30 * time.Second, + MaxConnections: 100, + }), + transport.WithLogger(logger), +) +if err != nil { + return nil, err +} +client := NewClient(logger, transport) +``` + +### Phase 4: Server Updates (Week 5) + +1. Update Server struct: + +```go +// Old implementation +type Server struct { + logger zerolog.Logger + transport Transport + // ... +} + +// New implementation +type Server struct { + logger zerolog.Logger + transport transport.Transport + handler RequestHandler + // ... +} + +// Migration steps: +1. Update import paths +2. Implement RequestHandler interface +3. Add transport configuration +4. Update error handling +``` + +2. Update server creation: + +```go +// Old +server := NewServer(logger, NewSSETransport(":8080")) + +// New +transport, err := sse.NewTransport( + transport.WithSSEOptions(sse.Options{ + Addr: ":8080", + TLSConfig: &tls.Config{...}, + Middleware: []func(http.Handler) http.Handler{ + cors.Handler, + auth.Handler, + }, + }), + transport.WithLogger(logger), +) +if err != nil { + return nil, err +} +server := NewServer(logger, transport) +``` + +### Phase 5: Testing Updates (Week 6) + +1. Create mock transport: + +```go +// pkg/transport/testing/mock.go +type MockTransport struct { + handler RequestHandler + requests chan *Request + responses chan *Response +} + +func (m *MockTransport) Listen(ctx context.Context, handler RequestHandler) error { + m.handler = handler + // Implementation +} + +// Usage in tests +func TestClient(t *testing.T) { + mock := testing.NewMockTransport() + client := NewClient(logger, mock) + + // Test client with mock transport +} +``` + +2. Update existing tests: + +```go +// Old tests +func TestSSETransport(t *testing.T) { + transport := NewSSETransport(":0") + // ... +} + +// New tests +func TestSSETransport(t *testing.T) { + transport, err := sse.NewTransport( + transport.WithSSEOptions(sse.Options{ + Addr: ":0", + HeartbeatInterval: time.Second, + }), + ) + require.NoError(t, err) + // ... +} +``` + +### Phase 6: Documentation and Examples (Week 7-8) + +1. Update documentation: + - Add migration guide + - Document new interfaces + - Provide examples for each transport + - Update API documentation + +2. Create example applications: + - Basic client/server + - Custom transport implementation + - Testing with mock transport + +[Rest of the RFC remains unchanged...] \ No newline at end of file diff --git a/ttmp/2025-02-14/changelog.md b/ttmp/2025-02-14/changelog.md new file mode 100644 index 0000000..fd07fab --- /dev/null +++ b/ttmp/2025-02-14/changelog.md @@ -0,0 +1,11 @@ +# Clean Transport Layer RFC + +Added a detailed RFC for a new clean transport layer design. The RFC proposes a unified interface for transports, better error handling, and clearer separation of concerns. + +- Added core Transport and RequestHandler interfaces +- Defined transport options and configuration +- Provided detailed SSE and stdio implementations +- Added comprehensive error handling +- Included migration guide and testing strategy +- Added detailed migration plan with code examples +- Added transport-specific options for SSE and stdio \ No newline at end of file From 294b4da87cfe5ddd84e73640acd394c13d6ef98d Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Sun, 16 Feb 2025 10:34:32 -0500 Subject: [PATCH 02/10] :art: Use ToJsonSchema from glalzed now and the new command layers --- changelog.md | 18 +- cmd/go-go-mcp/cmds/schema.go | 2 +- cmd/go-go-mcp/cmds/server/tools.go | 4 +- cmd/go-go-mcp/main.go | 4 +- pkg/cmds/cmd.go | 186 ------------------ pkg/cmds/shell-tool-provider.go | 2 +- .../config-provider/tool-provider.go | 2 +- 7 files changed, 24 insertions(+), 194 deletions(-) diff --git a/changelog.md b/changelog.md index 88e40b6..1ed436c 100644 --- a/changelog.md +++ b/changelog.md @@ -1047,4 +1047,20 @@ Added server-side tool management commands for direct interaction with tool prov - Added `server tools list` command to list available tools directly from tool provider - Added `server tools call` command to call tools directly without starting the server -- Reused server layer for configuration consistency \ No newline at end of file +- Reused server layer for configuration consistency + +## Add Minimal Glazed Command Layer + +Added a minimal version of the Glazed command layer (NewGlazedMinimalCommandLayer) that contains just the most commonly used parameters: print-yaml, print-parsed-parameters, load-parameters-from-file, and print-schema. This provides a simpler interface for basic command configuration. + +- Added GlazedMinimalCommandSlug constant +- Added NewGlazedMinimalCommandLayer function + +## Enhanced Glazed Command Layer Handling + +Updated cobra command handling to support both full and minimal Glazed command layers: + +- Added support for GlazedMinimalCommandLayer in cobra command processing +- Unified handling of common flags (print-yaml, print-parsed-parameters, etc.) between both layers +- Maintained backward compatibility with full GlazedCommandLayer features +- Added placeholder for schema printing functionality \ No newline at end of file diff --git a/cmd/go-go-mcp/cmds/schema.go b/cmd/go-go-mcp/cmds/schema.go index 45584f4..6909f25 100644 --- a/cmd/go-go-mcp/cmds/schema.go +++ b/cmd/go-go-mcp/cmds/schema.go @@ -85,7 +85,7 @@ func (c *SchemaCommand) RunIntoWriter( } // Convert to JSON schema - schema, err := mcp_cmds.ToJsonSchema(shellCmd.Description()) + schema, err := shellCmd.Description().ToJsonSchema() if err != nil { return fmt.Errorf("could not convert to JSON schema: %w", err) } diff --git a/cmd/go-go-mcp/cmds/server/tools.go b/cmd/go-go-mcp/cmds/server/tools.go index 85ffccf..dffa7f0 100644 --- a/cmd/go-go-mcp/cmds/server/tools.go +++ b/cmd/go-go-mcp/cmds/server/tools.go @@ -228,13 +228,13 @@ func init() { listCmd, err := NewListToolsCommand() cobra.CheckErr(err) - cobraListCmd, err := cli.BuildCobraCommandFromGlazeCommand(listCmd, cli.WithSkipGlazedCommandLayer()) + cobraListCmd, err := cli.BuildCobraCommandFromGlazeCommand(listCmd) cobra.CheckErr(err) callCmd, err := NewCallToolCommand() cobra.CheckErr(err) - cobraCallCmd, err := cli.BuildCobraCommandFromWriterCommand(callCmd, cli.WithSkipGlazedCommandLayer()) + cobraCallCmd, err := cli.BuildCobraCommandFromWriterCommand(callCmd) cobra.CheckErr(err) ToolsCmd.AddCommand(cobraListCmd) diff --git a/cmd/go-go-mcp/main.go b/cmd/go-go-mcp/main.go index 5d7a6f7..f0ec6f9 100644 --- a/cmd/go-go-mcp/main.go +++ b/cmd/go-go-mcp/main.go @@ -85,14 +85,14 @@ func initRootCmd() (*help.HelpSystem, error) { // Create and add start command startCmd, err := mcp_cmds.NewStartCommand() cobra.CheckErr(err) - cobraStartCmd, err := cli.BuildCobraCommandFromBareCommand(startCmd, cli.WithSkipGlazedCommandLayer()) + cobraStartCmd, err := cli.BuildCobraCommandFromBareCommand(startCmd) cobra.CheckErr(err) rootCmd.AddCommand(cobraStartCmd) // Create and add schema command schemaCmd, err := mcp_cmds.NewSchemaCommand() cobra.CheckErr(err) - cobraSchemaCmd, err := cli.BuildCobraCommandFromWriterCommand(schemaCmd, cli.WithSkipGlazedCommandLayer()) + cobraSchemaCmd, err := cli.BuildCobraCommandFromWriterCommand(schemaCmd) cobra.CheckErr(err) rootCmd.AddCommand(cobraSchemaCmd) diff --git a/pkg/cmds/cmd.go b/pkg/cmds/cmd.go index 7d4217b..438f3c3 100644 --- a/pkg/cmds/cmd.go +++ b/pkg/cmds/cmd.go @@ -246,189 +246,3 @@ func LoadShellCommandFromYAML(data []byte) (*ShellCommand, error) { WithCaptureStderr(desc.CaptureStderr), ) } - -// JsonSchemaProperty represents a property in the JSON Schema -type JsonSchemaProperty struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Enum []string `json:"enum,omitempty"` - Default interface{} `json:"default,omitempty"` - Items *JsonSchemaProperty `json:"items,omitempty"` - Required bool `json:"-"` - Properties map[string]*JsonSchemaProperty `json:"properties,omitempty"` - AdditionalProperties *JsonSchemaProperty `json:"additionalProperties,omitempty"` -} - -// CommandJsonSchema represents the root JSON Schema for a command -type CommandJsonSchema struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Properties map[string]*JsonSchemaProperty `json:"properties"` - Required []string `json:"required,omitempty"` -} - -// parameterTypeToJsonSchema converts a parameter definition to a JSON schema property -func parameterTypeToJsonSchema(param *parameters.ParameterDefinition) (*JsonSchemaProperty, error) { - prop := &JsonSchemaProperty{ - Description: param.Help, - Required: param.Required, - } - - if param.Default != nil { - prop.Default = *param.Default - } - - switch param.Type { - // Basic types - case parameters.ParameterTypeString: - prop.Type = "string" - - case parameters.ParameterTypeInteger: - prop.Type = "integer" - - case parameters.ParameterTypeFloat: - prop.Type = "number" - - case parameters.ParameterTypeBool: - prop.Type = "boolean" - - case parameters.ParameterTypeDate: - prop.Type = "string" - // Add format for date strings - prop.Properties = map[string]*JsonSchemaProperty{ - "format": {Type: "string", Default: "date"}, - } - - // List types - case parameters.ParameterTypeStringList: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{Type: "string"} - - case parameters.ParameterTypeIntegerList: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{Type: "integer"} - - case parameters.ParameterTypeFloatList: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{Type: "number"} - - // Choice types - case parameters.ParameterTypeChoice: - prop.Type = "string" - prop.Enum = param.Choices - - case parameters.ParameterTypeChoiceList: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{Type: "string"} - prop.Items.Enum = param.Choices - - // File types - case parameters.ParameterTypeFile: - prop.Type = "object" - prop.Properties = map[string]*JsonSchemaProperty{ - "path": {Type: "string", Description: "Path to the file"}, - "content": {Type: "string", Description: "File content"}, - } - - case parameters.ParameterTypeFileList: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{ - Type: "object", - Properties: map[string]*JsonSchemaProperty{ - "path": {Type: "string", Description: "Path to the file"}, - "content": {Type: "string", Description: "File content"}, - }, - } - - // Key-value type - case parameters.ParameterTypeKeyValue: - prop.Type = "object" - prop.Properties = map[string]*JsonSchemaProperty{ - "key": {Type: "string"}, - "value": {Type: "string"}, - } - - // File-based parameter types - case parameters.ParameterTypeStringFromFile: - prop.Type = "string" - - case parameters.ParameterTypeStringFromFiles: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{Type: "string"} - - case parameters.ParameterTypeObjectFromFile: - prop.Type = "object" - prop.AdditionalProperties = &JsonSchemaProperty{Type: "string"} - - case parameters.ParameterTypeObjectListFromFile: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{ - Type: "object", - AdditionalProperties: &JsonSchemaProperty{Type: "string"}, - } - - case parameters.ParameterTypeObjectListFromFiles: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{ - Type: "object", - AdditionalProperties: &JsonSchemaProperty{Type: "string"}, - } - - case parameters.ParameterTypeStringListFromFile: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{Type: "string"} - - case parameters.ParameterTypeStringListFromFiles: - prop.Type = "array" - prop.Items = &JsonSchemaProperty{Type: "string"} - - default: - return nil, fmt.Errorf("unsupported parameter type: %s", param.Type) - } - - return prop, nil -} - -// ToJsonSchema converts a ShellCommand to a JSON Schema representation -func ToJsonSchema(desc *cmds.CommandDescription) (*CommandJsonSchema, error) { - schema := &CommandJsonSchema{ - Type: "object", - Description: fmt.Sprintf("%s\n\n%s", desc.Short, desc.Long), - Properties: make(map[string]*JsonSchemaProperty), - Required: []string{}, - } - - // Process flags - err := desc.GetDefaultFlags().ForEachE(func(flag *parameters.ParameterDefinition) error { - prop, err := parameterTypeToJsonSchema(flag) - if err != nil { - return fmt.Errorf("error processing flag %s: %w", flag.Name, err) - } - schema.Properties[flag.Name] = prop - if flag.Required { - schema.Required = append(schema.Required, flag.Name) - } - return nil - }) - if err != nil { - return nil, err - } - - // Process arguments - err = desc.GetDefaultArguments().ForEachE(func(arg *parameters.ParameterDefinition) error { - prop, err := parameterTypeToJsonSchema(arg) - if err != nil { - return fmt.Errorf("error processing argument %s: %w", arg.Name, err) - } - schema.Properties[arg.Name] = prop - if arg.Required { - schema.Required = append(schema.Required, arg.Name) - } - return nil - }) - if err != nil { - return nil, err - } - - return schema, nil -} diff --git a/pkg/cmds/shell-tool-provider.go b/pkg/cmds/shell-tool-provider.go index 0387b25..ae069be 100644 --- a/pkg/cmds/shell-tool-provider.go +++ b/pkg/cmds/shell-tool-provider.go @@ -67,7 +67,7 @@ func (p *ShellToolProvider) ListTools(cursor string) ([]protocol.Tool, string, e for _, cmd := range p.commands { desc := cmd.Description() - schema, err := ToJsonSchema(desc) + schema, err := desc.ToJsonSchema() if err != nil { return nil, "", errors.Wrap(err, "failed to generate JSON schema") } diff --git a/pkg/tools/providers/config-provider/tool-provider.go b/pkg/tools/providers/config-provider/tool-provider.go index beefac9..2bc253b 100644 --- a/pkg/tools/providers/config-provider/tool-provider.go +++ b/pkg/tools/providers/config-provider/tool-provider.go @@ -154,7 +154,7 @@ func NewConfigToolProvider(options ...ConfigToolProviderOption) (*ConfigToolProv } func ConvertCommandToTool(desc *cmds.CommandDescription) (protocol.Tool, error) { - schema_, err := mcp_cmds.ToJsonSchema(desc) + schema_, err := desc.ToJsonSchema() if err != nil { return protocol.Tool{}, errors.Wrapf(err, "failed to convert command to schema") } From 874715bd524552e10b701853707b029807ab59c0 Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Sun, 16 Feb 2025 11:23:04 -0500 Subject: [PATCH 03/10] :sparkles: :tractor: :fire: Big refactor of how the transport and registries work --- cmd/go-go-mcp/cmds/bridge.go | 4 +- cmd/go-go-mcp/cmds/server/server.go | 9 + cmd/go-go-mcp/cmds/{ => server}/start.go | 73 ++- cmd/go-go-mcp/cmds/server/tools.go | 2 +- cmd/go-go-mcp/main.go | 7 - pkg/cmds/shell-tool-provider.go | 185 ------- pkg/doc/topics/01-config-file.md | 4 +- pkg/doc/topics/03-mcp-in-practice.md | 8 +- pkg/prompts/registry.go | 5 +- pkg/protocol/prompts.go | 24 + pkg/providers.go | 14 +- pkg/server/dispatcher/dispatcher.go | 62 --- pkg/server/dispatcher/handlers.go | 216 --------- pkg/server/dispatcher/response.go | 66 --- pkg/server/handler.go | 246 ++++++++++ pkg/server/options.go | 20 +- pkg/server/server.go | 104 ++-- .../{transports/stdio => }/sse_bridge.go | 2 +- pkg/server/stdio.go | 85 ---- pkg/server/transports/sse/sse.go | 388 --------------- pkg/server/transports/stdio/stdio.go | 418 ---------------- pkg/services/defaults/initialize.go | 58 --- pkg/services/defaults/prompts.go | 50 -- pkg/services/defaults/resources.go | 50 -- pkg/services/defaults/tools.go | 50 -- pkg/services/interfaces.go | 30 -- pkg/tools/examples/cursor/code.go | 258 ---------- pkg/tools/examples/cursor/context.go | 5 - pkg/tools/examples/cursor/conversation.go | 455 ------------------ pkg/tools/examples/cursor/helpers.go | 34 -- pkg/tools/examples/cursor/register.go | 34 -- pkg/tools/examples/echo.go | 3 +- pkg/tools/examples/fetch.go | 3 +- pkg/tools/examples/sqlite.go | 3 +- .../config-provider/tool-provider.go | 2 +- .../{ => providers/tool-registry}/registry.go | 15 +- pkg/transport/errors.go | 64 +++ pkg/transport/options.go | 81 ++++ pkg/transport/sse/transport.go | 344 +++++++++++++ pkg/transport/stdio/transport.go | 284 +++++++++++ pkg/transport/transport.go | 73 +++ .../01-design-config-file-format.md | 2 +- ttmp/2025-02-14/01-rfc-transport-layer.md | 65 --- 43 files changed, 1242 insertions(+), 2663 deletions(-) rename cmd/go-go-mcp/cmds/{ => server}/start.go (65%) delete mode 100644 pkg/cmds/shell-tool-provider.go delete mode 100644 pkg/server/dispatcher/dispatcher.go delete mode 100644 pkg/server/dispatcher/handlers.go delete mode 100644 pkg/server/dispatcher/response.go create mode 100644 pkg/server/handler.go rename pkg/server/{transports/stdio => }/sse_bridge.go (99%) delete mode 100644 pkg/server/stdio.go delete mode 100644 pkg/server/transports/sse/sse.go delete mode 100644 pkg/server/transports/stdio/stdio.go delete mode 100644 pkg/services/defaults/initialize.go delete mode 100644 pkg/services/defaults/prompts.go delete mode 100644 pkg/services/defaults/resources.go delete mode 100644 pkg/services/defaults/tools.go delete mode 100644 pkg/services/interfaces.go delete mode 100644 pkg/tools/examples/cursor/code.go delete mode 100644 pkg/tools/examples/cursor/context.go delete mode 100644 pkg/tools/examples/cursor/conversation.go delete mode 100644 pkg/tools/examples/cursor/helpers.go delete mode 100644 pkg/tools/examples/cursor/register.go rename pkg/tools/{ => providers/tool-registry}/registry.go (80%) create mode 100644 pkg/transport/errors.go create mode 100644 pkg/transport/options.go create mode 100644 pkg/transport/sse/transport.go create mode 100644 pkg/transport/stdio/transport.go create mode 100644 pkg/transport/transport.go diff --git a/cmd/go-go-mcp/cmds/bridge.go b/cmd/go-go-mcp/cmds/bridge.go index 56b4991..e7f27f0 100644 --- a/cmd/go-go-mcp/cmds/bridge.go +++ b/cmd/go-go-mcp/cmds/bridge.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/go-go-golems/go-go-mcp/pkg/server/transports/stdio" + "github.com/go-go-golems/go-go-mcp/pkg/server" "github.com/rs/zerolog" "github.com/spf13/cobra" ) @@ -22,7 +22,7 @@ This is useful when you want to connect a stdio client to a remote SSE server.`, return fmt.Errorf("SSE URL is required") } - server := stdio.NewSSEBridgeServer(logger, sseURL) + server := server.NewSSEBridgeServer(logger, sseURL) return server.Start(context.Background()) }, } diff --git a/cmd/go-go-mcp/cmds/server/server.go b/cmd/go-go-mcp/cmds/server/server.go index 67c6127..f701151 100644 --- a/cmd/go-go-mcp/cmds/server/server.go +++ b/cmd/go-go-mcp/cmds/server/server.go @@ -1,6 +1,8 @@ package server import ( + "github.com/go-go-golems/glazed/pkg/cli" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -13,4 +15,11 @@ var ServerCmd = &cobra.Command{ func init() { ServerCmd.AddCommand(ToolsCmd) + startCmd, err := NewStartCommand() + if err != nil { + log.Fatal().Err(err).Msg("failed to create start command") + } + cobraStartCmd, err := cli.BuildCobraCommandFromBareCommand(startCmd) + cobra.CheckErr(err) + ServerCmd.AddCommand(cobraStartCmd) } diff --git a/cmd/go-go-mcp/cmds/start.go b/cmd/go-go-mcp/cmds/server/start.go similarity index 65% rename from cmd/go-go-mcp/cmds/start.go rename to cmd/go-go-mcp/cmds/server/start.go index b3844e4..e66b31e 100644 --- a/cmd/go-go-mcp/cmds/start.go +++ b/cmd/go-go-mcp/cmds/server/start.go @@ -1,4 +1,4 @@ -package cmds +package server import ( "context" @@ -11,10 +11,12 @@ import ( "github.com/go-go-golems/glazed/pkg/cmds" glazed_layers "github.com/go-go-golems/glazed/pkg/cmds/layers" - "github.com/go-go-golems/glazed/pkg/cmds/parameters" "github.com/go-go-golems/go-go-mcp/cmd/go-go-mcp/cmds/server/layers" "github.com/go-go-golems/go-go-mcp/pkg/server" + "github.com/go-go-golems/go-go-mcp/pkg/transport" + "github.com/go-go-golems/go-go-mcp/pkg/transport/sse" + "github.com/go-go-golems/go-go-mcp/pkg/transport/stdio" "github.com/pkg/errors" "github.com/rs/zerolog/log" "golang.org/x/sync/errgroup" @@ -67,25 +69,55 @@ func (c *StartCommand) Run( ctx context.Context, parsedLayers *glazed_layers.ParsedLayers, ) error { - s := &StartCommandSettings{} - if err := parsedLayers.InitializeStruct(glazed_layers.DefaultSlug, s); err != nil { + logger := log.Logger + + s_ := &StartCommandSettings{} + if err := parsedLayers.InitializeStruct(glazed_layers.DefaultSlug, s_); err != nil { return err } + // Get transport type from flags + transportType := s_.Transport + port := s_.Port + + // Create transport based on type + var t transport.Transport + var err error + + switch transportType { + case "sse": + t, err = sse.NewSSETransport( + transport.WithLogger(logger), + transport.WithSSEOptions(transport.SSEOptions{ + Addr: fmt.Sprintf(":%d", port), + }), + ) + case "stdio": + t, err = stdio.NewStdioTransport( + transport.WithLogger(logger), + ) + default: + return fmt.Errorf("unsupported transport type: %s", transportType) + } + + if err != nil { + return fmt.Errorf("failed to create transport: %w", err) + } + + // Get server settings serverSettings := &layers.ServerSettings{} if err := parsedLayers.InitializeStruct(layers.ServerLayerSlug, serverSettings); err != nil { return err } - // Create server - srv := server.NewServer(log.Logger) - + // Create tool provider toolProvider, err := layers.CreateToolProvider(serverSettings) if err != nil { return err } - srv.GetRegistry().RegisterToolProvider(toolProvider) + // Create and start server with transport + s := server.NewServer(logger, t, server.WithToolProvider(toolProvider)) // Create a context that will be cancelled on SIGINT/SIGTERM ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) @@ -96,7 +128,7 @@ func (c *StartCommand) Run( // Start file watcher g.Go(func() error { if err := toolProvider.Watch(gctx); err != nil { - log.Error().Err(err).Msg("failed to start file watcher") + logger.Error().Err(err).Msg("failed to start file watcher") return err } return nil @@ -104,19 +136,8 @@ func (c *StartCommand) Run( // Start server g.Go(func() error { - var err error - switch s.Transport { - case "stdio": - log.Info().Msg("Starting server with stdio transport") - err = srv.StartStdio(gctx) - case "sse": - log.Info().Int("port", s.Port).Msg("Starting server with SSE transport") - err = srv.StartSSE(gctx, s.Port) - default: - err = fmt.Errorf("invalid transport type: %s", s.Transport) - } - if err != nil && err != io.EOF { - log.Error().Err(err).Msg("Server error") + if err := s.Start(gctx); err != nil && err != io.EOF { + logger.Error().Err(err).Msg("Server error") return err } return nil @@ -125,14 +146,14 @@ func (c *StartCommand) Run( // Add graceful shutdown handler g.Go(func() error { <-gctx.Done() - log.Info().Msg("Initiating graceful shutdown") + logger.Info().Msg("Initiating graceful shutdown") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() - if err := srv.Stop(shutdownCtx); err != nil { - log.Error().Err(err).Msg("Error during shutdown") + if err := s.Stop(shutdownCtx); err != nil { + logger.Error().Err(err).Msg("Error during shutdown") return err } - log.Info().Msg("Server stopped gracefully") + logger.Info().Msg("Server stopped gracefully") return nil }) diff --git a/cmd/go-go-mcp/cmds/server/tools.go b/cmd/go-go-mcp/cmds/server/tools.go index dffa7f0..69bc1c7 100644 --- a/cmd/go-go-mcp/cmds/server/tools.go +++ b/cmd/go-go-mcp/cmds/server/tools.go @@ -128,7 +128,7 @@ func (c *ListToolsCommand) RunIntoGlazeProcessor( return err } - tools, cursor, err := toolProvider.ListTools("") + tools, cursor, err := toolProvider.ListTools(ctx, "") if err != nil { return err } diff --git a/cmd/go-go-mcp/main.go b/cmd/go-go-mcp/main.go index f0ec6f9..53f13a7 100644 --- a/cmd/go-go-mcp/main.go +++ b/cmd/go-go-mcp/main.go @@ -82,13 +82,6 @@ func initRootCmd() (*help.HelpSystem, error) { rootCmd.AddCommand(runCommandCmd) - // Create and add start command - startCmd, err := mcp_cmds.NewStartCommand() - cobra.CheckErr(err) - cobraStartCmd, err := cli.BuildCobraCommandFromBareCommand(startCmd) - cobra.CheckErr(err) - rootCmd.AddCommand(cobraStartCmd) - // Create and add schema command schemaCmd, err := mcp_cmds.NewSchemaCommand() cobra.CheckErr(err) diff --git a/pkg/cmds/shell-tool-provider.go b/pkg/cmds/shell-tool-provider.go deleted file mode 100644 index ae069be..0000000 --- a/pkg/cmds/shell-tool-provider.go +++ /dev/null @@ -1,185 +0,0 @@ -package cmds - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/go-go-golems/glazed/pkg/cmds" - "github.com/go-go-golems/glazed/pkg/cmds/layers" - "github.com/go-go-golems/glazed/pkg/cmds/middlewares" - "github.com/go-go-golems/glazed/pkg/cmds/parameters" - "github.com/go-go-golems/go-go-mcp/pkg" - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/pkg/errors" - "github.com/rs/zerolog/log" -) - -// ShellToolProvider is a ToolProvider that exposes shell commands as tools -type ShellToolProvider struct { - commands map[string]cmds.Command - debug bool - tracingDir string -} - -type ShellToolProviderOption func(*ShellToolProvider) - -func WithDebug(debug bool) ShellToolProviderOption { - return func(p *ShellToolProvider) { - p.debug = debug - } -} - -func WithTracingDir(dir string) ShellToolProviderOption { - return func(p *ShellToolProvider) { - p.tracingDir = dir - } -} - -var _ pkg.ToolProvider = &ShellToolProvider{} - -// NewShellToolProvider creates a new ShellToolProvider with the given commands -func NewShellToolProvider(commands []cmds.Command, options ...ShellToolProviderOption) (*ShellToolProvider, error) { - provider := &ShellToolProvider{ - commands: make(map[string]cmds.Command), - } - - for _, option := range options { - option(provider) - } - - for _, cmd := range commands { - if shellCmd, ok := cmd.(*ShellCommand); ok { - provider.commands[shellCmd.Description().Name] = shellCmd - } - } - - return provider, nil -} - -// ListTools returns a list of available tools -func (p *ShellToolProvider) ListTools(cursor string) ([]protocol.Tool, string, error) { - tools := make([]protocol.Tool, 0, len(p.commands)) - - for _, cmd := range p.commands { - desc := cmd.Description() - schema, err := desc.ToJsonSchema() - if err != nil { - return nil, "", errors.Wrap(err, "failed to generate JSON schema") - } - - schemaBytes, err := json.Marshal(schema) - if err != nil { - return nil, "", errors.Wrap(err, "failed to marshal JSON schema") - } - - tools = append(tools, protocol.Tool{ - Name: desc.Name, - Description: desc.Short + "\n\n" + desc.Long, - InputSchema: schemaBytes, - }) - } - - return tools, "", nil -} - -// CallTool invokes a specific tool with the given arguments -func (p *ShellToolProvider) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*protocol.ToolResult, error) { - if p.debug { - log.Debug(). - Str("name", name). - Interface("arguments", arguments). - Msg("calling tool with arguments") - } - - cmd, ok := p.commands[name] - if !ok { - return nil, fmt.Errorf("tool not found: %s", name) - } - - if p.tracingDir != "" { - timestamp := time.Now().Format("2006-01-02T15-04-05.000") - inputFile := filepath.Join(p.tracingDir, fmt.Sprintf("%s-%s-input.json", name, timestamp)) - if err := p.writeTraceFile(inputFile, arguments); err != nil { - log.Error().Err(err).Str("file", inputFile).Msg("failed to write input trace file") - } - } - - // Get parameter layers from command description - parameterLayers := cmd.Description().Layers - - // Create empty parsed layers - parsedLayers := layers.NewParsedLayers() - - // Create a map structure for the arguments - argsMap := map[string]map[string]interface{}{ - layers.DefaultSlug: arguments, - } - - // Execute middlewares in order - err := middlewares.ExecuteMiddlewares( - parameterLayers, - parsedLayers, - middlewares.SetFromDefaults(parameters.WithParseStepSource(parameters.SourceDefaults)), - middlewares.UpdateFromMap(argsMap), - ) - if err != nil { - return protocol.NewErrorToolResult(protocol.NewTextContent(err.Error())), nil - } - - // Create a buffer to capture the command output - buf := &strings.Builder{} - - // Run the command with parsed parameters - switch c := cmd.(type) { - case cmds.WriterCommand: - if err := c.RunIntoWriter(ctx, parsedLayers, buf); err != nil { - return protocol.NewErrorToolResult(protocol.NewTextContent(err.Error())), nil - } - case cmds.BareCommand: - panic("BareCommand not supported yet") - case cmds.GlazeCommand: - panic("GlazeCommand not supported yet") - default: - panic("Unknown command type") - } - - text := buf.String() - l := 62 * 1024 - if len(text) > l { - text = text[:l] - } - - result := protocol.NewToolResult(protocol.WithText(text)) - - if p.tracingDir != "" { - timestamp := time.Now().Format("2006-01-02T15-04-05.000") - outputFile := filepath.Join(p.tracingDir, fmt.Sprintf("%s-%s-output.json", name, timestamp)) - if err := p.writeTraceFile(outputFile, result); err != nil { - log.Error().Err(err).Str("file", outputFile).Msg("failed to write output trace file") - } - } - - return result, nil -} - -func (p *ShellToolProvider) writeTraceFile(filename string, data interface{}) error { - if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { - return fmt.Errorf("failed to create tracing directory: %w", err) - } - - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal data: %w", err) - } - - if err := os.WriteFile(filename, jsonData, 0644); err != nil { - return fmt.Errorf("failed to write trace file: %w", err) - } - - return nil -} diff --git a/pkg/doc/topics/01-config-file.md b/pkg/doc/topics/01-config-file.md index da0324f..d0cd671 100644 --- a/pkg/doc/topics/01-config-file.md +++ b/pkg/doc/topics/01-config-file.md @@ -86,7 +86,7 @@ profiles: Save this as `config.yaml` and run: ```bash -go-go-mcp start --config-file config.yaml +go-go-mcp server start --config-file config.yaml ``` This will: @@ -130,7 +130,7 @@ profiles: To use a specific profile: ```bash -go-go-mcp start --config-file config.yaml --profile production +go-go-mcp server start --config-file config.yaml --profile production ``` ## Tool Configuration diff --git a/pkg/doc/topics/03-mcp-in-practice.md b/pkg/doc/topics/03-mcp-in-practice.md index 80b8ee1..dde4f76 100644 --- a/pkg/doc/topics/03-mcp-in-practice.md +++ b/pkg/doc/topics/03-mcp-in-practice.md @@ -707,7 +707,7 @@ When changes are detected: Start the server with all available tools: ```bash -go-go-mcp start \ +go-go-mcp server start \ --config-file config.yaml \ --profile all \ --transport sse \ @@ -726,7 +726,7 @@ cp new-tool.yaml tools/system/ Start the server with just system monitoring tools: ```bash -go-go-mcp start \ +go-go-mcp server start \ --config-file config.yaml \ --profile system \ --transport sse \ @@ -786,7 +786,7 @@ This is particularly useful for: Start with data analysis tools: ```bash -go-go-mcp start \ +go-go-mcp server start \ --config-file config.yaml \ --profile data \ --transport sse \ @@ -798,7 +798,7 @@ go-go-mcp start \ Start with calendar management tools: ```bash -go-go-mcp start \ +go-go-mcp server start \ --config-file config.yaml \ --profile calendar \ --transport sse \ diff --git a/pkg/prompts/registry.go b/pkg/prompts/registry.go index c1bc916..c8542d3 100644 --- a/pkg/prompts/registry.go +++ b/pkg/prompts/registry.go @@ -1,6 +1,7 @@ package prompts import ( + "context" "fmt" "sort" "sync" @@ -54,7 +55,7 @@ func (r *Registry) UnregisterPrompt(name string) { } // ListPrompts implements PromptProvider interface -func (r *Registry) ListPrompts(cursor string) ([]protocol.Prompt, string, error) { +func (r *Registry) ListPrompts(_ context.Context, cursor string) ([]protocol.Prompt, string, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -93,7 +94,7 @@ func (r *Registry) ListPrompts(cursor string) ([]protocol.Prompt, string, error) } // GetPrompt implements PromptProvider interface -func (r *Registry) GetPrompt(name string, arguments map[string]string) (*protocol.PromptMessage, error) { +func (r *Registry) GetPrompt(_ context.Context, name string, arguments map[string]string) (*protocol.PromptMessage, error) { r.mu.RLock() defer r.mu.RUnlock() diff --git a/pkg/protocol/prompts.go b/pkg/protocol/prompts.go index e8c3669..127ad92 100644 --- a/pkg/protocol/prompts.go +++ b/pkg/protocol/prompts.go @@ -28,3 +28,27 @@ type PromptContent struct { MimeType string `json:"mimeType,omitempty"` Resource *ResourceContent `json:"resource,omitempty"` // For resource content } + +type ListPromptsResult struct { + Prompts []Prompt `json:"prompts"` + NextCursor string `json:"nextCursor"` +} + +type ListResourcesResult struct { + Resources []Resource `json:"resources"` + NextCursor string `json:"nextCursor"` +} + +type ListToolsResult struct { + Tools []Tool `json:"tools"` + NextCursor string `json:"nextCursor"` +} + +type PromptResult struct { + Description string `json:"description"` + Messages []PromptMessage `json:"messages"` +} + +type ResourceResult struct { + Contents []ResourceContent `json:"contents"` +} diff --git a/pkg/providers.go b/pkg/providers.go index dd3d72f..a5cc6fd 100644 --- a/pkg/providers.go +++ b/pkg/providers.go @@ -9,32 +9,32 @@ import ( // PromptProvider defines the interface for serving prompts type PromptProvider interface { // ListPrompts returns a list of available prompts with optional pagination - ListPrompts(cursor string) ([]protocol.Prompt, string, error) + ListPrompts(ctx context.Context, cursor string) ([]protocol.Prompt, string, error) // GetPrompt retrieves a specific prompt with the given arguments - GetPrompt(name string, arguments map[string]string) (*protocol.PromptMessage, error) + GetPrompt(ctx context.Context, name string, arguments map[string]string) (*protocol.PromptMessage, error) } // ResourceProvider defines the interface for serving resources type ResourceProvider interface { // ListResources returns a list of available resources with optional pagination - ListResources(cursor string) ([]protocol.Resource, string, error) + ListResources(ctx context.Context, cursor string) ([]protocol.Resource, string, error) // ReadResource retrieves the contents of a specific resource - ReadResource(uri string) (*protocol.ResourceContent, error) + ReadResource(ctx context.Context, uri string) ([]protocol.ResourceContent, error) // ListResourceTemplates returns a list of available resource templates - ListResourceTemplates() ([]protocol.ResourceTemplate, error) + ListResourceTemplates(ctx context.Context) ([]protocol.ResourceTemplate, error) // SubscribeToResource registers for notifications about resource changes // Returns a channel that will receive notifications and a cleanup function - SubscribeToResource(uri string) (chan struct{}, func(), error) + SubscribeToResource(ctx context.Context, uri string) (chan struct{}, func(), error) } // ToolProvider defines the interface for serving tools type ToolProvider interface { // ListTools returns a list of available tools with optional pagination - ListTools(cursor string) ([]protocol.Tool, string, error) + ListTools(ctx context.Context, cursor string) ([]protocol.Tool, string, error) // CallTool invokes a specific tool with the given arguments CallTool(ctx context.Context, name string, arguments map[string]interface{}) (*protocol.ToolResult, error) diff --git a/pkg/server/dispatcher/dispatcher.go b/pkg/server/dispatcher/dispatcher.go deleted file mode 100644 index 09530e2..0000000 --- a/pkg/server/dispatcher/dispatcher.go +++ /dev/null @@ -1,62 +0,0 @@ -package dispatcher - -import ( - "context" - - "github.com/go-go-golems/go-go-mcp/pkg/services" - "github.com/rs/zerolog" -) - -// Dispatcher handles the MCP protocol methods and dispatches them to appropriate services -type Dispatcher struct { - logger zerolog.Logger - promptService services.PromptService - resourceService services.ResourceService - toolService services.ToolService - initializeService services.InitializeService -} - -// NewDispatcher creates a new dispatcher instance -func NewDispatcher( - logger zerolog.Logger, - ps services.PromptService, - rs services.ResourceService, - ts services.ToolService, - is services.InitializeService, -) *Dispatcher { - return &Dispatcher{ - logger: logger, - promptService: ps, - resourceService: rs, - toolService: ts, - initializeService: is, - } -} - -// contextKey is a custom type for context keys to avoid collisions -type contextKey struct{} - -var ( - // sessionIDKey is the key used to store the session ID in context - sessionIDKey = contextKey{} -) - -// GetSessionID retrieves the session ID from the context -func GetSessionID(ctx context.Context) (string, bool) { - sessionID, ok := ctx.Value(sessionIDKey).(string) - return sessionID, ok -} - -// MustGetSessionID retrieves the session ID from the context, panicking if not found -func MustGetSessionID(ctx context.Context) string { - sessionID, ok := GetSessionID(ctx) - if !ok { - panic("sessionId not found in context") - } - return sessionID -} - -// WithSessionID adds a session ID to the context -func WithSessionID(ctx context.Context, sessionID string) context.Context { - return context.WithValue(ctx, sessionIDKey, sessionID) -} diff --git a/pkg/server/dispatcher/handlers.go b/pkg/server/dispatcher/handlers.go deleted file mode 100644 index 53f6cf9..0000000 --- a/pkg/server/dispatcher/handlers.go +++ /dev/null @@ -1,216 +0,0 @@ -package dispatcher - -import ( - "context" - "encoding/json" - "strings" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" -) - -// Dispatch handles an incoming request and returns a response -func (d *Dispatcher) Dispatch(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - l := d.logger.Debug(). - Str("method", request.Method) - if len(request.Params) > 0 { - l = l.RawJSON("params", request.Params) - } - l.Msg("Dispatching request") - - // Validate JSON-RPC version - if request.JSONRPC != "2.0" { - return NewErrorResponse(request.ID, -32600, "Invalid Request", "invalid JSON-RPC version") - } - - // Handle requests vs notifications based on ID presence - if len(request.ID) == 0 || string(request.ID) == "null" { - return d.handleNotification(ctx, request) - } - - if strings.HasPrefix(request.Method, "notifications/") { - return d.handleNotification(ctx, request) - } - - switch request.Method { - case "initialize": - return d.handleInitialize(ctx, request) - case "ping": - return d.handlePing(ctx, request) - case "prompts/list": - return d.handlePromptsList(ctx, request) - case "prompts/get": - return d.handlePromptsGet(ctx, request) - case "resources/list": - return d.handleResourcesList(ctx, request) - case "resources/read": - return d.handleResourcesRead(ctx, request) - case "tools/list": - return d.handleToolsList(ctx, request) - case "tools/call": - return d.handleToolsCall(ctx, request) - default: - return NewErrorResponse(request.ID, -32601, "Method not found", nil) - } -} - -func (d *Dispatcher) handleNotification(_ context.Context, request protocol.Request) (*protocol.Response, error) { - switch request.Method { - case "notifications/initialized": - d.logger.Info().Msg("Client initialized") - return nil, nil - default: - d.logger.Warn().Str("method", request.Method).Msg("Unknown notification method") - return nil, nil - } -} - -func (d *Dispatcher) handleInitialize(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - var params protocol.InitializeParams - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return NewErrorResponse(request.ID, -32602, "Invalid params", err) - } - - result, err := d.initializeService.Initialize(ctx, params) - if err != nil { - return NewErrorResponse(request.ID, -32603, "Initialize failed", err) - } - - return NewSuccessResponse(request.ID, result) -} - -func (d *Dispatcher) handlePing(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - return NewSuccessResponse(request.ID, struct{}{}) -} - -func (d *Dispatcher) handlePromptsList(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - var cursor string - if request.Params != nil { - var params struct { - Cursor string `json:"cursor"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return NewErrorResponse(request.ID, -32602, "Invalid params", err) - } - cursor = params.Cursor - } - - prompts, nextCursor, err := d.promptService.ListPrompts(ctx, cursor) - if err != nil { - return NewErrorResponse(request.ID, -32603, "Internal error", err) - } - if prompts == nil { - prompts = []protocol.Prompt{} - } - - return NewSuccessResponse(request.ID, ListPromptsResult{ - Prompts: prompts, - NextCursor: nextCursor, - }) -} - -func (d *Dispatcher) handlePromptsGet(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - var params struct { - Name string `json:"name"` - Arguments map[string]string `json:"arguments"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return NewErrorResponse(request.ID, -32602, "Invalid params", err) - } - - message, err := d.promptService.GetPrompt(ctx, params.Name, params.Arguments) - if err != nil { - return NewErrorResponse(request.ID, -32602, "Prompt not found", err) - } - - return NewSuccessResponse(request.ID, PromptResult{ - Description: "Prompt from provider", - Messages: []protocol.PromptMessage{*message}, - }) -} - -func (d *Dispatcher) handleResourcesList(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - var cursor string - if request.Params != nil { - var params struct { - Cursor string `json:"cursor"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return NewErrorResponse(request.ID, -32602, "Invalid params", err) - } - cursor = params.Cursor - } - - resources, nextCursor, err := d.resourceService.ListResources(ctx, cursor) - if err != nil { - return NewErrorResponse(request.ID, -32603, "Internal error", err) - } - if resources == nil { - resources = []protocol.Resource{} - } - - return NewSuccessResponse(request.ID, ListResourcesResult{ - Resources: resources, - NextCursor: nextCursor, - }) -} - -func (d *Dispatcher) handleResourcesRead(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - var params struct { - URI string `json:"uri"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return NewErrorResponse(request.ID, -32602, "Invalid params", err) - } - - content, err := d.resourceService.ReadResource(ctx, params.URI) - if err != nil { - return NewErrorResponse(request.ID, -32002, "Resource not found", err) - } - - return NewSuccessResponse(request.ID, ResourceResult{ - Contents: []protocol.ResourceContent{*content}, - }) -} - -func (d *Dispatcher) handleToolsList(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - var cursor string - if request.Params != nil { - var params struct { - Cursor string `json:"cursor"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return NewErrorResponse(request.ID, -32602, "Invalid params", err) - } - cursor = params.Cursor - } - - tools, nextCursor, err := d.toolService.ListTools(ctx, cursor) - if err != nil { - return NewErrorResponse(request.ID, -32603, "Internal error", err) - } - if tools == nil { - tools = []protocol.Tool{} - } - - return NewSuccessResponse(request.ID, ListToolsResult{ - Tools: tools, - NextCursor: nextCursor, - }) -} - -func (d *Dispatcher) handleToolsCall(ctx context.Context, request protocol.Request) (*protocol.Response, error) { - var params struct { - Name string `json:"name"` - Arguments map[string]interface{} `json:"arguments"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return NewErrorResponse(request.ID, -32602, "Invalid params", err) - } - - result, err := d.toolService.CallTool(ctx, params.Name, params.Arguments) - if err != nil { - return NewErrorResponse(request.ID, -32602, "Tool not found", err) - } - - return NewSuccessResponse(request.ID, result) -} diff --git a/pkg/server/dispatcher/response.go b/pkg/server/dispatcher/response.go deleted file mode 100644 index e46d15c..0000000 --- a/pkg/server/dispatcher/response.go +++ /dev/null @@ -1,66 +0,0 @@ -package dispatcher - -import ( - "encoding/json" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" -) - -// NewSuccessResponse creates a new success response with the given result -func NewSuccessResponse(id json.RawMessage, result interface{}) (*protocol.Response, error) { - resultJSON, err := json.Marshal(result) - if err != nil { - return NewErrorResponse(id, -32603, "Internal error", err) - } - - return &protocol.Response{ - JSONRPC: "2.0", - ID: id, - Result: resultJSON, - }, nil -} - -// NewErrorResponse creates a new error response -func NewErrorResponse(id json.RawMessage, code int, message string, data interface{}) (*protocol.Response, error) { - var errorData json.RawMessage - if data != nil { - if jsonData, err := json.Marshal(data); err == nil { - errorData = jsonData - } - } - - return &protocol.Response{ - JSONRPC: "2.0", - ID: id, - Error: &protocol.Error{ - Code: code, - Message: message, - Data: errorData, - }, - }, nil -} - -// Common result types -type ListPromptsResult struct { - Prompts []protocol.Prompt `json:"prompts"` - NextCursor string `json:"nextCursor"` -} - -type ListResourcesResult struct { - Resources []protocol.Resource `json:"resources"` - NextCursor string `json:"nextCursor"` -} - -type ListToolsResult struct { - Tools []protocol.Tool `json:"tools"` - NextCursor string `json:"nextCursor"` -} - -type PromptResult struct { - Description string `json:"description"` - Messages []protocol.PromptMessage `json:"messages"` -} - -type ResourceResult struct { - Contents []protocol.ResourceContent `json:"contents"` -} diff --git a/pkg/server/handler.go b/pkg/server/handler.go new file mode 100644 index 0000000..785918a --- /dev/null +++ b/pkg/server/handler.go @@ -0,0 +1,246 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/go-go-golems/go-go-mcp/pkg/protocol" + "github.com/go-go-golems/go-go-mcp/pkg/transport" +) + +// RequestHandler handles incoming MCP protocol requests +type RequestHandler struct { + server *Server +} + +func NewRequestHandler(s *Server) *RequestHandler { + return &RequestHandler{ + server: s, + } +} + +// HandleRequest processes a request and returns a response +func (h *RequestHandler) HandleRequest(ctx context.Context, req *transport.Request) (*transport.Response, error) { + // Validate JSON-RPC version + if req.Headers["jsonrpc"] != "2.0" { + return nil, transport.NewInvalidRequestError("invalid JSON-RPC version") + } + + switch req.Method { + case "initialize": + return h.handleInitialize(ctx, req) + case "ping": + return h.handlePing(ctx, req) + case "prompts/list": + return h.handlePromptsList(ctx, req) + case "prompts/get": + return h.handlePromptsGet(ctx, req) + case "resources/list": + return h.handleResourcesList(ctx, req) + case "resources/read": + return h.handleResourcesRead(ctx, req) + case "tools/list": + return h.handleToolsList(ctx, req) + case "tools/call": + return h.handleToolsCall(ctx, req) + default: + return nil, transport.NewMethodNotFoundError(fmt.Sprintf("method %s not found", req.Method)) + } +} + +// HandleNotification processes a notification (no response expected) +func (h *RequestHandler) HandleNotification(ctx context.Context, notif *transport.Notification) error { + switch notif.Method { + case "notifications/initialized": + h.server.logger.Info().Msg("Client initialized") + return nil + default: + h.server.logger.Warn().Str("method", notif.Method).Msg("Unknown notification method") + return nil + } +} + +// Helper method to create success response +func (h *RequestHandler) newSuccessResponse(id string, result interface{}) (*transport.Response, error) { + resultJSON, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + return &transport.Response{ + ID: id, + Result: resultJSON, + }, nil +} + +// Individual request handlers +func (h *RequestHandler) handleInitialize(ctx context.Context, req *transport.Request) (*transport.Response, error) { + var params protocol.InitializeParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + + // Validate protocol version + supportedVersions := []string{"2024-11-05"} + isSupported := false + for _, version := range supportedVersions { + if params.ProtocolVersion == version { + isSupported = true + break + } + } + + if !isSupported { + return nil, fmt.Errorf("unsupported protocol version %s, supported versions: %v", params.ProtocolVersion, supportedVersions) + } + + // Return server capabilities + result := protocol.InitializeResult{ + ProtocolVersion: params.ProtocolVersion, + Capabilities: protocol.ServerCapabilities{ + Logging: &protocol.LoggingCapability{}, + Prompts: &protocol.PromptsCapability{ + ListChanged: true, + }, + Resources: &protocol.ResourcesCapability{ + Subscribe: true, + ListChanged: true, + }, + Tools: &protocol.ToolsCapability{ + ListChanged: true, + }, + }, + ServerInfo: protocol.ServerInfo{ + Name: h.server.serverName, + Version: h.server.serverVersion, + }, + } + + return h.newSuccessResponse(req.ID, result) +} + +func (h *RequestHandler) handlePing(_ context.Context, req *transport.Request) (*transport.Response, error) { + return h.newSuccessResponse(req.ID, struct{}{}) +} + +func (h *RequestHandler) handlePromptsList(ctx context.Context, req *transport.Request) (*transport.Response, error) { + var params struct { + Cursor string `json:"cursor"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + + prompts, nextCursor, err := h.server.promptProvider.ListPrompts(ctx, params.Cursor) + if err != nil { + return nil, transport.NewInternalError(err.Error()) + } + + if prompts == nil { + prompts = []protocol.Prompt{} + } + + return h.newSuccessResponse(req.ID, protocol.ListPromptsResult{ + Prompts: prompts, + NextCursor: nextCursor, + }) +} + +func (h *RequestHandler) handlePromptsGet(ctx context.Context, req *transport.Request) (*transport.Response, error) { + var params struct { + Name string `json:"name"` + Arguments map[string]string `json:"arguments"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + + prompt, err := h.server.promptProvider.GetPrompt(ctx, params.Name, params.Arguments) + if err != nil { + return nil, transport.NewInternalError(err.Error()) + } + + return h.newSuccessResponse(req.ID, prompt) +} + +func (h *RequestHandler) handleResourcesList(ctx context.Context, req *transport.Request) (*transport.Response, error) { + var params struct { + Cursor string `json:"cursor"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + + resources, nextCursor, err := h.server.resourceProvider.ListResources(ctx, params.Cursor) + if err != nil { + return nil, transport.NewInternalError(err.Error()) + } + + if resources == nil { + resources = []protocol.Resource{} + } + + return h.newSuccessResponse(req.ID, protocol.ListResourcesResult{ + Resources: resources, + NextCursor: nextCursor, + }) +} + +func (h *RequestHandler) handleResourcesRead(ctx context.Context, req *transport.Request) (*transport.Response, error) { + var params struct { + Name string `json:"name"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + + contents, err := h.server.resourceProvider.ReadResource(ctx, params.Name) + if err != nil { + return nil, transport.NewInternalError(err.Error()) + } + + return h.newSuccessResponse(req.ID, protocol.ResourceResult{ + Contents: contents, + }) +} + +func (h *RequestHandler) handleToolsList(ctx context.Context, req *transport.Request) (*transport.Response, error) { + var params struct { + Cursor string `json:"cursor"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + + tools, nextCursor, err := h.server.toolProvider.ListTools(ctx, params.Cursor) + if err != nil { + return nil, transport.NewInternalError(err.Error()) + } + + if tools == nil { + tools = []protocol.Tool{} + } + + return h.newSuccessResponse(req.ID, protocol.ListToolsResult{ + Tools: tools, + NextCursor: nextCursor, + }) +} + +func (h *RequestHandler) handleToolsCall(ctx context.Context, req *transport.Request) (*transport.Response, error) { + var params struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + + result, err := h.server.toolProvider.CallTool(ctx, params.Name, params.Arguments) + if err != nil { + return nil, transport.NewInternalError(err.Error()) + } + + return h.newSuccessResponse(req.ID, result) +} diff --git a/pkg/server/options.go b/pkg/server/options.go index ce99791..7ee69b7 100644 --- a/pkg/server/options.go +++ b/pkg/server/options.go @@ -1,32 +1,26 @@ package server import ( - "github.com/go-go-golems/go-go-mcp/pkg/services" + "github.com/go-go-golems/go-go-mcp/pkg" ) type ServerOption func(*Server) -func WithPromptService(ps services.PromptService) ServerOption { +func WithPromptProvider(pp pkg.PromptProvider) ServerOption { return func(s *Server) { - s.promptService = ps + s.promptProvider = pp } } -func WithResourceService(rs services.ResourceService) ServerOption { +func WithResourceProvider(rp pkg.ResourceProvider) ServerOption { return func(s *Server) { - s.resourceService = rs + s.resourceProvider = rp } } -func WithToolService(ts services.ToolService) ServerOption { +func WithToolProvider(tp pkg.ToolProvider) ServerOption { return func(s *Server) { - s.toolService = ts - } -} - -func WithInitializeService(is services.InitializeService) ServerOption { - return func(s *Server) { - s.initializeService = is + s.toolProvider = tp } } diff --git a/pkg/server/server.go b/pkg/server/server.go index dc3aae0..782e511 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -6,100 +6,50 @@ import ( "sync" "github.com/go-go-golems/go-go-mcp/pkg" - "github.com/go-go-golems/go-go-mcp/pkg/server/transports/sse" - "github.com/go-go-golems/go-go-mcp/pkg/server/transports/stdio" - "github.com/go-go-golems/go-go-mcp/pkg/services" - "github.com/go-go-golems/go-go-mcp/pkg/services/defaults" + "github.com/go-go-golems/go-go-mcp/pkg/transport" "github.com/rs/zerolog" ) -// Transport represents a server transport mechanism -type Transport interface { - // Start starts the transport with the given context - Start(ctx context.Context) error - // Stop gracefully stops the transport with the given context - Stop(ctx context.Context) error -} - // Server represents an MCP server that can use different transports type Server struct { - mu sync.Mutex - logger zerolog.Logger - registry *pkg.ProviderRegistry - promptService services.PromptService - resourceService services.ResourceService - toolService services.ToolService - initializeService services.InitializeService - serverName string - serverVersion string - transport Transport + mu sync.Mutex + logger zerolog.Logger + transport transport.Transport + promptProvider pkg.PromptProvider + resourceProvider pkg.ResourceProvider + toolProvider pkg.ToolProvider + handler *RequestHandler + + serverName string + serverVersion string } // NewServer creates a new server instance -func NewServer(logger zerolog.Logger, options ...ServerOption) *Server { - registry := pkg.NewProviderRegistry() +func NewServer(logger zerolog.Logger, t transport.Transport, opts ...ServerOption) *Server { s := &Server{ - logger: logger, - registry: registry, - serverName: "go-mcp-server", - serverVersion: "1.0.0", - promptService: defaults.NewPromptService(registry, logger), - resourceService: defaults.NewResourceService(registry, logger), - toolService: defaults.NewToolService(registry, logger), - initializeService: defaults.NewInitializeService("go-mcp-server", "1.0.0"), + logger: logger, + transport: t, } - for _, opt := range options { + // Apply options + for _, opt := range opts { opt(s) } - return s -} -// GetRegistry returns the server's provider registry -func (s *Server) GetRegistry() *pkg.ProviderRegistry { - return s.registry -} - -// StartStdio starts the server with stdio transport -func (s *Server) StartStdio(ctx context.Context) error { - s.mu.Lock() - s.logger.Debug().Msg("Creating stdio transport") - // Create a new logger for the stdio server that preserves the log level and other settings - stdioLogger := s.logger.With().Logger() - stdioServer := stdio.NewServer(stdioLogger, s.promptService, s.resourceService, s.toolService, s.initializeService) - s.transport = stdioServer - s.mu.Unlock() + // Create request handler + s.handler = NewRequestHandler(s) - s.logger.Debug().Msg("Starting stdio transport") - err := stdioServer.Start(ctx) - if err != nil { - s.logger.Debug(). - Err(err). - Msg("Stdio transport stopped with error") - return err - } - s.logger.Debug().Msg("Stdio transport stopped cleanly") - return nil + return s } -// StartSSE starts the server with SSE transport on the specified port -func (s *Server) StartSSE(ctx context.Context, port int) error { - s.mu.Lock() - s.logger.Debug().Int("port", port).Msg("Creating SSE transport") - sseServer := sse.NewSSEServer(s.logger, s.promptService, s.resourceService, s.toolService, s.initializeService, port) - s.transport = sseServer - s.mu.Unlock() +// Start begins the server with the configured transport +func (s *Server) Start(ctx context.Context) error { + s.logger.Info(). + Str("transport", s.transport.Info().Type). + Interface("capabilities", s.transport.Info().Capabilities). + Msg("Starting MCP server") - s.logger.Debug().Int("port", port).Msg("Starting SSE transport") - err := sseServer.Start(ctx) - if err != nil { - s.logger.Debug(). - Err(err). - Msg("SSE transport stopped with error") - return err - } - s.logger.Debug().Msg("SSE transport stopped cleanly") - return nil + return s.transport.Listen(ctx, s.handler) } // Stop gracefully stops the server @@ -113,7 +63,7 @@ func (s *Server) Stop(ctx context.Context) error { } s.logger.Info().Msg("Stopping server transport") - err := s.transport.Stop(ctx) + err := s.transport.Close(ctx) if err != nil { s.logger.Error(). Err(err). diff --git a/pkg/server/transports/stdio/sse_bridge.go b/pkg/server/sse_bridge.go similarity index 99% rename from pkg/server/transports/stdio/sse_bridge.go rename to pkg/server/sse_bridge.go index 9071075..1785d20 100644 --- a/pkg/server/transports/stdio/sse_bridge.go +++ b/pkg/server/sse_bridge.go @@ -1,4 +1,4 @@ -package stdio +package server import ( "bufio" diff --git a/pkg/server/stdio.go b/pkg/server/stdio.go deleted file mode 100644 index 1221300..0000000 --- a/pkg/server/stdio.go +++ /dev/null @@ -1,85 +0,0 @@ -package server - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "os" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/go-go-golems/go-go-mcp/pkg/server/dispatcher" - "github.com/go-go-golems/go-go-mcp/pkg/services" - "github.com/rs/zerolog" -) - -// StdioServer handles stdio transport for MCP protocol -type StdioServer struct { - scanner *bufio.Scanner - writer *json.Encoder - logger zerolog.Logger - dispatcher *dispatcher.Dispatcher -} - -// NewStdioServer creates a new stdio server instance -func NewStdioServer(logger zerolog.Logger, ps services.PromptService, rs services.ResourceService, ts services.ToolService, is services.InitializeService) *StdioServer { - scanner := bufio.NewScanner(os.Stdin) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer - - return &StdioServer{ - scanner: scanner, - writer: json.NewEncoder(os.Stdout), - logger: logger, - dispatcher: dispatcher.NewDispatcher(logger, ps, rs, ts, is), - } -} - -// Start begins listening for and handling messages on stdio -func (s *StdioServer) Start() error { - s.logger.Info().Msg("Starting stdio server...") - - // Process messages until stdin is closed - for s.scanner.Scan() { - line := s.scanner.Text() - s.logger.Debug().Str("line", line).Msg("Received line") - if err := s.handleMessage(line); err != nil { - s.logger.Error().Err(err).Msg("Error handling message") - // Continue processing messages even if one fails - } - } - - if err := s.scanner.Err(); err != nil { - return fmt.Errorf("scanner error: %w", err) - } - - return io.EOF -} - -// handleMessage processes a single message -func (s *StdioServer) handleMessage(message string) error { - s.logger.Debug().Str("message", message).Msg("Received message") - - // Parse the base message structure - var request protocol.Request - if err := json.Unmarshal([]byte(message), &request); err != nil { - response, _ := dispatcher.NewErrorResponse(nil, -32700, "Parse error", err) - return s.writer.Encode(response) - } - - // Use the dispatcher to handle the request - ctx := context.Background() - response, err := s.dispatcher.Dispatch(ctx, request) - if err != nil { - s.logger.Error().Err(err).Msg("Error dispatching request") - response, _ = dispatcher.NewErrorResponse(request.ID, -32603, "Internal error", err) - } - - // Send response if it's not nil (notifications don't have responses) - if response != nil { - s.logger.Debug().Interface("response", response).Msg("Sending response") - return s.writer.Encode(response) - } - - return nil -} diff --git a/pkg/server/transports/sse/sse.go b/pkg/server/transports/sse/sse.go deleted file mode 100644 index 1275d03..0000000 --- a/pkg/server/transports/sse/sse.go +++ /dev/null @@ -1,388 +0,0 @@ -package sse - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "sync" - "time" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/go-go-golems/go-go-mcp/pkg/server/dispatcher" - "github.com/go-go-golems/go-go-mcp/pkg/services" - "github.com/google/uuid" - "github.com/gorilla/mux" - "github.com/rs/zerolog" -) - -// SSEServer handles SSE transport for MCP protocol -type SSEServer struct { - mu sync.RWMutex - logger zerolog.Logger - clients map[string]*SSEClient - server *http.Server - port int - dispatcher *dispatcher.Dispatcher - nextClientID int - wg sync.WaitGroup - cancel context.CancelFunc -} - -type SSEClient struct { - id string - sessionID string - messageChan chan *protocol.Response - createdAt time.Time - remoteAddr string - userAgent string -} - -// contextKey is a custom type for context keys to avoid collisions -type contextKey struct{} - -var ( - // sessionIDKey is the key used to store the session ID in context - sessionIDKey = contextKey{} -) - -// GetSessionID retrieves the session ID from the context -func GetSessionID(ctx context.Context) (string, bool) { - sessionID, ok := ctx.Value(sessionIDKey).(string) - return sessionID, ok -} - -// MustGetSessionID retrieves the session ID from the context, panicking if not found -func MustGetSessionID(ctx context.Context) string { - sessionID, ok := GetSessionID(ctx) - if !ok { - panic("sessionId not found in context") - } - return sessionID -} - -// NewSSEServer creates a new SSE server instance -func NewSSEServer(logger zerolog.Logger, ps services.PromptService, rs services.ResourceService, ts services.ToolService, is services.InitializeService, port int) *SSEServer { - return &SSEServer{ - logger: logger, - clients: make(map[string]*SSEClient), - port: port, - dispatcher: dispatcher.NewDispatcher(logger, ps, rs, ts, is), - } -} - -// Start begins the SSE server -func (s *SSEServer) Start(ctx context.Context) error { - ctx, cancel := context.WithCancel(ctx) - s.cancel = cancel - - r := mux.NewRouter() - - // SSE endpoint for clients to establish connection - r.HandleFunc("/sse", s.handleSSE).Methods("GET") - - // POST endpoint for receiving client messages - r.HandleFunc("/messages", s.handleMessages).Methods("POST", "OPTIONS") - - s.server = &http.Server{ - Addr: fmt.Sprintf(":%d", s.port), - Handler: r, - BaseContext: func(l net.Listener) context.Context { - return ctx - }, - } - - // Create a channel to capture server errors - errChan := make(chan error, 1) - go func() { - s.logger.Info().Int("port", s.port).Msg("Starting SSE server") - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - errChan <- err - } - close(errChan) - }() - - // Wait for context cancellation or server error - select { - case err := <-errChan: - return err - case <-ctx.Done(): - return s.Stop(context.Background()) - } -} - -// Stop gracefully stops the SSE server -func (s *SSEServer) Stop(ctx context.Context) error { - s.mu.Lock() - - if s.server != nil { - s.logger.Info().Msg("Stopping SSE server") - - // Cancel all client goroutines - if s.cancel != nil { - s.cancel() - } - - // Close all client connections - for sessionID, client := range s.clients { - s.logger.Debug().Str("sessionId", sessionID).Msg("Closing client connection") - close(client.messageChan) - delete(s.clients, sessionID) - } - - s.mu.Unlock() - - // Wait for all client goroutines to finish with a timeout - done := make(chan struct{}) - go func() { - s.wg.Wait() - close(done) - }() - - select { - case <-done: - s.logger.Debug().Msg("All client goroutines finished") - case <-ctx.Done(): - s.logger.Warn().Msg("Timeout waiting for client goroutines") - } - - // Shutdown the HTTP server - if err := s.server.Shutdown(ctx); err != nil { - return fmt.Errorf("error shutting down server: %w", err) - } - - return nil - } - - s.mu.Unlock() - return nil -} - -// marshalJSON marshals data to JSON and returns any error -func (s *SSEServer) marshalJSON(v interface{}) (json.RawMessage, error) { - data, err := json.Marshal(v) - if err != nil { - s.logger.Error().Err(err).Interface("value", v).Msg("Failed to marshal JSON") - return nil, fmt.Errorf("failed to marshal JSON: %w", err) - } - return data, nil -} - -// withSessionID adds a session ID to the context -func withSessionID(ctx context.Context, sessionID string) context.Context { - return context.WithValue(ctx, sessionIDKey, sessionID) -} - -// handleSSE handles new SSE connections -func (s *SSEServer) handleSSE(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Set SSE headers according to protocol spec - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - - // Create unique session ID - sessionID := r.URL.Query().Get("sessionId") - if sessionID == "" { - sessionID = uuid.New().String() - } - - ctx = withSessionID(ctx, sessionID) - - s.mu.Lock() - s.nextClientID++ - clientID := fmt.Sprintf("client-%d", s.nextClientID) - client := &SSEClient{ - id: clientID, - sessionID: sessionID, - messageChan: make(chan *protocol.Response, 100), - createdAt: time.Now(), - remoteAddr: r.RemoteAddr, - userAgent: r.UserAgent(), - } - s.clients[clientID] = client - clientCount := len(s.clients) - s.mu.Unlock() - - s.logger.Debug(). - Str("client_id", clientID). - Str("sessionId", sessionID). - Str("remote_addr", r.RemoteAddr). - Str("user_agent", r.UserAgent()). - Int("total_clients", clientCount). - Msg("New SSE connection") - - // Send initial endpoint event with session ID - endpoint := fmt.Sprintf("%s?sessionId=%s", "/messages", sessionID) - fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n", endpoint) - w.(http.Flusher).Flush() - - // Add to waitgroup before starting goroutine - s.wg.Add(1) - defer s.wg.Done() - - defer func() { - s.mu.Lock() - if c, exists := s.clients[clientID]; exists { - close(c.messageChan) - delete(s.clients, clientID) - s.logger.Debug(). - Str("client_id", clientID). - Str("sessionId", sessionID). - Int("total_clients", len(s.clients)). - Dur("connection_duration", time.Since(c.createdAt)). - Msg("Client disconnected") - } - s.mu.Unlock() - }() - - // Keep connection open and send messages - for { - select { - case msg := <-client.messageChan: - if msg == nil { - s.logger.Debug(). - Str("client_id", clientID). - Str("sessionId", sessionID). - Msg("Received nil message, closing connection") - return - } - - data, err := s.marshalJSON(msg) - if err != nil { - s.logger.Error(). - Err(err). - Str("client_id", clientID). - Str("sessionId", sessionID). - Interface("message", msg). - Msg("Failed to marshal message") - continue - } - - s.logger.Debug(). - Str("client_id", clientID). - Str("sessionId", sessionID). - RawJSON("message", data). - Msg("Sending message to client") - - // Send message event according to protocol spec - fmt.Fprintf(w, "event: message\ndata: %s\n\n", data) - w.(http.Flusher).Flush() - - case <-ctx.Done(): - s.logger.Debug(). - Str("client_id", clientID). - Str("sessionId", sessionID). - Msg("Context done, closing connection") - return - } - } -} - -// handleMessages processes incoming client messages -func (s *SSEServer) handleMessages(w http.ResponseWriter, r *http.Request) { - // Set CORS headers - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - - // Handle preflight request - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } - - ctx := r.Context() - sessionID := r.URL.Query().Get("sessionId") - s.logger.Debug(). - Str("url", r.URL.String()). - Str("remote_addr", r.RemoteAddr). - Str("sessionId", sessionID). - Msg("Received message request") - - // Use default session if none provided - if sessionID == "" { - sessionID = "default" - s.logger.Debug().Msg("Using default session") - } - - // Add sessionId to context - ctx = dispatcher.WithSessionID(ctx, sessionID) - - // Find all clients for this session - s.mu.RLock() - var sessionClients []*SSEClient - for _, client := range s.clients { - if client.sessionID == sessionID { - sessionClients = append(sessionClients, client) - } - } - s.mu.RUnlock() - - if len(sessionClients) == 0 { - s.logger.Error(). - Str("sessionId", sessionID). - Msg("No active clients found for session") - http.Error(w, "No active clients found for session", http.StatusBadRequest) - return - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - s.logger.Error().Err(err).Msg("Error reading request body") - http.Error(w, "Error reading request body", http.StatusBadRequest) - return - } - - var request protocol.Request - if err := json.Unmarshal(bodyBytes, &request); err != nil { - response, _ := dispatcher.NewErrorResponse(nil, -32700, "Parse error", err) - // Send error to all session clients - for _, client := range sessionClients { - select { - case client.messageChan <- response: - default: - s.logger.Error(). - Str("client_id", client.id). - Str("sessionId", sessionID). - Msg("Failed to send error response to client") - } - } - w.WriteHeader(http.StatusAccepted) - return - } - - // Use the dispatcher to handle the request - response, err := s.dispatcher.Dispatch(ctx, request) - if err != nil { - s.logger.Error().Err(err).Msg("Error dispatching request") - response, _ = dispatcher.NewErrorResponse(request.ID, -32603, "Internal error", err) - } - - // Send response to all session clients if it's not nil (notifications don't have responses) - if response != nil { - for _, client := range sessionClients { - select { - case client.messageChan <- response: - s.logger.Debug(). - Str("client_id", client.id). - Str("sessionId", sessionID). - Interface("response", response). - Msg("Response sent to client") - default: - s.logger.Error(). - Str("client_id", client.id). - Str("sessionId", sessionID). - Msg("Failed to send response to client") - } - } - } - - w.WriteHeader(http.StatusAccepted) -} diff --git a/pkg/server/transports/stdio/stdio.go b/pkg/server/transports/stdio/stdio.go deleted file mode 100644 index 4983328..0000000 --- a/pkg/server/transports/stdio/stdio.go +++ /dev/null @@ -1,418 +0,0 @@ -package stdio - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "os" - "os/signal" - "syscall" - "time" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/go-go-golems/go-go-mcp/pkg/services" - "github.com/rs/zerolog" -) - -// Server handles stdio transport for MCP protocol -type Server struct { - scanner *bufio.Scanner - writer *json.Encoder - logger zerolog.Logger - promptService services.PromptService - resourceService services.ResourceService - toolService services.ToolService - initializeService services.InitializeService - signalChan chan os.Signal -} - -// NewServer creates a new stdio server instance -func NewServer(logger zerolog.Logger, ps services.PromptService, rs services.ResourceService, ts services.ToolService, is services.InitializeService) *Server { - scanner := bufio.NewScanner(os.Stdin) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer - - // Create a ConsoleWriter that writes to stderr with a SERVER tag - consoleWriter := zerolog.ConsoleWriter{ - Out: os.Stderr, - TimeFormat: time.RFC3339, - FormatMessage: func(i interface{}) string { - return fmt.Sprintf("[SERVER] %s", i) - }, - } - - // Create a new logger that writes to the tagged stderr - taggedLogger := logger.Output(consoleWriter) - - return &Server{ - scanner: scanner, - writer: json.NewEncoder(os.Stdout), - logger: taggedLogger, - promptService: ps, - resourceService: rs, - toolService: ts, - initializeService: is, - signalChan: make(chan os.Signal, 1), - } -} - -// Start begins listening for and handling messages on stdio -func (s *Server) Start(ctx context.Context) error { - s.logger.Info().Msg("Starting stdio server...") - - // Set up signal handling - signal.Notify(s.signalChan, os.Interrupt, syscall.SIGTERM) - defer signal.Stop(s.signalChan) - - // Create a channel for scanner errors - scanErrChan := make(chan error, 1) - - // Create a cancellable context for the scanner - scannerCtx, cancelScanner := context.WithCancel(ctx) - defer cancelScanner() - - // Start scanning in a goroutine - go func() { - for s.scanner.Scan() { - select { - case <-scannerCtx.Done(): - s.logger.Debug().Msg("Context cancelled, stopping scanner") - scanErrChan <- scannerCtx.Err() - return - default: - line := s.scanner.Text() - s.logger.Debug(). - Str("line", line). - Msg("Received line") - if err := s.handleMessage(line); err != nil { - s.logger.Error().Err(err).Msg("Error handling message") - // Continue processing messages even if one fails - } - } - } - - if err := s.scanner.Err(); err != nil { - s.logger.Error(). - Err(err). - Msg("Scanner error") - scanErrChan <- fmt.Errorf("scanner error: %w", err) - return - } - - s.logger.Debug().Msg("Scanner reached EOF") - scanErrChan <- io.EOF - }() - - // Wait for either a signal, context cancellation, or scanner error - select { - case sig := <-s.signalChan: - s.logger.Debug(). - Str("signal", sig.String()). - Msg("Received signal in stdio server") - cancelScanner() - return nil - case <-ctx.Done(): - s.logger.Debug(). - Err(ctx.Err()). - Msg("Context cancelled in stdio server") - return ctx.Err() - case err := <-scanErrChan: - if err == io.EOF { - s.logger.Debug().Msg("Scanner completed normally") - } else if err != nil { - s.logger.Error(). - Err(err). - Msg("Scanner error in stdio server") - } - return err - } -} - -// Stop gracefully stops the stdio server -func (s *Server) Stop(ctx context.Context) error { - s.logger.Info().Msg("Stopping stdio server") - - // Wait for context to be done or timeout - select { - case <-ctx.Done(): - s.logger.Debug(). - Err(ctx.Err()). - Msg("Stop context cancelled before clean shutdown") - return ctx.Err() - case <-time.After(100 * time.Millisecond): // Give a small grace period for cleanup - s.logger.Debug().Msg("Stop completed successfully") - return nil - } -} - -// handleMessage processes a single message -func (s *Server) handleMessage(message string) error { - s.logger.Debug(). - Str("message", message). - Msg("Processing message") - - // Parse the base message structure - var request protocol.Request - if err := json.Unmarshal([]byte(message), &request); err != nil { - s.logger.Error(). - Err(err). - Str("message", message). - Msg("Failed to parse message as JSON-RPC request") - return s.sendError(nil, -32700, "Parse error", err) - } - - // Validate JSON-RPC version - if request.JSONRPC != "2.0" { - s.logger.Error(). - Str("version", request.JSONRPC). - Msg("Invalid JSON-RPC version") - return s.sendError(&request.ID, -32600, "Invalid Request", fmt.Errorf("invalid JSON-RPC version")) - } - - s.logger.Debug(). - RawJSON("id", request.ID). - Str("method", request.Method). - Msg("Received message") - - // Handle requests vs notifications based on ID presence - if request.ID != nil && string(request.ID) != "null" && len(request.ID) > 0 { - s.logger.Debug(). - RawJSON("id", request.ID). - Str("method", request.Method). - Msg("Handling request") - return s.handleRequest(request) - } - - s.logger.Debug(). - Str("method", request.Method). - Msg("Handling notification") - return s.handleNotification(request) -} - -// handleRequest processes a request message -func (s *Server) handleRequest(request protocol.Request) error { - ctx := context.Background() - - switch request.Method { - case "initialize": - var params protocol.InitializeParams - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return s.sendError(&request.ID, -32602, "Invalid params", err) - } - s.logger.Debug().Interface("params", params).Msg("Handling initialize request") - - result, err := s.initializeService.Initialize(ctx, params) - if err != nil { - return s.sendError(&request.ID, -32603, "Initialize failed", err) - } - return s.sendResult(&request.ID, result) - - case "ping": - return s.sendResult(&request.ID, struct{}{}) - - case "prompts/list": - var cursor string - if request.Params != nil { - var params struct { - Cursor string `json:"cursor"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return s.sendError(&request.ID, -32602, "Invalid params", err) - } - cursor = params.Cursor - } - - s.logger.Debug().Str("cursor", cursor).Msg("Listing prompts") - - prompts, nextCursor, err := s.promptService.ListPrompts(ctx, cursor) - if err != nil { - return s.sendError(&request.ID, -32603, "Internal error", err) - } - - return s.sendResult(&request.ID, map[string]interface{}{ - "prompts": prompts, - "nextCursor": nextCursor, - }) - - case "prompts/get": - var params struct { - Name string `json:"name"` - Arguments map[string]string `json:"arguments"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return s.sendError(&request.ID, -32602, "Invalid params", err) - } - - s.logger.Debug().Str("name", params.Name).Str("arguments", fmt.Sprintf("%v", params.Arguments)).Msg("Getting prompt") - - message, err := s.promptService.GetPrompt(ctx, params.Name, params.Arguments) - if err != nil { - return s.sendError(&request.ID, -32602, "Prompt not found", err) - } - - return s.sendResult(&request.ID, map[string]interface{}{ - "description": "Prompt from provider", - "messages": []protocol.PromptMessage{*message}, - }) - - case "resources/list": - var cursor string - if request.Params != nil { - var params struct { - Cursor string `json:"cursor"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return s.sendError(&request.ID, -32602, "Invalid params", err) - } - cursor = params.Cursor - } - - s.logger.Debug().Str("cursor", cursor).Msg("Listing resources") - - resources, nextCursor, err := s.resourceService.ListResources(ctx, cursor) - if err != nil { - return s.sendError(&request.ID, -32603, "Internal error", err) - } - - return s.sendResult(&request.ID, map[string]interface{}{ - "resources": resources, - "nextCursor": nextCursor, - }) - - case "resources/read": - var params struct { - URI string `json:"uri"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return s.sendError(&request.ID, -32602, "Invalid params", err) - } - - s.logger.Debug().Str("uri", params.URI).Msg("Reading resource") - - content, err := s.resourceService.ReadResource(ctx, params.URI) - if err != nil { - return s.sendError(&request.ID, -32002, "Resource not found", err) - } - - return s.sendResult(&request.ID, map[string]interface{}{ - "contents": []protocol.ResourceContent{*content}, - }) - - case "tools/list": - var cursor string - if request.Params != nil { - var params struct { - Cursor string `json:"cursor"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return s.sendError(&request.ID, -32602, "Invalid params", err) - } - cursor = params.Cursor - } - - s.logger.Debug().Str("cursor", cursor).Msg("Listing tools") - - tools, nextCursor, err := s.toolService.ListTools(ctx, cursor) - if err != nil { - return s.sendError(&request.ID, -32603, "Internal error", err) - } - - return s.sendResult(&request.ID, map[string]interface{}{ - "tools": tools, - "nextCursor": nextCursor, - }) - - case "tools/call": - var params struct { - Name string `json:"name"` - Arguments map[string]interface{} `json:"arguments"` - } - if err := json.Unmarshal(request.Params, ¶ms); err != nil { - return s.sendError(&request.ID, -32602, "Invalid params", err) - } - - s.logger.Debug().Str("name", params.Name).Str("arguments", fmt.Sprintf("%v", params.Arguments)).Msg("Calling tool") - - result, err := s.toolService.CallTool(ctx, params.Name, params.Arguments) - if err != nil { - return s.sendError(&request.ID, -32602, "Tool not found", err) - } - - return s.sendResult(&request.ID, result) - - default: - return s.sendError(&request.ID, -32601, fmt.Sprintf("Method %s not found", request.Method), nil) - } -} - -// handleNotification processes a notification message -func (s *Server) handleNotification(request protocol.Request) error { - switch request.Method { - case "notifications/initialized": - s.logger.Info().Msg("Client initialized") - return nil - - default: - s.logger.Warn().Str("method", request.Method).Msg("Unknown notification method") - return nil - } -} - -// marshalJSON marshals data to JSON and returns any error -func (s *Server) marshalJSON(v interface{}) (json.RawMessage, error) { - data, err := json.Marshal(v) - if err != nil { - s.logger.Error().Err(err).Interface("value", v).Msg("Failed to marshal JSON") - return nil, fmt.Errorf("failed to marshal JSON: %w", err) - } - return data, nil -} - -// sendResult sends a successful response -func (s *Server) sendResult(id *json.RawMessage, result interface{}) error { - resultJSON, err := s.marshalJSON(result) - if err != nil { - return s.sendError(id, -32603, "Internal error", err) - } - - response := protocol.Response{ - JSONRPC: "2.0", - Result: resultJSON, - } - if id != nil { - response.ID = *id - } - - s.logger.Debug().Interface("response", response).Msg("Sending response") - return s.writer.Encode(response) -} - -// sendError sends an error response -func (s *Server) sendError(id *json.RawMessage, code int, message string, data interface{}) error { - var errorData json.RawMessage - if data != nil { - var err error - errorData, err = s.marshalJSON(data) - if err != nil { - // If we can't marshal the error data, log it and send a simpler error - s.logger.Error().Err(err).Interface("data", data).Msg("Failed to marshal error data") - return s.sendError(id, -32603, "Internal error marshaling error data", nil) - } - } - - response := protocol.Response{ - JSONRPC: "2.0", - Error: &protocol.Error{ - Code: code, - Message: message, - Data: errorData, - }, - } - if id != nil { - response.ID = *id - } - - s.logger.Debug().Interface("response", response).Msg("Sending error response") - return s.writer.Encode(response) -} diff --git a/pkg/services/defaults/initialize.go b/pkg/services/defaults/initialize.go deleted file mode 100644 index 9922d68..0000000 --- a/pkg/services/defaults/initialize.go +++ /dev/null @@ -1,58 +0,0 @@ -package defaults - -import ( - "context" - "fmt" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" -) - -type DefaultInitializeService struct { - serverName string - serverVersion string -} - -func NewInitializeService(serverName string, serverVersion string) *DefaultInitializeService { - return &DefaultInitializeService{ - serverName: serverName, - serverVersion: serverVersion, - } -} - -func (s *DefaultInitializeService) Initialize(ctx context.Context, params protocol.InitializeParams) (protocol.InitializeResult, error) { - // Validate protocol version - supportedVersions := []string{"2024-11-05"} - isSupported := false - for _, version := range supportedVersions { - if params.ProtocolVersion == version { - isSupported = true - break - } - } - - if !isSupported { - return protocol.InitializeResult{}, fmt.Errorf("unsupported protocol version %s, supported versions: %v", params.ProtocolVersion, supportedVersions) - } - - // Return server capabilities - return protocol.InitializeResult{ - ProtocolVersion: params.ProtocolVersion, - Capabilities: protocol.ServerCapabilities{ - Logging: &protocol.LoggingCapability{}, - Prompts: &protocol.PromptsCapability{ - ListChanged: true, - }, - Resources: &protocol.ResourcesCapability{ - Subscribe: true, - ListChanged: true, - }, - Tools: &protocol.ToolsCapability{ - ListChanged: true, - }, - }, - ServerInfo: protocol.ServerInfo{ - Name: s.serverName, - Version: s.serverVersion, - }, - }, nil -} diff --git a/pkg/services/defaults/prompts.go b/pkg/services/defaults/prompts.go deleted file mode 100644 index b61a81b..0000000 --- a/pkg/services/defaults/prompts.go +++ /dev/null @@ -1,50 +0,0 @@ -package defaults - -import ( - "context" - - "github.com/go-go-golems/go-go-mcp/pkg" - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/rs/zerolog" -) - -type DefaultPromptService struct { - registry *pkg.ProviderRegistry - logger zerolog.Logger -} - -func NewPromptService(registry *pkg.ProviderRegistry, logger zerolog.Logger) *DefaultPromptService { - return &DefaultPromptService{ - registry: registry, - logger: logger, - } -} - -func (s *DefaultPromptService) ListPrompts(ctx context.Context, cursor string) ([]protocol.Prompt, string, error) { - var allPrompts []protocol.Prompt - var lastCursor string - - for _, provider := range s.registry.GetPromptProviders() { - prompts, nextCursor, err := provider.ListPrompts(cursor) - if err != nil { - s.logger.Error().Err(err).Msg("Error listing prompts from provider") - continue - } - allPrompts = append(allPrompts, prompts...) - if nextCursor != "" { - lastCursor = nextCursor - } - } - - return allPrompts, lastCursor, nil -} - -func (s *DefaultPromptService) GetPrompt(ctx context.Context, name string, arguments map[string]string) (*protocol.PromptMessage, error) { - for _, provider := range s.registry.GetPromptProviders() { - message, err := provider.GetPrompt(name, arguments) - if err == nil { - return message, nil - } - } - return nil, pkg.ErrPromptNotFound -} diff --git a/pkg/services/defaults/resources.go b/pkg/services/defaults/resources.go deleted file mode 100644 index de4d1b0..0000000 --- a/pkg/services/defaults/resources.go +++ /dev/null @@ -1,50 +0,0 @@ -package defaults - -import ( - "context" - - "github.com/go-go-golems/go-go-mcp/pkg" - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/rs/zerolog" -) - -type DefaultResourceService struct { - registry *pkg.ProviderRegistry - logger zerolog.Logger -} - -func NewResourceService(registry *pkg.ProviderRegistry, logger zerolog.Logger) *DefaultResourceService { - return &DefaultResourceService{ - registry: registry, - logger: logger, - } -} - -func (s *DefaultResourceService) ListResources(ctx context.Context, cursor string) ([]protocol.Resource, string, error) { - var allResources []protocol.Resource - var lastCursor string - - for _, provider := range s.registry.GetResourceProviders() { - resources, nextCursor, err := provider.ListResources(cursor) - if err != nil { - s.logger.Error().Err(err).Msg("Error listing resources from provider") - continue - } - allResources = append(allResources, resources...) - if nextCursor != "" { - lastCursor = nextCursor - } - } - - return allResources, lastCursor, nil -} - -func (s *DefaultResourceService) ReadResource(ctx context.Context, uri string) (*protocol.ResourceContent, error) { - for _, provider := range s.registry.GetResourceProviders() { - content, err := provider.ReadResource(uri) - if err == nil { - return content, nil - } - } - return nil, pkg.ErrResourceNotFound -} diff --git a/pkg/services/defaults/tools.go b/pkg/services/defaults/tools.go deleted file mode 100644 index 6c086d4..0000000 --- a/pkg/services/defaults/tools.go +++ /dev/null @@ -1,50 +0,0 @@ -package defaults - -import ( - "context" - - "github.com/go-go-golems/go-go-mcp/pkg" - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/rs/zerolog" -) - -type DefaultToolService struct { - registry *pkg.ProviderRegistry - logger zerolog.Logger -} - -func NewToolService(registry *pkg.ProviderRegistry, logger zerolog.Logger) *DefaultToolService { - return &DefaultToolService{ - registry: registry, - logger: logger, - } -} - -func (s *DefaultToolService) ListTools(ctx context.Context, cursor string) ([]protocol.Tool, string, error) { - var allTools []protocol.Tool - var lastCursor string - - for _, provider := range s.registry.GetToolProviders() { - tools, nextCursor, err := provider.ListTools(cursor) - if err != nil { - s.logger.Error().Err(err).Msg("Error listing tools from provider") - continue - } - allTools = append(allTools, tools...) - if nextCursor != "" { - lastCursor = nextCursor - } - } - - return allTools, lastCursor, nil -} - -func (s *DefaultToolService) CallTool(ctx context.Context, name string, arguments map[string]interface{}) (interface{}, error) { - for _, provider := range s.registry.GetToolProviders() { - result, err := provider.CallTool(ctx, name, arguments) - if err == nil { - return result, nil - } - } - return nil, pkg.ErrToolNotFound -} diff --git a/pkg/services/interfaces.go b/pkg/services/interfaces.go deleted file mode 100644 index a71a06f..0000000 --- a/pkg/services/interfaces.go +++ /dev/null @@ -1,30 +0,0 @@ -package services - -import ( - "context" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" -) - -// PromptService handles prompt-related operations -type PromptService interface { - ListPrompts(ctx context.Context, cursor string) ([]protocol.Prompt, string, error) - GetPrompt(ctx context.Context, name string, arguments map[string]string) (*protocol.PromptMessage, error) -} - -// ResourceService handles resource-related operations -type ResourceService interface { - ListResources(ctx context.Context, cursor string) ([]protocol.Resource, string, error) - ReadResource(ctx context.Context, uri string) (*protocol.ResourceContent, error) -} - -// ToolService handles tool-related operations -type ToolService interface { - ListTools(ctx context.Context, cursor string) ([]protocol.Tool, string, error) - CallTool(ctx context.Context, name string, arguments map[string]interface{}) (interface{}, error) -} - -// InitializeService handles initialization operations -type InitializeService interface { - Initialize(ctx context.Context, params protocol.InitializeParams) (protocol.InitializeResult, error) -} diff --git a/pkg/tools/examples/cursor/code.go b/pkg/tools/examples/cursor/code.go deleted file mode 100644 index af7371f..0000000 --- a/pkg/tools/examples/cursor/code.go +++ /dev/null @@ -1,258 +0,0 @@ -package cursor - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/go-go-golems/go-go-mcp/pkg/tools" - _ "github.com/mattn/go-sqlite3" - "gopkg.in/yaml.v3" -) - -func RegisterExtractCodeBlocksTool(registry *tools.Registry) error { - schemaJson := `{ - "type": "object", - "properties": { - "filepaths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of file paths or search terms to extract code blocks for. Each term can be a file path (relative or absolute) or a search term. Terms containing spaces should be wrapped in quotes. Example: ['file.go', 'other.go', 'some search term']. Multiple unquoted words are treated as separate search terms." - } - }, - "required": ["filepaths"] - }` - - tool, err := tools.NewToolImpl( - "cursor-extract-code-blocks", - "Extract all code blocks that were generated or modified by the AI for multiple files or search terms. Returns a structured YAML output containing code blocks organized by file and conversation. Accepts space-separated terms (use quotes for terms containing spaces) for flexible searching. Example: file.go 'quoted term' other.go", - json.RawMessage(schemaJson)) - if err != nil { - return err - } - - registry.RegisterToolWithHandler( - tool, - func(ctx context.Context, tool tools.Tool, arguments map[string]interface{}) (*protocol.ToolResult, error) { - filepaths, ok := arguments["filepaths"].([]interface{}) - if !ok { - return protocol.NewToolResult( - protocol.WithError("filepaths argument must be an array of strings"), - ), nil - } - - // Process each filepath/search term - var searchTerms []string - for _, path := range filepaths { - pathStr := fmt.Sprintf("%v", path) - terms := splitSearchTerms(pathStr) - searchTerms = append(searchTerms, terms...) - } - - db, err := sql.Open("sqlite3", defaultDBPath) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error opening database: %v", err)), - ), nil - } - defer db.Close() - - // Build LIKE conditions for each search term - conditions := make([]string, len(searchTerms)) - args := make([]interface{}, len(searchTerms)) - for i, term := range searchTerms { - conditions[i] = "value LIKE ?" - args[i] = "%" + term + "%" - } - - query := fmt.Sprintf(` - SELECT key, - json_extract(value, '$.conversation[1].codeBlocks') as code_blocks, - value as full_value - FROM cursorDiskKV - WHERE %s - `, strings.Join(conditions, " OR ")) - - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error executing query: %v", err)), - ), nil - } - defer rows.Close() - - results := make(map[string][]map[string]interface{}) - for rows.Next() { - var key string - var codeBlocks string - var fullValue string - if err := rows.Scan(&key, &codeBlocks, &fullValue); err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error scanning row: %v", err)), - ), nil - } - - var blocks []interface{} - if err := json.Unmarshal([]byte(codeBlocks), &blocks); err != nil { - continue // Skip invalid JSON - } - - // Match against each search term - for _, term := range searchTerms { - if strings.Contains(fullValue, term) { - if _, ok := results[term]; !ok { - results[term] = []map[string]interface{}{} - } - results[term] = append(results[term], map[string]interface{}{ - "key": key, - "code_blocks": blocks, - }) - } - } - } - - yamlData, err := yaml.Marshal(results) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error converting to YAML: %v", err)), - ), nil - } - - return protocol.NewToolResult( - protocol.WithText(string(yamlData)), - ), nil - }) - - return nil -} - -func RegisterTrackFileModificationsTool(registry *tools.Registry) error { - schemaJson := `{ - "type": "object", - "properties": { - "filepaths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of file paths or search terms to track modifications for. Each term can be a file path (relative or absolute) or a search term. Terms containing spaces should be wrapped in quotes. Example: ['file.go', 'other.go', 'some search term']. Multiple unquoted words are treated as separate search terms." - } - }, - "required": ["filepaths"] - }` - - tool, err := tools.NewToolImpl( - "cursor-track-file-modifications", - "Track and analyze all modifications made to files matching the specified search terms through Cursor AI interactions over time. Returns a chronological history of changes for each matching file or term. Accepts space-separated terms (use quotes for terms containing spaces) for flexible searching. Example: file.go 'quoted term' other.go", - json.RawMessage(schemaJson)) - if err != nil { - return err - } - - registry.RegisterToolWithHandler( - tool, - func(ctx context.Context, tool tools.Tool, arguments map[string]interface{}) (*protocol.ToolResult, error) { - filepaths, ok := arguments["filepaths"].([]interface{}) - if !ok { - return protocol.NewToolResult( - protocol.WithError("filepaths argument must be an array of strings"), - ), nil - } - - // Process each filepath/search term - var searchTerms []string - for _, path := range filepaths { - pathStr := fmt.Sprintf("%v", path) - terms := splitSearchTerms(pathStr) - searchTerms = append(searchTerms, terms...) - } - - db, err := sql.Open("sqlite3", defaultDBPath) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error opening database: %v", err)), - ), nil - } - defer db.Close() - - // Build LIKE conditions for each search term - conditions := make([]string, len(searchTerms)) - args := make([]interface{}, len(searchTerms)) - for i, term := range searchTerms { - conditions[i] = "value LIKE ?" - args[i] = "%" + term + "%" - } - - query := fmt.Sprintf(` - WITH RECURSIVE - file_mods AS ( - SELECT key, - json_extract(value, '$.createdAt') as created_at, - json_extract(value, '$.conversation[1].codeBlocks') as code_blocks, - value as full_value - FROM cursorDiskKV - WHERE %s - ORDER BY created_at ASC - ) - SELECT * FROM file_mods - `, strings.Join(conditions, " OR ")) - - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error executing query: %v", err)), - ), nil - } - defer rows.Close() - - results := make(map[string][]map[string]interface{}) - for rows.Next() { - var key string - var createdAt string - var codeBlocks string - var fullValue string - if err := rows.Scan(&key, &createdAt, &codeBlocks, &fullValue); err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error scanning row: %v", err)), - ), nil - } - - var blocks []interface{} - if err := json.Unmarshal([]byte(codeBlocks), &blocks); err != nil { - continue // Skip invalid JSON - } - - // Match against each search term - for _, term := range searchTerms { - if strings.Contains(fullValue, term) { - if _, ok := results[term]; !ok { - results[term] = []map[string]interface{}{} - } - results[term] = append(results[term], map[string]interface{}{ - "key": key, - "created_at": createdAt, - "code_blocks": blocks, - }) - } - } - } - - yamlData, err := yaml.Marshal(results) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error converting to YAML: %v", err)), - ), nil - } - - return protocol.NewToolResult( - protocol.WithText(string(yamlData)), - ), nil - }) - - return nil -} diff --git a/pkg/tools/examples/cursor/context.go b/pkg/tools/examples/cursor/context.go deleted file mode 100644 index 83b8abb..0000000 --- a/pkg/tools/examples/cursor/context.go +++ /dev/null @@ -1,5 +0,0 @@ -package cursor - -import ( - _ "github.com/mattn/go-sqlite3" -) diff --git a/pkg/tools/examples/cursor/conversation.go b/pkg/tools/examples/cursor/conversation.go deleted file mode 100644 index 36157db..0000000 --- a/pkg/tools/examples/cursor/conversation.go +++ /dev/null @@ -1,455 +0,0 @@ -package cursor - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - - "github.com/go-go-golems/go-go-mcp/pkg/protocol" - "github.com/go-go-golems/go-go-mcp/pkg/tools" - _ "github.com/mattn/go-sqlite3" - "gopkg.in/yaml.v3" -) - -const defaultDBPath = "/home/manuel/.config/Cursor/User/globalStorage/state.vscdb" - -func RegisterGetConversationTool(registry *tools.Registry) error { - schemaJson := `{ - "type": "object", - "properties": { - "composer_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of composer IDs or search terms to find conversations. Each term can be a specific composer ID or a search term. Terms containing spaces should be wrapped in quotes. Example: ['abc123', 'def456', 'search term']. Multiple unquoted words are treated as separate search terms." - } - }, - "required": ["composer_ids"] - }` - - tool, err := tools.NewToolImpl( - "cursor-get-conversation", - "Retrieve the complete conversation history between users and AI assistants in the Cursor IDE. Returns the full conversations including all messages, code snippets, and metadata. Accepts space-separated terms (use quotes for terms containing spaces) for flexible searching. Example: abc123 'search term' def456", - json.RawMessage(schemaJson)) - if err != nil { - return err - } - - registry.RegisterToolWithHandler( - tool, - func(ctx context.Context, tool tools.Tool, arguments map[string]interface{}) (*protocol.ToolResult, error) { - composerIDs, ok := arguments["composer_ids"].([]interface{}) - if !ok { - return protocol.NewToolResult( - protocol.WithError("composer_ids argument must be an array of strings"), - ), nil - } - - // Process each composer ID/search term - var searchTerms []string - for _, id := range composerIDs { - idStr := fmt.Sprintf("%v", id) - terms := splitSearchTerms(idStr) - searchTerms = append(searchTerms, terms...) - } - - db, err := sql.Open("sqlite3", defaultDBPath) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error opening database: %v", err)), - ), nil - } - defer db.Close() - - // Build conditions for each search term - conditions := make([]string, len(searchTerms)) - args := make([]interface{}, len(searchTerms)) - for i, term := range searchTerms { - conditions[i] = "(json_extract(value, '$.composerId') = ? OR value LIKE ?)" - args[i*2] = term - args[i*2+1] = "%" + term + "%" - } - - query := fmt.Sprintf(` - SELECT value - FROM cursorDiskKV - WHERE %s - `, strings.Join(conditions, " OR ")) - - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error executing query: %v", err)), - ), nil - } - defer rows.Close() - - // Group results by matching search term - results := make(map[string][]interface{}) - for rows.Next() { - var value string - if err := rows.Scan(&value); err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error scanning row: %v", err)), - ), nil - } - - var jsonData interface{} - if err := json.Unmarshal([]byte(value), &jsonData); err != nil { - continue // Skip invalid JSON - } - - // Match against each search term - for _, term := range searchTerms { - if strings.Contains(value, term) { - if _, ok := results[term]; !ok { - results[term] = []interface{}{} - } - results[term] = append(results[term], jsonData) - } - } - } - - yamlData, err := yaml.Marshal(results) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error converting to YAML: %v", err)), - ), nil - } - - return protocol.NewToolResult( - protocol.WithText(string(yamlData)), - ), nil - }) - - return nil -} - -func RegisterFindConversationsTool(registry *tools.Registry) error { - schemaJson := `{ - "type": "object", - "properties": { - "search_terms": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of terms to search for in conversations. Each term can be a simple word or a quoted phrase. Terms containing spaces should be wrapped in quotes. Example: ['error', 'file.go', 'database query']. Multiple unquoted words are treated as separate search terms." - } - }, - "required": ["search_terms"] - }` - - tool, err := tools.NewToolImpl( - "cursor-find-conversations", - "Search through all AI conversations stored in the Cursor IDE database. Returns matching conversations with their composer IDs, initial user messages, and AI responses. Accepts space-separated terms (use quotes for terms containing spaces) for flexible searching. Example: error 'database query' file.go", - json.RawMessage(schemaJson)) - if err != nil { - return err - } - - registry.RegisterToolWithHandler( - tool, - func(ctx context.Context, tool tools.Tool, arguments map[string]interface{}) (*protocol.ToolResult, error) { - searchTermsInput, ok := arguments["search_terms"].([]interface{}) - if !ok { - return protocol.NewToolResult( - protocol.WithError("search_terms argument must be an array of strings"), - ), nil - } - - // Process each search term - var searchTerms []string - for _, term := range searchTermsInput { - termStr := fmt.Sprintf("%v", term) - terms := splitSearchTerms(termStr) - searchTerms = append(searchTerms, terms...) - } - - db, err := sql.Open("sqlite3", defaultDBPath) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error opening database: %v", err)), - ), nil - } - defer db.Close() - - // Build conditions for each search term - conditions := make([]string, len(searchTerms)) - args := make([]interface{}, len(searchTerms)) - for i, term := range searchTerms { - conditions[i] = "value LIKE ?" - args[i] = "%" + term + "%" - } - - query := fmt.Sprintf(` - SELECT key, - json_extract(value, '$.conversation[0].text') as user_message, - json_extract(value, '$.conversation[1].text') as assistant_response, - value as full_value - FROM cursorDiskKV - WHERE %s - `, strings.Join(conditions, " OR ")) - - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error executing query: %v", err)), - ), nil - } - defer rows.Close() - - // Group results by matching search term - results := make(map[string][]map[string]interface{}) - for rows.Next() { - var key, userMsg, assistantMsg, fullValue string - if err := rows.Scan(&key, &userMsg, &assistantMsg, &fullValue); err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error scanning row: %v", err)), - ), nil - } - - // Match against each search term - for _, term := range searchTerms { - if strings.Contains(fullValue, term) { - if _, ok := results[term]; !ok { - results[term] = []map[string]interface{}{} - } - results[term] = append(results[term], map[string]interface{}{ - "key": key, - "user_message": userMsg, - "assistant_response": assistantMsg, - }) - } - } - } - - yamlData, err := yaml.Marshal(results) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error converting to YAML: %v", err)), - ), nil - } - - return protocol.NewToolResult( - protocol.WithText(string(yamlData)), - ), nil - }) - - return nil -} - -func RegisterGetFileReferencesTool(registry *tools.Registry) error { - schemaJson := `{ - "type": "object", - "properties": { - "conversation_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of conversation IDs or search terms. Each term can be a specific conversation ID or a search term. Terms containing spaces should be wrapped in quotes. Example: ['conv123', 'other456', 'search term']. Multiple unquoted words are treated as separate search terms." - } - }, - "required": ["conversation_ids"] - }` - - tool, err := tools.NewToolImpl( - "cursor-get-file-references", - "Retrieve all files that were referenced, viewed, or modified during specific Cursor AI conversations. This includes files that were open in the editor, files that were searched, and files that were modified. Accepts space-separated terms (use quotes for terms containing spaces) for flexible searching. Example: conv123 'search term' other456", - json.RawMessage(schemaJson)) - if err != nil { - return err - } - - registry.RegisterToolWithHandler( - tool, - func(ctx context.Context, tool tools.Tool, arguments map[string]interface{}) (*protocol.ToolResult, error) { - conversationIDs, ok := arguments["conversation_ids"].([]interface{}) - if !ok { - return protocol.NewToolResult( - protocol.WithError("conversation_ids argument must be an array of strings"), - ), nil - } - - // Process each conversation ID/search term - var searchTerms []string - for _, id := range conversationIDs { - idStr := fmt.Sprintf("%v", id) - terms := splitSearchTerms(idStr) - searchTerms = append(searchTerms, terms...) - } - - db, err := sql.Open("sqlite3", defaultDBPath) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error opening database: %v", err)), - ), nil - } - defer db.Close() - - // Build conditions for each search term - conditions := make([]string, len(searchTerms)) - args := make([]interface{}, len(searchTerms)*2) - for i, term := range searchTerms { - conditions[i] = "(key = ? OR value LIKE ?)" - args[i*2] = term - args[i*2+1] = "%" + term + "%" - } - - query := fmt.Sprintf(` - SELECT key, - json_extract(value, '$.conversation[0].context.fileSelections') as files - FROM cursorDiskKV - WHERE (%s) AND json_valid(value) - `, strings.Join(conditions, " OR ")) - - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error executing query: %v", err)), - ), nil - } - defer rows.Close() - - results := make(map[string]interface{}) - for rows.Next() { - var key string - var files string - if err := rows.Scan(&key, &files); err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error scanning row: %v", err)), - ), nil - } - - var fileList []interface{} - if err := json.Unmarshal([]byte(files), &fileList); err != nil { - continue // Skip invalid JSON - } - - results[key] = fileList - } - - yamlData, err := yaml.Marshal(results) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error converting to YAML: %v", err)), - ), nil - } - - return protocol.NewToolResult( - protocol.WithText(string(yamlData)), - ), nil - }) - - return nil -} - -func RegisterGetConversationContextTool(registry *tools.Registry) error { - schemaJson := `{ - "type": "object", - "properties": { - "conversation_ids": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of conversation IDs or search terms. Each term can be a specific conversation ID or a search term. Terms containing spaces should be wrapped in quotes. Example: ['conv123', 'other456', 'search term']. Multiple unquoted words are treated as separate search terms." - } - }, - "required": ["conversation_ids"] - }` - - tool, err := tools.NewToolImpl( - "cursor-get-conversation-context", - "Retrieve the complete context that was available to the AI during specific conversations in the Cursor IDE. This includes open files, selected text regions, cursor positions, and other IDE state information. Accepts space-separated terms (use quotes for terms containing spaces) for flexible searching. Example: conv123 'search term' other456", - json.RawMessage(schemaJson)) - if err != nil { - return err - } - - registry.RegisterToolWithHandler( - tool, - func(ctx context.Context, tool tools.Tool, arguments map[string]interface{}) (*protocol.ToolResult, error) { - conversationIDs, ok := arguments["conversation_ids"].([]interface{}) - if !ok { - return protocol.NewToolResult( - protocol.WithError("conversation_ids argument must be an array of strings"), - ), nil - } - - // Process each conversation ID/search term - var searchTerms []string - for _, id := range conversationIDs { - idStr := fmt.Sprintf("%v", id) - terms := splitSearchTerms(idStr) - searchTerms = append(searchTerms, terms...) - } - - db, err := sql.Open("sqlite3", defaultDBPath) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error opening database: %v", err)), - ), nil - } - defer db.Close() - - // Build conditions for each search term - conditions := make([]string, len(searchTerms)) - args := make([]interface{}, len(searchTerms)*2) - for i, term := range searchTerms { - conditions[i] = "(key = ? OR value LIKE ?)" - args[i*2] = term - args[i*2+1] = "%" + term + "%" - } - - query := fmt.Sprintf(` - SELECT key, - json_extract(value, '$.conversation[0].context') as context - FROM cursorDiskKV - WHERE (%s) AND json_valid(value) - `, strings.Join(conditions, " OR ")) - - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error executing query: %v", err)), - ), nil - } - defer rows.Close() - - results := make(map[string]interface{}) - for rows.Next() { - var key string - var contextData string - if err := rows.Scan(&key, &contextData); err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error scanning row: %v", err)), - ), nil - } - - var context interface{} - if err := json.Unmarshal([]byte(contextData), &context); err != nil { - continue // Skip invalid JSON - } - - results[key] = context - } - - yamlData, err := yaml.Marshal(results) - if err != nil { - return protocol.NewToolResult( - protocol.WithError(fmt.Sprintf("error converting to YAML: %v", err)), - ), nil - } - - return protocol.NewToolResult( - protocol.WithText(string(yamlData)), - ), nil - }) - - return nil -} diff --git a/pkg/tools/examples/cursor/helpers.go b/pkg/tools/examples/cursor/helpers.go deleted file mode 100644 index e122d0c..0000000 --- a/pkg/tools/examples/cursor/helpers.go +++ /dev/null @@ -1,34 +0,0 @@ -package cursor - -import "strings" - -// splitSearchTerms splits a string into terms, respecting quoted sections. -// For example: `file.go "some quoted term" other` becomes ["file.go", "some quoted term", "other"] -func splitSearchTerms(input string) []string { - var terms []string - var currentTerm strings.Builder - inQuotes := false - - for _, r := range input { - switch r { - case '"': - inQuotes = !inQuotes - // Don't include the quotes in the term - case ' ': - if inQuotes { - currentTerm.WriteRune(r) - } else if currentTerm.Len() > 0 { - terms = append(terms, currentTerm.String()) - currentTerm.Reset() - } - default: - currentTerm.WriteRune(r) - } - } - - if currentTerm.Len() > 0 { - terms = append(terms, currentTerm.String()) - } - - return terms -} diff --git a/pkg/tools/examples/cursor/register.go b/pkg/tools/examples/cursor/register.go deleted file mode 100644 index 4b61d0d..0000000 --- a/pkg/tools/examples/cursor/register.go +++ /dev/null @@ -1,34 +0,0 @@ -package cursor - -import ( - "github.com/go-go-golems/go-go-mcp/pkg/tools" -) - -// RegisterCursorTools registers all cursor-related tools with the registry -func RegisterCursorTools(registry *tools.Registry) error { - // Register conversation tools - if err := RegisterGetConversationTool(registry); err != nil { - return err - } - if err := RegisterFindConversationsTool(registry); err != nil { - return err - } - - // Register code analysis tools - if err := RegisterExtractCodeBlocksTool(registry); err != nil { - return err - } - if err := RegisterTrackFileModificationsTool(registry); err != nil { - return err - } - - // Register context tools - if err := RegisterGetFileReferencesTool(registry); err != nil { - return err - } - if err := RegisterGetConversationContextTool(registry); err != nil { - return err - } - - return nil -} diff --git a/pkg/tools/examples/echo.go b/pkg/tools/examples/echo.go index ec20884..2d5a705 100644 --- a/pkg/tools/examples/echo.go +++ b/pkg/tools/examples/echo.go @@ -6,9 +6,10 @@ import ( "github.com/go-go-golems/go-go-mcp/pkg/protocol" "github.com/go-go-golems/go-go-mcp/pkg/tools" + "github.com/go-go-golems/go-go-mcp/pkg/tools/providers/tool-registry" ) -func RegisterEchoTool(registry *tools.Registry) error { +func RegisterEchoTool(registry *tool_registry.Registry) error { schemaJson := `{ "type": "object", "properties": { diff --git a/pkg/tools/examples/fetch.go b/pkg/tools/examples/fetch.go index 43202b3..2a2acfa 100644 --- a/pkg/tools/examples/fetch.go +++ b/pkg/tools/examples/fetch.go @@ -10,9 +10,10 @@ import ( md "github.com/JohannesKaufmann/html-to-markdown" "github.com/go-go-golems/go-go-mcp/pkg/protocol" "github.com/go-go-golems/go-go-mcp/pkg/tools" + "github.com/go-go-golems/go-go-mcp/pkg/tools/providers/tool-registry" ) -func RegisterFetchTool(registry *tools.Registry) error { +func RegisterFetchTool(registry *tool_registry.Registry) error { fetchSchemaJson := `{ "type": "object", "properties": { diff --git a/pkg/tools/examples/sqlite.go b/pkg/tools/examples/sqlite.go index ebd1f99..3f7f7ad 100644 --- a/pkg/tools/examples/sqlite.go +++ b/pkg/tools/examples/sqlite.go @@ -8,13 +8,14 @@ import ( "github.com/go-go-golems/go-go-mcp/pkg/protocol" "github.com/go-go-golems/go-go-mcp/pkg/tools" + "github.com/go-go-golems/go-go-mcp/pkg/tools/providers/tool-registry" _ "github.com/mattn/go-sqlite3" "gopkg.in/yaml.v3" ) const defaultDBPath = "/home/manuel/.config/Cursor/User/globalStorage/state.vscdb" -func RegisterSQLiteTool(registry *tools.Registry) error { +func RegisterSQLiteTool(registry *tool_registry.Registry) error { schemaJson := `{ "type": "object", "properties": { diff --git a/pkg/tools/providers/config-provider/tool-provider.go b/pkg/tools/providers/config-provider/tool-provider.go index 2bc253b..a026a6c 100644 --- a/pkg/tools/providers/config-provider/tool-provider.go +++ b/pkg/tools/providers/config-provider/tool-provider.go @@ -172,7 +172,7 @@ func ConvertCommandToTool(desc *cmds.CommandDescription) (protocol.Tool, error) } // ListTools implements pkg.ToolProvider interface -func (p *ConfigToolProvider) ListTools(cursor string) ([]protocol.Tool, string, error) { +func (p *ConfigToolProvider) ListTools(_ context.Context, cursor string) ([]protocol.Tool, string, error) { var tools []protocol.Tool repoCommands := p.repository.CollectCommands([]string{}, true) diff --git a/pkg/tools/registry.go b/pkg/tools/providers/tool-registry/registry.go similarity index 80% rename from pkg/tools/registry.go rename to pkg/tools/providers/tool-registry/registry.go index e41d0b3..995d56f 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/providers/tool-registry/registry.go @@ -1,4 +1,4 @@ -package tools +package tool_registry import ( "context" @@ -7,37 +7,38 @@ import ( "github.com/go-go-golems/go-go-mcp/pkg" "github.com/go-go-golems/go-go-mcp/pkg/protocol" + "github.com/go-go-golems/go-go-mcp/pkg/tools" ) // Registry provides a simple way to register individual tools type Registry struct { mu sync.RWMutex - tools map[string]Tool + tools map[string]tools.Tool handlers map[string]Handler } // Handler is a function that executes a tool with given arguments -type Handler func(ctx context.Context, tool Tool, arguments map[string]interface{}) (*protocol.ToolResult, error) +type Handler func(ctx context.Context, tool tools.Tool, arguments map[string]interface{}) (*protocol.ToolResult, error) var _ pkg.ToolProvider = &Registry{} // NewRegistry creates a new tool registry func NewRegistry() *Registry { return &Registry{ - tools: make(map[string]Tool), + tools: make(map[string]tools.Tool), handlers: make(map[string]Handler), } } // RegisterTool adds a tool to the registry -func (r *Registry) RegisterTool(tool Tool) { +func (r *Registry) RegisterTool(tool tools.Tool) { r.mu.Lock() defer r.mu.Unlock() r.tools[tool.GetName()] = tool } // RegisterToolWithHandler adds a tool with a custom handler -func (r *Registry) RegisterToolWithHandler(tool Tool, handler Handler) { +func (r *Registry) RegisterToolWithHandler(tool tools.Tool, handler Handler) { r.mu.Lock() defer r.mu.Unlock() r.tools[tool.GetName()] = tool @@ -53,7 +54,7 @@ func (r *Registry) UnregisterTool(name string) { } // ListTools implements ToolProvider interface -func (r *Registry) ListTools(cursor string) ([]protocol.Tool, string, error) { +func (r *Registry) ListTools(_ context.Context, cursor string) ([]protocol.Tool, string, error) { r.mu.RLock() defer r.mu.RUnlock() diff --git a/pkg/transport/errors.go b/pkg/transport/errors.go new file mode 100644 index 0000000..c6da4e2 --- /dev/null +++ b/pkg/transport/errors.go @@ -0,0 +1,64 @@ +package transport + +import "fmt" + +// Common error codes +const ( + ErrCodeParse = -32700 + ErrCodeInvalidRequest = -32600 + ErrCodeMethodNotFound = -32601 + ErrCodeInvalidParams = -32602 + ErrCodeInternal = -32603 + ErrCodeTransport = -32500 + ErrCodeTimeout = -32501 +) + +// Error constructors +func NewParseError(msg string) *ResponseError { + return &ResponseError{ + Code: ErrCodeParse, + Message: fmt.Sprintf("Parse error: %s", msg), + } +} + +func NewInvalidRequestError(msg string) *ResponseError { + return &ResponseError{ + Code: ErrCodeInvalidRequest, + Message: fmt.Sprintf("Invalid request: %s", msg), + } +} + +func NewMethodNotFoundError(msg string) *ResponseError { + return &ResponseError{ + Code: ErrCodeMethodNotFound, + Message: fmt.Sprintf("Method not found: %s", msg), + } +} + +func NewInvalidParamsError(msg string) *ResponseError { + return &ResponseError{ + Code: ErrCodeInvalidParams, + Message: fmt.Sprintf("Invalid params: %s", msg), + } +} + +func NewInternalError(msg string) *ResponseError { + return &ResponseError{ + Code: ErrCodeInternal, + Message: fmt.Sprintf("Internal error: %s", msg), + } +} + +func NewTransportError(msg string) *ResponseError { + return &ResponseError{ + Code: ErrCodeTransport, + Message: fmt.Sprintf("Transport error: %s", msg), + } +} + +func NewTimeoutError(msg string) *ResponseError { + return &ResponseError{ + Code: ErrCodeTimeout, + Message: fmt.Sprintf("Timeout error: %s", msg), + } +} diff --git a/pkg/transport/options.go b/pkg/transport/options.go new file mode 100644 index 0000000..3c6ac41 --- /dev/null +++ b/pkg/transport/options.go @@ -0,0 +1,81 @@ +package transport + +import ( + "crypto/tls" + "net/http" + "os" + + "github.com/gorilla/mux" + "github.com/rs/zerolog" +) + +// TransportOptions contains common options for all transports +type TransportOptions struct { + // Common options + MaxMessageSize int64 + Logger zerolog.Logger + + // Transport specific options + SSE *SSEOptions + Stdio *StdioOptions +} + +// SSEOptions contains SSE-specific transport options +type SSEOptions struct { + // HTTP server configuration + Addr string + TLSConfig *tls.Config + + // Middleware + Middleware []func(http.Handler) http.Handler + + // Router configuration + Router *mux.Router // Optional: existing router to use + PathPrefix string // Optional: prefix for all SSE endpoints +} + +// StdioOptions contains stdio-specific transport options +type StdioOptions struct { + // Buffer sizes + ReadBufferSize int + WriteBufferSize int + + // Process management + Command string + Args []string + WorkingDir string + Environment map[string]string + + // Signal handling + SignalHandlers map[os.Signal]func() +} + +// TransportOption is a function that modifies TransportOptions +type TransportOption func(*TransportOptions) + +// Common option constructors +func WithLogger(logger zerolog.Logger) TransportOption { + return func(o *TransportOptions) { + o.Logger = logger + } +} + +func WithMaxMessageSize(size int64) TransportOption { + return func(o *TransportOptions) { + o.MaxMessageSize = size + } +} + +// SSE-specific options +func WithSSEOptions(opts SSEOptions) TransportOption { + return func(o *TransportOptions) { + o.SSE = &opts + } +} + +// Stdio-specific options +func WithStdioOptions(opts StdioOptions) TransportOption { + return func(o *TransportOptions) { + o.Stdio = &opts + } +} diff --git a/pkg/transport/sse/transport.go b/pkg/transport/sse/transport.go new file mode 100644 index 0000000..5eda558 --- /dev/null +++ b/pkg/transport/sse/transport.go @@ -0,0 +1,344 @@ +package sse + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/go-go-golems/go-go-mcp/pkg/transport" + "github.com/google/uuid" + "github.com/gorilla/mux" + "github.com/rs/zerolog" +) + +// contextKey is a custom type for context keys to avoid collisions +type contextKey string + +const ( + sessionIDKey contextKey = "sessionID" +) + +// GetSessionID retrieves the session ID from the context. +// Returns an empty string and false if not found. +func GetSessionID(ctx context.Context) (string, bool) { + sessionID, ok := ctx.Value(sessionIDKey).(string) + return sessionID, ok +} + +type SSETransport struct { + mu sync.RWMutex + logger zerolog.Logger + clients map[string]*SSEClient + server *http.Server + port int + handler transport.RequestHandler + nextID int + wg sync.WaitGroup + cancel context.CancelFunc + standalone bool // indicates if we manage our own server +} + +type SSEClient struct { + id string + sessionID string + messageChan chan *transport.Response + createdAt time.Time + remoteAddr string + userAgent string +} + +// SSEHandlers contains the HTTP handlers for SSE endpoints +type SSEHandlers struct { + SSEHandler http.HandlerFunc + MessageHandler http.HandlerFunc +} + +func NewSSETransport(opts ...transport.TransportOption) (*SSETransport, error) { + options := &transport.TransportOptions{ + MaxMessageSize: 1024 * 1024, // 1MB default + Logger: zerolog.Nop(), + } + + for _, opt := range opts { + opt(options) + } + + if options.SSE == nil { + return nil, fmt.Errorf("SSE options are required") + } + + s := &SSETransport{ + logger: options.Logger, + clients: make(map[string]*SSEClient), + port: 8080, // Default port + standalone: true, // Default to standalone mode + } + + // If middleware is provided, we assume we're not standalone + if len(options.SSE.Middleware) > 0 { + s.standalone = false + } + + return s, nil +} + +// GetHandlers returns the HTTP handlers for SSE endpoints +func (s *SSETransport) GetHandlers() *SSEHandlers { + return &SSEHandlers{ + SSEHandler: s.handleSSE, + MessageHandler: s.handleMessages, + } +} + +// RegisterHandlers registers the SSE handlers with the provided router +func (s *SSETransport) RegisterHandlers(r *mux.Router) { + r.HandleFunc("/sse", s.handleSSE).Methods("GET") + r.HandleFunc("/messages", s.handleMessages).Methods("POST", "OPTIONS") +} + +func (s *SSETransport) Listen(ctx context.Context, handler transport.RequestHandler) error { + ctx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.handler = handler + + if s.standalone { + r := mux.NewRouter() + s.RegisterHandlers(r) + + s.server = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: r, + BaseContext: func(l net.Listener) context.Context { + return ctx + }, + } + + errChan := make(chan error, 1) + go func() { + s.logger.Info().Int("port", s.port).Msg("Starting standalone SSE transport") + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + close(errChan) + }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + return s.Close(context.Background()) + } + } else { + // In non-standalone mode, we just wait for context cancellation + s.logger.Info().Msg("Starting integrated SSE transport") + <-ctx.Done() + return s.Close(context.Background()) + } +} + +func (s *SSETransport) Send(ctx context.Context, response *transport.Response) error { + s.mu.RLock() + defer s.mu.RUnlock() + + // Find client by session ID from context + sessionID, ok := GetSessionID(ctx) + if !ok { + return fmt.Errorf("session ID not found in context") + } + + var targetClients []*SSEClient + for _, client := range s.clients { + if client.sessionID == sessionID { + targetClients = append(targetClients, client) + } + } + + if len(targetClients) == 0 { + return fmt.Errorf("no clients found for session %s", sessionID) + } + + // Send to all clients in the session + for _, client := range targetClients { + select { + case client.messageChan <- response: + s.logger.Debug(). + Str("client_id", client.id). + Str("sessionId", sessionID). + Interface("response", response). + Msg("Response sent to client") + default: + s.logger.Error(). + Str("client_id", client.id). + Str("sessionId", sessionID). + Msg("Failed to send response to client") + } + } + + return nil +} + +func (s *SSETransport) Close(ctx context.Context) error { + s.mu.Lock() + + if s.server != nil { + s.logger.Info().Msg("Stopping SSE transport") + + if s.cancel != nil { + s.cancel() + } + + for sessionID, client := range s.clients { + s.logger.Debug().Str("sessionId", sessionID).Msg("Closing client connection") + close(client.messageChan) + delete(s.clients, sessionID) + } + + s.mu.Unlock() + + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + + select { + case <-done: + s.logger.Debug().Msg("All client goroutines finished") + case <-ctx.Done(): + s.logger.Warn().Msg("Timeout waiting for client goroutines") + } + + if err := s.server.Shutdown(ctx); err != nil { + return fmt.Errorf("error shutting down server: %w", err) + } + + return nil + } + + s.mu.Unlock() + return nil +} + +func (s *SSETransport) Info() transport.TransportInfo { + return transport.TransportInfo{ + Type: "sse", + Capabilities: map[string]bool{ + "bidirectional": true, + "persistent": true, + }, + Metadata: map[string]string{ + "port": fmt.Sprintf("%d", s.port), + }, + } +} + +func (s *SSETransport) handleSSE(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + sessionID := r.URL.Query().Get("sessionId") + if sessionID == "" { + sessionID = uuid.New().String() + } + + s.mu.Lock() + s.nextID++ + clientID := fmt.Sprintf("client-%d", s.nextID) + client := &SSEClient{ + id: clientID, + sessionID: sessionID, + messageChan: make(chan *transport.Response, 100), + createdAt: time.Now(), + remoteAddr: r.RemoteAddr, + userAgent: r.UserAgent(), + } + s.clients[clientID] = client + s.mu.Unlock() + + s.wg.Add(1) + defer s.wg.Done() + defer func() { + s.mu.Lock() + if c, exists := s.clients[clientID]; exists { + close(c.messageChan) + delete(s.clients, clientID) + } + s.mu.Unlock() + }() + + // Send initial endpoint event + endpoint := fmt.Sprintf("%s?sessionId=%s", "/messages", sessionID) + fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n", endpoint) + w.(http.Flusher).Flush() + + for { + select { + case msg := <-client.messageChan: + if msg == nil { + return + } + + data, err := json.Marshal(msg) + if err != nil { + s.logger.Error().Err(err).Interface("message", msg).Msg("Failed to marshal message") + continue + } + + fmt.Fprintf(w, "event: message\ndata: %s\n\n", data) + w.(http.Flusher).Flush() + + case <-ctx.Done(): + return + } + } +} + +func (s *SSETransport) handleMessages(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + sessionID := r.URL.Query().Get("sessionId") + if sessionID == "" { + sessionID = "default" + } + + ctx := context.WithValue(r.Context(), sessionIDKey, sessionID) + + var request transport.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + s.logger.Error().Err(err).Msg("Failed to decode request") + w.WriteHeader(http.StatusBadRequest) + return + } + + response, err := s.handler.HandleRequest(ctx, &request) + if err != nil { + s.logger.Error().Err(err).Msg("Error handling request") + w.WriteHeader(http.StatusInternalServerError) + return + } + + if response != nil { + if err := s.Send(ctx, response); err != nil { + s.logger.Error().Err(err).Msg("Error sending response") + w.WriteHeader(http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusAccepted) +} diff --git a/pkg/transport/stdio/transport.go b/pkg/transport/stdio/transport.go new file mode 100644 index 0000000..9a2d463 --- /dev/null +++ b/pkg/transport/stdio/transport.go @@ -0,0 +1,284 @@ +package stdio + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/go-go-golems/go-go-mcp/pkg/transport" + "github.com/rs/zerolog" +) + +type StdioTransport struct { + mu sync.Mutex + logger zerolog.Logger + scanner *bufio.Scanner + writer *json.Encoder + handler transport.RequestHandler + signalChan chan os.Signal + wg sync.WaitGroup + cancel context.CancelFunc +} + +func NewStdioTransport(opts ...transport.TransportOption) (*StdioTransport, error) { + options := &transport.TransportOptions{ + MaxMessageSize: 1024 * 1024, // 1MB default + Logger: zerolog.Nop(), + } + + for _, opt := range opts { + opt(options) + } + + scanner := bufio.NewScanner(os.Stdin) + if options.Stdio != nil && options.Stdio.ReadBufferSize > 0 { + scanner.Buffer(make([]byte, options.Stdio.ReadBufferSize), options.Stdio.ReadBufferSize) + } else { + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB default + } + + // Create a ConsoleWriter that writes to stderr with a SERVER tag + consoleWriter := zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: time.RFC3339, + FormatMessage: func(i interface{}) string { + return fmt.Sprintf("[STDIO] %s", i) + }, + } + + // Create a new logger that writes to the tagged stderr + taggedLogger := options.Logger.Output(consoleWriter) + + return &StdioTransport{ + scanner: scanner, + writer: json.NewEncoder(os.Stdout), + logger: taggedLogger, + signalChan: make(chan os.Signal, 1), + }, nil +} + +func (s *StdioTransport) Listen(ctx context.Context, handler transport.RequestHandler) error { + s.logger.Info().Msg("Starting stdio transport...") + + ctx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.handler = handler + + // Set up signal handling + signal.Notify(s.signalChan, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(s.signalChan) + + // Create a channel for scanner errors + scanErrChan := make(chan error, 1) + + // Create a cancellable context for the scanner + scannerCtx, cancelScanner := context.WithCancel(ctx) + defer cancelScanner() + + // Start scanning in a goroutine + s.wg.Add(1) + go func() { + defer s.wg.Done() + for s.scanner.Scan() { + select { + case <-scannerCtx.Done(): + s.logger.Debug().Msg("Context cancelled, stopping scanner") + scanErrChan <- scannerCtx.Err() + return + default: + line := s.scanner.Text() + s.logger.Debug(). + Str("line", line). + Msg("Received line") + if err := s.handleMessage(line); err != nil { + s.logger.Error().Err(err).Msg("Error handling message") + // Continue processing messages even if one fails + } + } + } + + if err := s.scanner.Err(); err != nil { + s.logger.Error(). + Err(err). + Msg("Scanner error") + scanErrChan <- fmt.Errorf("scanner error: %w", err) + return + } + + s.logger.Debug().Msg("Scanner reached EOF") + scanErrChan <- io.EOF + }() + + // Wait for either a signal, context cancellation, or scanner error + select { + case sig := <-s.signalChan: + s.logger.Debug(). + Str("signal", sig.String()). + Msg("Received signal in stdio transport") + cancelScanner() + return nil + case <-ctx.Done(): + s.logger.Debug(). + Err(ctx.Err()). + Msg("Context cancelled in stdio transport") + return ctx.Err() + case err := <-scanErrChan: + if err == io.EOF { + s.logger.Debug().Msg("Scanner completed normally") + return nil + } + s.logger.Error(). + Err(err). + Msg("Scanner error in stdio transport") + return err + } +} + +func (s *StdioTransport) Send(ctx context.Context, response *transport.Response) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.logger.Debug().Interface("response", response).Msg("Sending response") + return s.writer.Encode(response) +} + +func (s *StdioTransport) Close(ctx context.Context) error { + s.logger.Info().Msg("Stopping stdio transport") + + if s.cancel != nil { + s.cancel() + } + + // Wait for context to be done or timeout + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + + select { + case <-done: + s.logger.Debug().Msg("All goroutines finished") + return nil + case <-ctx.Done(): + s.logger.Debug(). + Err(ctx.Err()). + Msg("Stop context cancelled before clean shutdown") + return ctx.Err() + case <-time.After(100 * time.Millisecond): // Give a small grace period for cleanup + s.logger.Debug().Msg("Stop completed with timeout") + return nil + } +} + +func (s *StdioTransport) Info() transport.TransportInfo { + return transport.TransportInfo{ + Type: "stdio", + Capabilities: map[string]bool{ + "bidirectional": true, + "persistent": false, + }, + Metadata: map[string]string{ + "pid": fmt.Sprintf("%d", os.Getpid()), + }, + } +} + +func (s *StdioTransport) handleMessage(message string) error { + s.logger.Debug(). + Str("message", message). + Msg("Processing message") + + // Parse the base message structure + var request transport.Request + if err := json.Unmarshal([]byte(message), &request); err != nil { + s.logger.Error(). + Err(err). + Str("message", message). + Msg("Failed to parse message as JSON-RPC request") + return s.sendError(nil, transport.ErrCodeParse, "Parse error", err) + } + + // Handle requests vs notifications based on ID presence + if request.ID != "" { + s.logger.Debug(). + Str("id", request.ID). + Str("method", request.Method). + Msg("Handling request") + return s.handleRequest(request) + } + + s.logger.Debug(). + Str("method", request.Method). + Msg("Handling notification") + return s.handleNotification(request) +} + +func (s *StdioTransport) handleRequest(request transport.Request) error { + response, err := s.handler.HandleRequest(context.Background(), &request) + if err != nil { + s.logger.Error(). + Err(err). + Str("method", request.Method). + Msg("Error handling request") + return s.sendError(&request.ID, transport.ErrCodeInternal, "Internal error", err) + } + + if response != nil { + return s.Send(context.Background(), response) + } + + return nil +} + +func (s *StdioTransport) handleNotification(request transport.Request) error { + notification := &transport.Notification{ + Method: request.Method, + Params: request.Params, + Headers: request.Headers, + } + + if err := s.handler.HandleNotification(context.Background(), notification); err != nil { + s.logger.Error(). + Err(err). + Str("method", request.Method). + Msg("Error handling notification") + // Don't send error responses for notifications + } + + return nil +} + +func (s *StdioTransport) sendError(id *string, code int, message string, data interface{}) error { + var errorData json.RawMessage + if data != nil { + var err error + errorData, err = json.Marshal(data) + if err != nil { + // If we can't marshal the error data, log it and send a simpler error + s.logger.Error().Err(err).Interface("data", data).Msg("Failed to marshal error data") + return s.sendError(id, transport.ErrCodeInternal, "Internal error marshaling error data", nil) + } + } + + response := &transport.Response{ + Error: &transport.ResponseError{ + Code: code, + Message: message, + Data: errorData, + }, + } + if id != nil { + response.ID = *id + } + + s.logger.Debug().Interface("response", response).Msg("Sending error response") + return s.Send(context.Background(), response) +} diff --git a/pkg/transport/transport.go b/pkg/transport/transport.go new file mode 100644 index 0000000..2a74101 --- /dev/null +++ b/pkg/transport/transport.go @@ -0,0 +1,73 @@ +package transport + +import ( + "context" + "encoding/json" + "fmt" +) + +// Transport handles the low-level communication between client and server +type Transport interface { + // Listen starts accepting requests and forwards them to the handler + Listen(ctx context.Context, handler RequestHandler) error + + // Send transmits a response back to the client + Send(ctx context.Context, response *Response) error + + // Close cleanly shuts down the transport + Close(ctx context.Context) error + + // Info returns metadata about the transport + Info() TransportInfo +} + +// TransportInfo provides metadata about the transport +type TransportInfo struct { + Type string // "sse", "stdio", etc. + RemoteAddr string // Remote address if applicable + Capabilities map[string]bool // Transport capabilities + Metadata map[string]string // Additional transport metadata +} + +// RequestHandler processes incoming requests and notifications +type RequestHandler interface { + // HandleRequest processes a request and returns a response + HandleRequest(ctx context.Context, req *Request) (*Response, error) + + // HandleNotification processes a notification (no response expected) + HandleNotification(ctx context.Context, notif *Notification) error +} + +// Request represents an incoming JSON-RPC request +type Request struct { + ID string + Method string + Params json.RawMessage + Headers map[string]string +} + +// Response represents an outgoing JSON-RPC response +type Response struct { + ID string + Result json.RawMessage + Error *ResponseError + Headers map[string]string +} + +// Notification represents an incoming notification +type Notification struct { + Method string + Params json.RawMessage + Headers map[string]string +} + +// ResponseError represents a JSON-RPC error response +type ResponseError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +func (r *ResponseError) Error() string { + return fmt.Sprintf("code: %d, message: %s, data: %s", r.Code, r.Message, string(r.Data)) +} diff --git a/ttmp/2025-02-10/01-design-config-file-format.md b/ttmp/2025-02-10/01-design-config-file-format.md index 2851337..a0f0924 100644 --- a/ttmp/2025-02-10/01-design-config-file-format.md +++ b/ttmp/2025-02-10/01-design-config-file-format.md @@ -350,7 +350,7 @@ profiles: Command-line usage: ```bash -go-go-mcp start --config-file config.yaml --profile development +go-go-mcp server start --config-file config.yaml --profile development ``` ## Error Handling Examples diff --git a/ttmp/2025-02-14/01-rfc-transport-layer.md b/ttmp/2025-02-14/01-rfc-transport-layer.md index fe0360d..987a51a 100644 --- a/ttmp/2025-02-14/01-rfc-transport-layer.md +++ b/ttmp/2025-02-14/01-rfc-transport-layer.md @@ -90,9 +90,6 @@ type Notification struct { type TransportOptions struct { // Common options MaxMessageSize int64 - ReadTimeout time.Duration - WriteTimeout time.Duration - MaxConcurrentRequests int Logger zerolog.Logger // Transport specific options @@ -105,16 +102,6 @@ type SSEOptions struct { // HTTP server configuration Addr string TLSConfig *tls.Config - ReadBufferSize int - WriteBufferSize int - - // SSE specific settings - HeartbeatInterval time.Duration - RetryInterval time.Duration - - // Connection management - MaxConnections int - IdleTimeout time.Duration // Middleware Middleware []func(http.Handler) http.Handler @@ -254,49 +241,14 @@ The implementation will follow these steps: 1. Create new transport package with core interfaces 2. Implement SSE transport 3. Implement stdio transport -4. Add tests for both implementations -5. Create migration guide for existing code 6. Update server to use new transport layer -### Testing Strategy - -1. Unit tests for each transport implementation -2. Integration tests with mock handlers -3. Benchmark tests for performance comparison -4. Stress tests for concurrent handling -5. Error handling tests - -Example test: -```go -func TestSSETransport(t *testing.T) { - // Create transport - transport, err := NewSSETransport(":0", WithLogger(zerolog.Nop())) - require.NoError(t, err) - - // Create mock handler - handler := &MockRequestHandler{} - - // Start transport - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - go func() { - err := transport.Listen(ctx, handler) - require.NoError(t, err) - }() - - // Test requests - // ... -} -``` - ## Migration Guide 1. Update imports to use new transport package 2. Replace direct transport usage with interface 3. Update error handling to use new error types 4. Add transport options where needed -5. Update tests to use new interfaces Example: ```go @@ -453,8 +405,6 @@ client := NewClient(logger, NewSSETransport(baseURL)) transport, err := sse.NewTransport( transport.WithSSEOptions(sse.Options{ Addr: baseURL, - HeartbeatInterval: 30 * time.Second, - MaxConnections: 100, }), transport.WithLogger(logger), ) @@ -562,18 +512,3 @@ func TestSSETransport(t *testing.T) { // ... } ``` - -### Phase 6: Documentation and Examples (Week 7-8) - -1. Update documentation: - - Add migration guide - - Document new interfaces - - Provide examples for each transport - - Update API documentation - -2. Create example applications: - - Basic client/server - - Custom transport implementation - - Testing with mock transport - -[Rest of the RFC remains unchanged...] \ No newline at end of file From c0637c77a381b4ce0c9f7f4c77a2496f24c94d6d Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Sun, 16 Feb 2025 11:40:43 -0500 Subject: [PATCH 04/10] :sparkles: Polish new protocol structure --- README.md | 10 ++--- changelog.md | 61 +++++++++++++++++++++++++++- cmd/go-go-mcp/cmds/server/start.go | 4 +- pkg/protocol/base.go | 9 ++++- pkg/server/handler.go | 26 ++++++------ pkg/transport/errors.go | 34 +++++++++------- pkg/transport/sse/transport.go | 9 +++-- pkg/transport/stdio/transport.go | 39 +++++++++--------- pkg/transport/transport.go | 65 +++++++++++++----------------- 9 files changed, 160 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 1dba535..ab20585 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,10 @@ Start the server with either stdio or SSE transport: ```bash # Start with stdio transport (default) -go-go-mcp start --transport stdio +go-go-mcp server start --transport stdio # Start with SSE transport -go-go-mcp start --transport sse --port 3001 +go-go-mcp server start --transport sse --port 3001 ``` The server automatically watches configured repositories and files for changes, reloading tools when: @@ -142,7 +142,7 @@ go-go-mcp server tools list --profile data Use the client subcommand to interact with an MCP server: ```bash -# List available prompts (uses default server: go-go-mcp start --transport stdio) +# List available prompts (uses default server: go-go-mcp server start --transport stdio) go-go-mcp client prompts list # List available tools @@ -171,7 +171,7 @@ go-go-mcp can be used as a bridge to expose an SSE server as a stdio server. Thi ```bash # Start an SSE server on port 3000 -go-go-mcp start --transport sse --port 3000 +go-go-mcp server start --transport sse --port 3000 # In another terminal, start the bridge to expose the SSE server as stdio go-go-mcp bridge --sse-url http://localhost:3000 --log-level debug @@ -186,7 +186,7 @@ This is particularly useful when integrating with tools that only support stdio Add the `--debug` flag to enable detailed logging: ```bash -go-go-mcp start --debug +go-go-mcp server start --debug ``` ### Version Information diff --git a/changelog.md b/changelog.md index 1ed436c..8a1b64e 100644 --- a/changelog.md +++ b/changelog.md @@ -1063,4 +1063,63 @@ Updated cobra command handling to support both full and minimal Glazed command l - Added support for GlazedMinimalCommandLayer in cobra command processing - Unified handling of common flags (print-yaml, print-parsed-parameters, etc.) between both layers - Maintained backward compatibility with full GlazedCommandLayer features -- Added placeholder for schema printing functionality \ No newline at end of file +- Added placeholder for schema printing functionality + +# Transport Layer Refactoring + +Implemented new transport layer architecture as described in RFC-01. This change: +- Creates a clean interface for different transport mechanisms +- Separates transport concerns from business logic +- Provides consistent error handling across transports +- Adds support for transport-specific options and capabilities + +- Created new transport package with core interfaces and types +- Implemented SSE transport using new architecture +- Added transport options system +- Added standardized error handling + +# Transport Layer Implementation + +Added stdio transport implementation using new transport layer architecture: +- Implemented stdio transport with proper signal handling and graceful shutdown +- Added support for configurable buffer sizes and logging +- Added proper error handling and JSON-RPC message processing +- Added context-based cancellation and cleanup + +# Server Layer Updates + +Updated server implementation to use new transport layer: +- Refactored Server struct to use transport interface +- Added RequestHandler to implement transport.RequestHandler interface +- Updated server command to support multiple transport types +- Improved error handling and logging throughout server layer + +# Enhanced SSE Transport + +Added support for integrating SSE transport with existing HTTP servers: +- Added standalone and integrated modes for SSE transport +- Added GetHandlers method to get SSE endpoint handlers +- Added RegisterHandlers method for router integration +- Added support for path prefixes and middleware +- Improved configuration options for HTTP server integration + +# Transport Interface Refactoring + +Simplified transport interface to use protocol types directly instead of custom types. +- Removed duplicate type definitions from transport package +- Use protocol.Request/Response/Notification types directly +- Improved type safety by removing interface{} usage + +# Transport Request ID Handling + +Added proper request ID handling to transport package: +- Added IsNotification helper to check for empty/null request IDs +- Improved notification detection for JSON-RPC messages +- Consistent handling of request IDs across transports + +# Transport ID Type Conversion + +Added helper functions for converting between string and JSON-RPC ID types: +- Added StringToID to convert string to json.RawMessage +- Added IDToString to convert json.RawMessage to string +- Improved type safety in ID handling across transports \ No newline at end of file diff --git a/cmd/go-go-mcp/cmds/server/start.go b/cmd/go-go-mcp/cmds/server/start.go index e66b31e..fe80ada 100644 --- a/cmd/go-go-mcp/cmds/server/start.go +++ b/cmd/go-go-mcp/cmds/server/start.go @@ -128,7 +128,9 @@ func (c *StartCommand) Run( // Start file watcher g.Go(func() error { if err := toolProvider.Watch(gctx); err != nil { - logger.Error().Err(err).Msg("failed to start file watcher") + if !errors.Is(err, context.Canceled) { + logger.Error().Err(err).Msg("failed to run file watcher") + } return err } return nil diff --git a/pkg/protocol/base.go b/pkg/protocol/base.go index e131fc1..c2b5c6e 100644 --- a/pkg/protocol/base.go +++ b/pkg/protocol/base.go @@ -1,6 +1,9 @@ package protocol -import "encoding/json" +import ( + "encoding/json" + "fmt" +) // Request represents a JSON-RPC 2.0 request. type Request struct { @@ -25,6 +28,10 @@ type Error struct { Data json.RawMessage `json:"data,omitempty"` } +func (e *Error) Error() string { + return fmt.Sprintf("code: %d, message: %s, data: %s", e.Code, e.Message, e.Data) +} + // Notification represents a JSON-RPC 2.0 notification. type Notification struct { JSONRPC string `json:"jsonrpc"` diff --git a/pkg/server/handler.go b/pkg/server/handler.go index 785918a..9151dfc 100644 --- a/pkg/server/handler.go +++ b/pkg/server/handler.go @@ -21,9 +21,9 @@ func NewRequestHandler(s *Server) *RequestHandler { } // HandleRequest processes a request and returns a response -func (h *RequestHandler) HandleRequest(ctx context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) HandleRequest(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { // Validate JSON-RPC version - if req.Headers["jsonrpc"] != "2.0" { + if req.JSONRPC != "2.0" { return nil, transport.NewInvalidRequestError("invalid JSON-RPC version") } @@ -50,7 +50,7 @@ func (h *RequestHandler) HandleRequest(ctx context.Context, req *transport.Reque } // HandleNotification processes a notification (no response expected) -func (h *RequestHandler) HandleNotification(ctx context.Context, notif *transport.Notification) error { +func (h *RequestHandler) HandleNotification(ctx context.Context, notif *protocol.Notification) error { switch notif.Method { case "notifications/initialized": h.server.logger.Info().Msg("Client initialized") @@ -62,20 +62,20 @@ func (h *RequestHandler) HandleNotification(ctx context.Context, notif *transpor } // Helper method to create success response -func (h *RequestHandler) newSuccessResponse(id string, result interface{}) (*transport.Response, error) { +func (h *RequestHandler) newSuccessResponse(id json.RawMessage, result interface{}) (*protocol.Response, error) { resultJSON, err := json.Marshal(result) if err != nil { return nil, fmt.Errorf("failed to marshal result: %w", err) } - return &transport.Response{ + return &protocol.Response{ ID: id, Result: resultJSON, }, nil } // Individual request handlers -func (h *RequestHandler) handleInitialize(ctx context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) handleInitialize(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params protocol.InitializeParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { return nil, transport.NewInvalidParamsError(err.Error()) @@ -120,11 +120,11 @@ func (h *RequestHandler) handleInitialize(ctx context.Context, req *transport.Re return h.newSuccessResponse(req.ID, result) } -func (h *RequestHandler) handlePing(_ context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) handlePing(_ context.Context, req *protocol.Request) (*protocol.Response, error) { return h.newSuccessResponse(req.ID, struct{}{}) } -func (h *RequestHandler) handlePromptsList(ctx context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) handlePromptsList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { Cursor string `json:"cursor"` } @@ -147,7 +147,7 @@ func (h *RequestHandler) handlePromptsList(ctx context.Context, req *transport.R }) } -func (h *RequestHandler) handlePromptsGet(ctx context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) handlePromptsGet(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { Name string `json:"name"` Arguments map[string]string `json:"arguments"` @@ -164,7 +164,7 @@ func (h *RequestHandler) handlePromptsGet(ctx context.Context, req *transport.Re return h.newSuccessResponse(req.ID, prompt) } -func (h *RequestHandler) handleResourcesList(ctx context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) handleResourcesList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { Cursor string `json:"cursor"` } @@ -187,7 +187,7 @@ func (h *RequestHandler) handleResourcesList(ctx context.Context, req *transport }) } -func (h *RequestHandler) handleResourcesRead(ctx context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) handleResourcesRead(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { Name string `json:"name"` } @@ -205,7 +205,7 @@ func (h *RequestHandler) handleResourcesRead(ctx context.Context, req *transport }) } -func (h *RequestHandler) handleToolsList(ctx context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) handleToolsList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { Cursor string `json:"cursor"` } @@ -228,7 +228,7 @@ func (h *RequestHandler) handleToolsList(ctx context.Context, req *transport.Req }) } -func (h *RequestHandler) handleToolsCall(ctx context.Context, req *transport.Request) (*transport.Response, error) { +func (h *RequestHandler) handleToolsCall(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { Name string `json:"name"` Arguments map[string]interface{} `json:"arguments"` diff --git a/pkg/transport/errors.go b/pkg/transport/errors.go index c6da4e2..6226e36 100644 --- a/pkg/transport/errors.go +++ b/pkg/transport/errors.go @@ -1,6 +1,10 @@ package transport -import "fmt" +import ( + "fmt" + + "github.com/go-go-golems/go-go-mcp/pkg/protocol" +) // Common error codes const ( @@ -14,50 +18,50 @@ const ( ) // Error constructors -func NewParseError(msg string) *ResponseError { - return &ResponseError{ +func NewParseError(msg string) *protocol.Error { + return &protocol.Error{ Code: ErrCodeParse, Message: fmt.Sprintf("Parse error: %s", msg), } } -func NewInvalidRequestError(msg string) *ResponseError { - return &ResponseError{ +func NewInvalidRequestError(msg string) *protocol.Error { + return &protocol.Error{ Code: ErrCodeInvalidRequest, Message: fmt.Sprintf("Invalid request: %s", msg), } } -func NewMethodNotFoundError(msg string) *ResponseError { - return &ResponseError{ +func NewMethodNotFoundError(msg string) *protocol.Error { + return &protocol.Error{ Code: ErrCodeMethodNotFound, Message: fmt.Sprintf("Method not found: %s", msg), } } -func NewInvalidParamsError(msg string) *ResponseError { - return &ResponseError{ +func NewInvalidParamsError(msg string) *protocol.Error { + return &protocol.Error{ Code: ErrCodeInvalidParams, Message: fmt.Sprintf("Invalid params: %s", msg), } } -func NewInternalError(msg string) *ResponseError { - return &ResponseError{ +func NewInternalError(msg string) *protocol.Error { + return &protocol.Error{ Code: ErrCodeInternal, Message: fmt.Sprintf("Internal error: %s", msg), } } -func NewTransportError(msg string) *ResponseError { - return &ResponseError{ +func NewTransportError(msg string) *protocol.Error { + return &protocol.Error{ Code: ErrCodeTransport, Message: fmt.Sprintf("Transport error: %s", msg), } } -func NewTimeoutError(msg string) *ResponseError { - return &ResponseError{ +func NewTimeoutError(msg string) *protocol.Error { + return &protocol.Error{ Code: ErrCodeTimeout, Message: fmt.Sprintf("Timeout error: %s", msg), } diff --git a/pkg/transport/sse/transport.go b/pkg/transport/sse/transport.go index 5eda558..330bf16 100644 --- a/pkg/transport/sse/transport.go +++ b/pkg/transport/sse/transport.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/go-go-golems/go-go-mcp/pkg/protocol" "github.com/go-go-golems/go-go-mcp/pkg/transport" "github.com/google/uuid" "github.com/gorilla/mux" @@ -45,7 +46,7 @@ type SSETransport struct { type SSEClient struct { id string sessionID string - messageChan chan *transport.Response + messageChan chan *protocol.Response createdAt time.Time remoteAddr string userAgent string @@ -140,7 +141,7 @@ func (s *SSETransport) Listen(ctx context.Context, handler transport.RequestHand } } -func (s *SSETransport) Send(ctx context.Context, response *transport.Response) error { +func (s *SSETransport) Send(ctx context.Context, response *protocol.Response) error { s.mu.RLock() defer s.mu.RUnlock() @@ -255,7 +256,7 @@ func (s *SSETransport) handleSSE(w http.ResponseWriter, r *http.Request) { client := &SSEClient{ id: clientID, sessionID: sessionID, - messageChan: make(chan *transport.Response, 100), + messageChan: make(chan *protocol.Response, 100), createdAt: time.Now(), remoteAddr: r.RemoteAddr, userAgent: r.UserAgent(), @@ -318,7 +319,7 @@ func (s *SSETransport) handleMessages(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), sessionIDKey, sessionID) - var request transport.Request + var request protocol.Request if err := json.NewDecoder(r.Body).Decode(&request); err != nil { s.logger.Error().Err(err).Msg("Failed to decode request") w.WriteHeader(http.StatusBadRequest) diff --git a/pkg/transport/stdio/transport.go b/pkg/transport/stdio/transport.go index 9a2d463..9f5130c 100644 --- a/pkg/transport/stdio/transport.go +++ b/pkg/transport/stdio/transport.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/go-go-golems/go-go-mcp/pkg/protocol" "github.com/go-go-golems/go-go-mcp/pkg/transport" "github.com/rs/zerolog" ) @@ -141,7 +142,7 @@ func (s *StdioTransport) Listen(ctx context.Context, handler transport.RequestHa } } -func (s *StdioTransport) Send(ctx context.Context, response *transport.Response) error { +func (s *StdioTransport) Send(ctx context.Context, response *protocol.Response) error { s.mu.Lock() defer s.mu.Unlock() @@ -197,7 +198,7 @@ func (s *StdioTransport) handleMessage(message string) error { Msg("Processing message") // Parse the base message structure - var request transport.Request + var request protocol.Request if err := json.Unmarshal([]byte(message), &request); err != nil { s.logger.Error(). Err(err). @@ -207,9 +208,9 @@ func (s *StdioTransport) handleMessage(message string) error { } // Handle requests vs notifications based on ID presence - if request.ID != "" { + if !transport.IsNotification(&request) { s.logger.Debug(). - Str("id", request.ID). + RawJSON("id", request.ID). Str("method", request.Method). Msg("Handling request") return s.handleRequest(request) @@ -218,17 +219,20 @@ func (s *StdioTransport) handleMessage(message string) error { s.logger.Debug(). Str("method", request.Method). Msg("Handling notification") - return s.handleNotification(request) + return s.handleNotification(protocol.Notification{ + Method: request.Method, + Params: request.Params, + }) } -func (s *StdioTransport) handleRequest(request transport.Request) error { +func (s *StdioTransport) handleRequest(request protocol.Request) error { response, err := s.handler.HandleRequest(context.Background(), &request) if err != nil { s.logger.Error(). Err(err). Str("method", request.Method). Msg("Error handling request") - return s.sendError(&request.ID, transport.ErrCodeInternal, "Internal error", err) + return s.sendError(request.ID, transport.ErrCodeInternal, "Internal error", err) } if response != nil { @@ -238,17 +242,12 @@ func (s *StdioTransport) handleRequest(request transport.Request) error { return nil } -func (s *StdioTransport) handleNotification(request transport.Request) error { - notification := &transport.Notification{ - Method: request.Method, - Params: request.Params, - Headers: request.Headers, - } +func (s *StdioTransport) handleNotification(notification protocol.Notification) error { - if err := s.handler.HandleNotification(context.Background(), notification); err != nil { + if err := s.handler.HandleNotification(context.Background(), ¬ification); err != nil { s.logger.Error(). Err(err). - Str("method", request.Method). + Str("method", notification.Method). Msg("Error handling notification") // Don't send error responses for notifications } @@ -256,7 +255,7 @@ func (s *StdioTransport) handleNotification(request transport.Request) error { return nil } -func (s *StdioTransport) sendError(id *string, code int, message string, data interface{}) error { +func (s *StdioTransport) sendError(id json.RawMessage, code int, message string, data interface{}) error { var errorData json.RawMessage if data != nil { var err error @@ -268,15 +267,13 @@ func (s *StdioTransport) sendError(id *string, code int, message string, data in } } - response := &transport.Response{ - Error: &transport.ResponseError{ + response := &protocol.Response{ + Error: &protocol.Error{ Code: code, Message: message, Data: errorData, }, - } - if id != nil { - response.ID = *id + ID: id, } s.logger.Debug().Interface("response", response).Msg("Sending error response") diff --git a/pkg/transport/transport.go b/pkg/transport/transport.go index 2a74101..cf9137a 100644 --- a/pkg/transport/transport.go +++ b/pkg/transport/transport.go @@ -3,7 +3,8 @@ package transport import ( "context" "encoding/json" - "fmt" + + "github.com/go-go-golems/go-go-mcp/pkg/protocol" ) // Transport handles the low-level communication between client and server @@ -12,7 +13,7 @@ type Transport interface { Listen(ctx context.Context, handler RequestHandler) error // Send transmits a response back to the client - Send(ctx context.Context, response *Response) error + Send(ctx context.Context, response *protocol.Response) error // Close cleanly shuts down the transport Close(ctx context.Context) error @@ -29,45 +30,37 @@ type TransportInfo struct { Metadata map[string]string // Additional transport metadata } -// RequestHandler processes incoming requests and notifications -type RequestHandler interface { - // HandleRequest processes a request and returns a response - HandleRequest(ctx context.Context, req *Request) (*Response, error) - - // HandleNotification processes a notification (no response expected) - HandleNotification(ctx context.Context, notif *Notification) error +// IsNotification checks if a request is a notification (no ID) +func IsNotification(req *protocol.Request) bool { + return req.ID == nil || string(req.ID) == "null" || len(req.ID) == 0 } -// Request represents an incoming JSON-RPC request -type Request struct { - ID string - Method string - Params json.RawMessage - Headers map[string]string +// StringToID converts a string to a JSON-RPC ID (json.RawMessage) +func StringToID(s string) json.RawMessage { + if s == "" { + return nil + } + // Quote the string to make it a valid JSON string + return json.RawMessage(`"` + s + `"`) } -// Response represents an outgoing JSON-RPC response -type Response struct { - ID string - Result json.RawMessage - Error *ResponseError - Headers map[string]string +// IDToString converts a JSON-RPC ID to a string +func IDToString(id json.RawMessage) string { + if id == nil { + return "" + } + var s string + if err := json.Unmarshal(id, &s); err != nil { + return string(id) + } + return s } -// Notification represents an incoming notification -type Notification struct { - Method string - Params json.RawMessage - Headers map[string]string -} - -// ResponseError represents a JSON-RPC error response -type ResponseError struct { - Code int `json:"code"` - Message string `json:"message"` - Data json.RawMessage `json:"data,omitempty"` -} +// RequestHandler processes incoming requests and notifications +type RequestHandler interface { + // HandleRequest processes a request and returns a response + HandleRequest(ctx context.Context, req *protocol.Request) (*protocol.Response, error) -func (r *ResponseError) Error() string { - return fmt.Sprintf("code: %d, message: %s, data: %s", r.Code, r.Message, string(r.Data)) + // HandleNotification processes a notification (no response expected) + HandleNotification(ctx context.Context, notif *protocol.Notification) error } From bfa10d70c0cb6d849e3ba21f1eca53d15cbe99db Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Sun, 16 Feb 2025 11:42:39 -0500 Subject: [PATCH 05/10] :arrow_up: Bump deps --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index a04b224..dfe6f44 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,10 @@ toolchain go1.23.3 require ( github.com/JohannesKaufmann/html-to-markdown v1.6.0 - github.com/go-go-golems/clay v0.1.26 - github.com/go-go-golems/geppetto v0.4.34 - github.com/go-go-golems/glazed v0.5.28 - github.com/go-go-golems/parka v0.5.17 + github.com/go-go-golems/clay v0.1.28 + github.com/go-go-golems/geppetto v0.4.35 + github.com/go-go-golems/glazed v0.5.29 + github.com/go-go-golems/parka v0.5.18 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/hpcloud/tail v1.0.0 diff --git a/go.sum b/go.sum index bc9c42e..2914b85 100644 --- a/go.sum +++ b/go.sum @@ -77,14 +77,14 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-go-golems/clay v0.1.26 h1:JUw2QR4FIZ03zGBu/Ty/wc0ekZ8LWIm333+lx10CMVo= -github.com/go-go-golems/clay v0.1.26/go.mod h1:oS1t0/5pDbMpO+WqkKh94ddokbivW3dDX8Zqvy4JxYw= -github.com/go-go-golems/geppetto v0.4.34 h1:kVQqVqrXPPa+4tRPqAxTbvOAomM9XKd255nuUdBFCfE= -github.com/go-go-golems/geppetto v0.4.34/go.mod h1:HGEsHKvH8HKH89CLWIcueYm46bue7LdFTtsFos3Uzyo= -github.com/go-go-golems/glazed v0.5.28 h1:5ipOnzkhjfuxEP0LARNuAiXe2syPIPnywAvY98th6tk= -github.com/go-go-golems/glazed v0.5.28/go.mod h1:/ZgeDXELDOcAkD505fijARmbF6x5Ev7oewNV4V6Andk= -github.com/go-go-golems/parka v0.5.17 h1:XIyqLFmMwd253+J+jdOTV1F5H8xjVChe6+4UvjDN4SM= -github.com/go-go-golems/parka v0.5.17/go.mod h1:gjUXXumO+yrysFQhbzuwKo+l2u5+eJoo51DRHFjlDoU= +github.com/go-go-golems/clay v0.1.28 h1:/SZUVof5MoLE2DOcpYF3hUuux1tUfaHdl2gnPgIYkS4= +github.com/go-go-golems/clay v0.1.28/go.mod h1:sUiDlOnC0zP8W47TX0xJKfOdPhHgk2UVjyQCGMrjzEs= +github.com/go-go-golems/geppetto v0.4.35 h1:QZ/aUWCwnn+7BrFDHQuwOr1CitySM7aFMsUfOzPNu48= +github.com/go-go-golems/geppetto v0.4.35/go.mod h1:HGEsHKvH8HKH89CLWIcueYm46bue7LdFTtsFos3Uzyo= +github.com/go-go-golems/glazed v0.5.29 h1:3KaYdZBmdIymFRwhAhscFbTrpwhLx37VIgCHN+0bDX8= +github.com/go-go-golems/glazed v0.5.29/go.mod h1:/ZgeDXELDOcAkD505fijARmbF6x5Ev7oewNV4V6Andk= +github.com/go-go-golems/parka v0.5.18 h1:Rsw/ujSkMQ+Xw8XsZiZ/FoTPF+tzPUAXUFA0Xyqxsuo= +github.com/go-go-golems/parka v0.5.18/go.mod h1:gjUXXumO+yrysFQhbzuwKo+l2u5+eJoo51DRHFjlDoU= github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= From 3bf8a9443c605ca65ea3855a5debb3ea27500b5a Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Fri, 21 Feb 2025 18:07:07 -0500 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=A8=20Add=20UI=20server=20for=20ren?= =?UTF-8?q?dering=20YAML=20UI=20definitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 69 +++ changelog.md | 26 +- cmd/ui-server/main.go | 34 ++ cmd/ui-server/server.go | 111 ++++ cmd/ui-server/templates.templ | 224 ++++++++ cmd/ui-server/templates_templ.go | 860 +++++++++++++++++++++++++++++++ go.mod | 5 +- go.sum | 19 + 8 files changed, 1345 insertions(+), 3 deletions(-) create mode 100644 cmd/ui-server/main.go create mode 100644 cmd/ui-server/server.go create mode 100644 cmd/ui-server/templates.templ create mode 100644 cmd/ui-server/templates_templ.go diff --git a/README.md b/README.md index ab20585..6676b8e 100644 --- a/README.md +++ b/README.md @@ -345,3 +345,72 @@ go-go-mcp help # Show examples for a topic go-go-mcp help --example ``` + +# UI DSL + +A simple YAML-based Domain Specific Language for defining user interfaces. + +## Components + +The DSL supports the following basic components: + +### Button +- `text`: Button label +- `type`: primary, secondary, danger, success +- `onclick`: JavaScript event handler + +### Title +- `content`: Heading text content + +### Text +- `content`: Text content + +### Input +- `type`: text, email, password, number, tel +- `placeholder`: Placeholder text +- `value`: Default value +- `required`: Boolean + +### Textarea +- `placeholder`: Placeholder text +- `rows`: Number of rows +- `cols`: Number of columns +- `value`: Default value + +### Checkbox +- `label`: Checkbox label +- `checked`: Boolean +- `required`: Boolean +- `name`: Form field name + +### List +- `type`: ul or ol +- `items`: Array of items or nested components + +## Common Attributes + +All components support these common attributes: +- `id`: Unique identifier +- `style`: Inline CSS +- `disabled`: Boolean +- `data`: Map of data attributes + +## Example + +```yaml +form: + id: signup-form + components: + - title: + content: Sign Up + - text: + content: Please fill in your details below. + - input: + type: email + placeholder: Email address + - button: + text: Submit + type: primary +``` + +See `ui-dsl.yaml` for more comprehensive examples. diff --git a/changelog.md b/changelog.md index 8a1b64e..2f277e7 100644 --- a/changelog.md +++ b/changelog.md @@ -1122,4 +1122,28 @@ Added proper request ID handling to transport package: Added helper functions for converting between string and JSON-RPC ID types: - Added StringToID to convert string to json.RawMessage - Added IDToString to convert json.RawMessage to string -- Improved type safety in ID handling across transports \ No newline at end of file +- Improved type safety in ID handling across transports + +# Simplified UI DSL + +Simplified the UI DSL by removing class attributes and creating distinct title and text elements: +- Removed class attributes from all components +- Added dedicated title element for headings +- Simplified text element to be just for paragraphs +- Updated documentation to reflect changes + +# UI DSL Implementation + +Created a YAML-based UI DSL for defining simple user interfaces. The DSL supports common UI components like buttons, text, inputs, textareas, checkboxes, and lists with a clean and intuitive syntax. + +- Added `ui-dsl.yaml` with component definitions and examples +- Added documentation in `README.md` +- Included support for common attributes across all components +- Added nested component support for complex layouts + +# UI Server Implementation +Added a new UI server that can render YAML UI definitions using HTMX and Bootstrap: +- Created a new command `ui-server` that serves UI definitions from YAML files +- Implemented templ templates for rendering UI components +- Added support for various UI components like buttons, inputs, forms, etc. +- Used HTMX for dynamic interactions and Bootstrap for styling \ No newline at end of file diff --git a/cmd/ui-server/main.go b/cmd/ui-server/main.go new file mode 100644 index 0000000..ff29df7 --- /dev/null +++ b/cmd/ui-server/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +var rootCmd = &cobra.Command{ + Use: "ui-server [directory]", + Short: "Start a UI server that renders YAML UI definitions", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + dir := args[0] + port, _ := cmd.Flags().GetInt("port") + + server := NewServer(dir) + return server.Start(ctx, port) + }, +} + +func init() { + rootCmd.Flags().IntP("port", "p", 8080, "Port to run the server on") +} diff --git a/cmd/ui-server/server.go b/cmd/ui-server/server.go new file mode 100644 index 0000000..91befb6 --- /dev/null +++ b/cmd/ui-server/server.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type Server struct { + dir string + pages map[string]UIDefinition +} + +type UIDefinition struct { + Components map[string]interface{} `yaml:",inline"` +} + +func NewServer(dir string) *Server { + return &Server{ + dir: dir, + pages: make(map[string]UIDefinition), + } +} + +func (s *Server) Start(ctx context.Context, port int) error { + if err := s.loadPages(); err != nil { + return fmt.Errorf("failed to load pages: %w", err) + } + + mux := http.NewServeMux() + + // Serve static files + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + // Index page + mux.HandleFunc("/", s.handleIndex()) + + // Individual pages + for name := range s.pages { + pagePath := "/" + strings.TrimSuffix(name, ".yaml") + mux.HandleFunc(pagePath, s.handlePage(name)) + } + + addr := fmt.Sprintf(":%d", port) + log.Printf("Starting server on %s", addr) + return http.ListenAndServe(addr, mux) +} + +func (s *Server) loadPages() error { + return filepath.WalkDir(s.dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() && strings.HasSuffix(d.Name(), ".yaml") { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + var def UIDefinition + if err := yaml.Unmarshal(data, &def); err != nil { + return fmt.Errorf("failed to parse YAML in %s: %w", path, err) + } + + relPath, err := filepath.Rel(s.dir, path) + if err != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, err) + } + + s.pages[relPath] = def + } + return nil + }) +} + +func (s *Server) handleIndex() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + component := indexTemplate(s.pages) + if err := component.Render(r.Context(), w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func (s *Server) handlePage(name string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + def, ok := s.pages[name] + if !ok { + http.NotFound(w, r) + return + } + + component := pageTemplate(name, def) + if err := component.Render(r.Context(), w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} diff --git a/cmd/ui-server/templates.templ b/cmd/ui-server/templates.templ new file mode 100644 index 0000000..b468858 --- /dev/null +++ b/cmd/ui-server/templates.templ @@ -0,0 +1,224 @@ +package main + +import ( + "fmt" + "strings" +) + +templ base(title string) { + + + + + + { title } + + + + + + + { children... } + + +} + +templ indexTemplate(pages map[string]UIDefinition) { + @base("UI Server - Index") { +
+
+

Available Pages

+
+ for name := range pages { + + { name } + + } +
+
+
+ } +} + +templ pageTemplate(name string, def UIDefinition) { + @base("UI Server - " + name) { +
+
+

{ name }

+ @renderComponents(def.Components) +
+
+ } +} + +templ renderComponents(components map[string]interface{}) { + for key, component := range components { + switch c := component.(type) { + case map[string]interface{}: + @renderComponent(key, c) + } + } +} + +templ renderComponent(typ string, props map[string]interface{}) { + switch typ { + case "button": + + case "title": +

+ if content, ok := props["content"].(string); ok { + { content } + } +

+ case "text": +

+ if content, ok := props["content"].(string); ok { + { content } + } +

+ case "input": + + case "textarea": + + case "checkbox": +
+ + if label, ok := props["label"].(string); ok { + + } +
+ case "list": + if typ, ok := props["type"].(string); ok { + if typ == "ul" { +
    + if items, ok := props["items"].([]interface{}); ok { + for _, item := range items { +
  • + switch i := item.(type) { + case string: + { i } + case map[string]interface{}: + @renderComponents(i) + } +
  • + } + } +
+ } else if typ == "ol" { +
    + if items, ok := props["items"].([]interface{}); ok { + for _, item := range items { +
  1. + switch i := item.(type) { + case string: + { i } + case map[string]interface{}: + @renderComponents(i) + } +
  2. + } + } +
+ } + } + case "form": +
+ if components, ok := props["components"].([]interface{}); ok { + for _, comp := range components { + if c, ok := comp.(map[string]interface{}); ok { + @renderComponents(c) + } + } + } +
+ } +} \ No newline at end of file diff --git a/cmd/ui-server/templates_templ.go b/cmd/ui-server/templates_templ.go new file mode 100644 index 0000000..788a773 --- /dev/null +++ b/cmd/ui-server/templates_templ.go @@ -0,0 +1,860 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package main + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "strings" +) + +func base(title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 14, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func indexTemplate(pages map[string]UIDefinition) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = base("UI Server - Index").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func pageTemplate(name string, def UIDefinition) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 51, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = renderComponents(def.Components).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = base("UI Server - "+name).Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func renderComponents(components map[string]interface{}) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + for key, component := range components { + switch c := component.(type) { + case map[string]interface{}: + templ_7745c5c3_Err = renderComponent(key, c).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + return templ_7745c5c3_Err + }) +} + +func renderComponent(typ string, props map[string]interface{}) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + switch typ { + case "button": + var templ_7745c5c3_Var12 = []any{ + "btn", + templ.KV("btn-primary", props["type"] == "primary"), + templ.KV("btn-secondary", props["type"] == "secondary"), + templ.KV("btn-danger", props["type"] == "danger"), + templ.KV("btn-success", props["type"] == "success"), + } + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if text, ok := props["text"].(string); ok { + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(text) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 89, Col: 10} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "title": + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if content, ok := props["content"].(string); ok { + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(content) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 99, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "text": + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if content, ok := props["content"].(string); ok { + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(content) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 109, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "input": + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "textarea": + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if value, ok := props["value"].(string); ok { + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(value) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 148, Col: 11} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "checkbox": + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if label, ok := props["label"].(string); ok { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case "list": + if typ, ok := props["type"].(string); ok { + if typ == "ul" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if items, ok := props["items"].([]interface{}); ok { + for _, item := range items { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + switch i := item.(type) { + case string: + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(i) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 182, Col: 12} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case map[string]interface{}: + templ_7745c5c3_Err = renderComponents(i).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if typ == "ol" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if items, ok := props["items"].([]interface{}); ok { + for _, item := range items { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
  1. ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + switch i := item.(type) { + case string: + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(i) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 197, Col: 12} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case map[string]interface{}: + templ_7745c5c3_Err = renderComponents(i).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
  2. ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + case "form": + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if components, ok := props["components"].([]interface{}); ok { + for _, comp := range components { + if c, ok := comp.(map[string]interface{}); ok { + templ_7745c5c3_Err = renderComponents(c).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/go.mod b/go.mod index dfe6f44..5d26cb3 100644 --- a/go.mod +++ b/go.mod @@ -29,10 +29,11 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible // indirect - github.com/PuerkitoBio/goquery v1.9.2 // indirect + github.com/PuerkitoBio/goquery v1.10.1 // indirect + github.com/a-h/templ v0.3.833 // indirect github.com/adrg/frontmatter v0.2.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect - github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2 v1.33.0 // indirect diff --git a/go.sum b/go.sum index 2914b85..f6ebdd9 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,9 @@ github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZC github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= +github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU= +github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk= github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= @@ -21,6 +24,7 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= @@ -259,6 +263,7 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= @@ -271,6 +276,9 @@ golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -278,6 +286,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= @@ -286,6 +295,9 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -302,21 +314,26 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= @@ -325,6 +342,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= From 7c8368d6c5f48bd7aee4055575ebb8a21ce84b1f Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Fri, 21 Feb 2025 18:07:30 -0500 Subject: [PATCH 07/10] :art: Add ui dsl yaml --- ttmp/2025-02-21/ui-dsl.yaml | 80 +++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 ttmp/2025-02-21/ui-dsl.yaml diff --git a/ttmp/2025-02-21/ui-dsl.yaml b/ttmp/2025-02-21/ui-dsl.yaml new file mode 100644 index 0000000..40d563e --- /dev/null +++ b/ttmp/2025-02-21/ui-dsl.yaml @@ -0,0 +1,80 @@ +# UI DSL Schema +# Each component has common attributes: +# - id: unique identifier (optional) +# - style: inline CSS (optional) +# - disabled: boolean (optional) +# - data: map of data attributes (optional) + +# Example components: +--- +button: + text: Click me + type: primary # primary, secondary, danger, success + id: submit-btn + disabled: false + onclick: alert('clicked') + +title: + content: Welcome to My App + id: main-title + +text: + content: This is a paragraph of text that explains something. + id: description + +input: + type: text # text, email, password, number, tel + placeholder: Enter your name + value: "" + required: true + id: name-input + data: + validate: true + maxlength: 50 + +textarea: + placeholder: Enter description + rows: 4 + cols: 50 + value: | + Default multiline + text content + +checkbox: + label: Accept terms + checked: false + required: true + name: terms + id: terms-checkbox + +list: + type: ul # ul or ol + items: + - First item + - Second item + - Third item with: + button: + text: Click me + type: secondary + +# Complex example with nested components +form: + id: signup-form + components: + - title: + content: Sign Up + - text: + content: Please fill in your details below. + - input: + type: email + placeholder: Email address + required: true + - input: + type: password + placeholder: Password + required: true + - checkbox: + label: Subscribe to newsletter + - button: + text: Submit + type: primary \ No newline at end of file From 984b18de5005b3d482afc5acfd2f12afae3db5db Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Sat, 22 Feb 2025 10:02:56 -0500 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9C=A8=20Add=20YAML=20source=20display?= =?UTF-8?q?=20with=20syntax=20highlighting=20to=20UI=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ui-server/templates.templ | 77 ++++++++++++++++++++++++++++------- ttmp/2025-02-21/changelog.md | 15 +++++++ 2 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 ttmp/2025-02-21/changelog.md diff --git a/cmd/ui-server/templates.templ b/cmd/ui-server/templates.templ index b468858..efc8460 100644 --- a/cmd/ui-server/templates.templ +++ b/cmd/ui-server/templates.templ @@ -3,6 +3,7 @@ package main import ( "fmt" "strings" + "gopkg.in/yaml.v3" ) templ base(title string) { @@ -15,6 +16,27 @@ templ base(title string) { + + + + + { children... } +
+
} @@ -100,28 +143,26 @@ templ pageTemplate(name string, def UIDefinition) { templ renderComponent(typ string, props map[string]interface{}) { switch typ { case "button": - + data-hx-on:click={ fmt.Sprintf("logToConsole('%s clicked')", id) } + if disabled, ok := props["disabled"].(bool); ok && disabled { + disabled="disabled" + } + class={ + "btn", + templ.KV("btn-primary", props["type"] == "primary"), + templ.KV("btn-secondary", props["type"] == "secondary"), + templ.KV("btn-danger", props["type"] == "danger"), + templ.KV("btn-success", props["type"] == "success"), + } + > + if text, ok := props["text"].(string); ok { + { text } + } + + } case "title":

case "checkbox": -
- + + if label, ok := props["label"].(string); ok { + } - if name, ok := props["name"].(string); ok { - name={ name } - } - if checked, ok := props["checked"].(bool); ok && checked { - checked="checked" - } - if required, ok := props["required"].(bool); ok && required { - required="required" - } - class="form-check-input" - /> - if label, ok := props["label"].(string); ok { - - } -
+ + } case "list": if typ, ok := props["type"].(string); ok { if typ == "ul" { @@ -242,23 +284,24 @@ templ renderComponent(typ string, props map[string]interface{}) { } } case "form": -
- if components, ok := props["components"].([]interface{}); ok { - for _, comp := range components { - if c, ok := comp.(map[string]interface{}); ok { - for typ, props := range c { - @renderComponent(typ, props.(map[string]interface{})) + data-hx-on:submit={ fmt.Sprintf("event.preventDefault(); logFormSubmit('%s', this)", id) } + class="needs-validation" + novalidate + > + if components, ok := props["components"].([]interface{}); ok { + for _, comp := range components { + if c, ok := comp.(map[string]interface{}); ok { + for typ, props := range c { + @renderComponent(typ, props.(map[string]interface{})) + } } } } - } -
+ + } } } diff --git a/cmd/ui-server/templates_templ.go b/cmd/ui-server/templates_templ.go index 788a773..8e6bf24 100644 --- a/cmd/ui-server/templates_templ.go +++ b/cmd/ui-server/templates_templ.go @@ -175,26 +175,17 @@ func pageTemplate(name string, def UIDefinition) templ.Component { }() } ctx = templ.InitializeContext(ctx) - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 51, Col: 14} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = renderComponents(def.Components).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + for _, component := range def.Components { + for typ, props := range component { + templ_7745c5c3_Err = renderComponent(typ, props.(map[string]interface{})).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { @@ -210,40 +201,6 @@ func pageTemplate(name string, def UIDefinition) templ.Component { }) } -func renderComponents(components map[string]interface{}) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var10 := templ.GetChildren(ctx) - if templ_7745c5c3_Var10 == nil { - templ_7745c5c3_Var10 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - for key, component := range components { - switch c := component.(type) { - case map[string]interface{}: - templ_7745c5c3_Err = renderComponent(key, c).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } - return templ_7745c5c3_Err - }) -} - func renderComponent(typ string, props map[string]interface{}) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -260,21 +217,21 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) switch typ { case "button": - var templ_7745c5c3_Var12 = []any{ + var templ_7745c5c3_Var10 = []any{ "btn", templ.KV("btn-primary", props["type"] == "primary"), templ.KV("btn-secondary", props["type"] == "secondary"), templ.KV("btn-danger", props["type"] == "danger"), templ.KV("btn-success", props["type"] == "success"), } - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -287,12 +244,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(id) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(id) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 72, Col: 11} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 66, Col: 11} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -312,12 +269,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(onclick) + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(onclick) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 78, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 72, Col: 30} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -330,12 +287,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String()) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var10).String()) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -344,12 +301,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { return templ_7745c5c3_Err } if text, ok := props["text"].(string); ok { - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(text) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(text) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 89, Col: 10} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 83, Col: 10} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -368,12 +325,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(id) + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(id) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 95, Col: 11} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 89, Col: 11} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -387,12 +344,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { return templ_7745c5c3_Err } if content, ok := props["content"].(string); ok { - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(content) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(content) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 99, Col: 13} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 93, Col: 13} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -411,12 +368,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(id) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(id) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 105, Col: 11} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 99, Col: 11} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -430,12 +387,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { return templ_7745c5c3_Err } if content, ok := props["content"].(string); ok { - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(content) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(content) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 109, Col: 13} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 103, Col: 13} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -454,12 +411,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(id) + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(id) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 115, Col: 11} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 109, Col: 11} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -473,12 +430,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(typ) + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(typ) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 118, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 112, Col: 14} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -492,12 +449,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(placeholder) + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(placeholder) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 121, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 115, Col: 29} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -511,12 +468,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(value) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 124, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 118, Col: 17} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -545,12 +502,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(id) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(id) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 134, Col: 11} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 128, Col: 11} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -564,12 +521,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(rows)) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(rows)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 137, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 131, Col: 27} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -583,12 +540,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(cols)) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(cols)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 140, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 134, Col: 27} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -602,12 +559,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(placeholder) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(placeholder) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 143, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 137, Col: 29} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -621,12 +578,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { return templ_7745c5c3_Err } if value, ok := props["value"].(string); ok { - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(value) + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 148, Col: 11} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 142, Col: 11} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -645,12 +602,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(id) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(id) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 156, Col: 12} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 150, Col: 12} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -664,12 +621,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(name) + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 159, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 153, Col: 16} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -699,12 +656,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(props["id"].(string)) + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(props["id"].(string)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 170, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 164, Col: 62} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -712,12 +669,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(label) + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 170, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 164, Col: 72} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -745,19 +702,21 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { } switch i := item.(type) { case string: - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(i) + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(i) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 182, Col: 12} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 176, Col: 12} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case map[string]interface{}: - templ_7745c5c3_Err = renderComponents(i).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + for typ, props := range i { + templ_7745c5c3_Err = renderComponent(typ, props.(map[string]interface{})).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") @@ -783,19 +742,21 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { } switch i := item.(type) { case string: - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(i) + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(i) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 197, Col: 12} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 193, Col: 12} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case map[string]interface{}: - templ_7745c5c3_Err = renderComponents(i).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + for typ, props := range i { + templ_7745c5c3_Err = renderComponent(typ, props.(map[string]interface{})).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") @@ -820,12 +781,12 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(id) + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(id) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 210, Col: 11} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/ui-server/templates.templ`, Line: 208, Col: 11} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -841,9 +802,11 @@ func renderComponent(typ string, props map[string]interface{}) templ.Component { if components, ok := props["components"].([]interface{}); ok { for _, comp := range components { if c, ok := comp.(map[string]interface{}); ok { - templ_7745c5c3_Err = renderComponents(c).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + for typ, props := range c { + templ_7745c5c3_Err = renderComponent(typ, props.(map[string]interface{})).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } } diff --git a/go.mod b/go.mod index 5d26cb3..55101db 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,11 @@ toolchain go1.23.3 require ( github.com/JohannesKaufmann/html-to-markdown v1.6.0 - github.com/go-go-golems/clay v0.1.28 - github.com/go-go-golems/geppetto v0.4.35 - github.com/go-go-golems/glazed v0.5.29 - github.com/go-go-golems/parka v0.5.18 + github.com/a-h/templ v0.3.833 + github.com/go-go-golems/clay v0.1.31 + github.com/go-go-golems/geppetto v0.4.37 + github.com/go-go-golems/glazed v0.5.34 + github.com/go-go-golems/parka v0.5.20 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/hpcloud/tail v1.0.0 @@ -30,7 +31,6 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible // indirect github.com/PuerkitoBio/goquery v1.10.1 // indirect - github.com/a-h/templ v0.3.833 // indirect github.com/adrg/frontmatter v0.2.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect diff --git a/go.sum b/go.sum index f6ebdd9..e2f593f 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= -github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU= github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk= @@ -22,8 +22,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46 github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= @@ -81,14 +81,14 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-go-golems/clay v0.1.28 h1:/SZUVof5MoLE2DOcpYF3hUuux1tUfaHdl2gnPgIYkS4= -github.com/go-go-golems/clay v0.1.28/go.mod h1:sUiDlOnC0zP8W47TX0xJKfOdPhHgk2UVjyQCGMrjzEs= -github.com/go-go-golems/geppetto v0.4.35 h1:QZ/aUWCwnn+7BrFDHQuwOr1CitySM7aFMsUfOzPNu48= -github.com/go-go-golems/geppetto v0.4.35/go.mod h1:HGEsHKvH8HKH89CLWIcueYm46bue7LdFTtsFos3Uzyo= -github.com/go-go-golems/glazed v0.5.29 h1:3KaYdZBmdIymFRwhAhscFbTrpwhLx37VIgCHN+0bDX8= -github.com/go-go-golems/glazed v0.5.29/go.mod h1:/ZgeDXELDOcAkD505fijARmbF6x5Ev7oewNV4V6Andk= -github.com/go-go-golems/parka v0.5.18 h1:Rsw/ujSkMQ+Xw8XsZiZ/FoTPF+tzPUAXUFA0Xyqxsuo= -github.com/go-go-golems/parka v0.5.18/go.mod h1:gjUXXumO+yrysFQhbzuwKo+l2u5+eJoo51DRHFjlDoU= +github.com/go-go-golems/clay v0.1.31 h1:5+E/vtKzNXY/VKnyXebaBcvptxSJmWPl4o5AOn/g100= +github.com/go-go-golems/clay v0.1.31/go.mod h1:SEZqdWNIgWO3ox2xSR7O3hqG/d4Sqbh/58mj7oFdGro= +github.com/go-go-golems/geppetto v0.4.37 h1:yST23VguveYHgJXGP7nv0PRm6Y6gUjfvBCkvUOSkTcI= +github.com/go-go-golems/geppetto v0.4.37/go.mod h1:iLPhJydbXU/0PkZHzBxnutXYBXYTV3lSOdiv4pqOgyE= +github.com/go-go-golems/glazed v0.5.34 h1:EByXTz3aQpbApg9PaNBTl5O347GcA80aKqeqrB0IWHE= +github.com/go-go-golems/glazed v0.5.34/go.mod h1:cySRZANlIpuZNBTYkofLe8ls7M6O/aur4spck3J7O7w= +github.com/go-go-golems/parka v0.5.20 h1:y5cdyhilXbRI09D148lzAzEtIzlinRgDpdV5FKjpOjI= +github.com/go-go-golems/parka v0.5.20/go.mod h1:HqUJRu52zC+X/AXUIw+pZtHlsNIcE9dkIKpZIr5aWdc= github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= @@ -192,8 +192,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= @@ -230,8 +230,9 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI= diff --git a/pkg/prompts/providers/config-provider/prompt_provider.go b/pkg/prompts/providers/config-provider/prompt_provider.go index 4f21d64..d587f92 100644 --- a/pkg/prompts/providers/config-provider/prompt_provider.go +++ b/pkg/prompts/providers/config-provider/prompt_provider.go @@ -56,7 +56,7 @@ func NewConfigPromptProvider(config_ *config.Config, profile string) (*ConfigPro helpSystem := help.NewHelpSystem() // Load repository commands if err := provider.repository.LoadCommands(helpSystem); err != nil { - return nil, errors.Wrap(err, "failed to load repository commands") + return nil, errors.Wrap(err, "failed to load repository prompts") } // Load individual Pinocchio files diff --git a/pkg/transport/sse/transport.go b/pkg/transport/sse/transport.go index 330bf16..d998dd7 100644 --- a/pkg/transport/sse/transport.go +++ b/pkg/transport/sse/transport.go @@ -84,6 +84,19 @@ func NewSSETransport(opts ...transport.TransportOption) (*SSETransport, error) { s.standalone = false } + // Parse the port from the address if provided + if options.SSE != nil && options.SSE.Addr != "" { + _, portStr, err := net.SplitHostPort(options.SSE.Addr) + if err != nil { + return nil, fmt.Errorf("invalid address format: %w", err) + } + port, err := net.LookupPort("tcp", portStr) + if err != nil { + return nil, fmt.Errorf("invalid port: %w", err) + } + s.port = port + } + return s, nil } diff --git a/ttmp/2025-02-21/changelog.md b/ttmp/2025-02-21/changelog.md index bfc5e00..9f3bc3c 100644 --- a/ttmp/2025-02-21/changelog.md +++ b/ttmp/2025-02-21/changelog.md @@ -12,4 +12,12 @@ UI DSL YAML Display Enhanced the UI server to display the YAML source of each page alongside its rendered output: - Added syntax highlighting using highlight.js - Split view with rendered UI and YAML source side by side -- Improved visual presentation with Bootstrap cards \ No newline at end of file +- Improved visual presentation with Bootstrap cards + +UI DSL Interaction Logging + +Added an interaction console to display user interactions with the UI: +- Fixed-position console at the bottom of the page +- Logs button clicks, checkbox changes, and form submissions +- Form submissions display data in YAML format +- Limited console history to 50 entries with auto-scroll \ No newline at end of file diff --git a/ttmp/2025-02-21/ui-dsl.yaml b/ttmp/2025-02-21/ui-dsl.yaml index 40d563e..b36fd7e 100644 --- a/ttmp/2025-02-21/ui-dsl.yaml +++ b/ttmp/2025-02-21/ui-dsl.yaml @@ -7,74 +7,74 @@ # Example components: --- -button: - text: Click me - type: primary # primary, secondary, danger, success - id: submit-btn - disabled: false - onclick: alert('clicked') +components: + - button: + text: Click me + type: primary # primary, secondary, danger, success + id: submit-btn + disabled: false + onclick: alert('clicked') -title: - content: Welcome to My App - id: main-title + - title: + content: Welcome to My App + id: main-title -text: - content: This is a paragraph of text that explains something. - id: description + - text: + content: This is a paragraph of text that explains something. + id: description -input: - type: text # text, email, password, number, tel - placeholder: Enter your name - value: "" - required: true - id: name-input - data: - validate: true - maxlength: 50 + - input: + type: text # text, email, password, number, tel + placeholder: Enter your name + value: "" + required: true + id: name-input + data: + validate: true + maxlength: 50 -textarea: - placeholder: Enter description - rows: 4 - cols: 50 - value: | - Default multiline - text content + - textarea: + placeholder: Enter description + rows: 4 + cols: 50 + value: | + Default multiline + text content -checkbox: - label: Accept terms - checked: false - required: true - name: terms - id: terms-checkbox + - checkbox: + label: Accept terms + checked: false + required: true + name: terms + id: terms-checkbox -list: - type: ul # ul or ol - items: - - First item - - Second item - - Third item with: - button: - text: Click me - type: secondary + - list: + type: ul # ul or ol + items: + - First item + - Second item + - Third item with: + button: + text: Click me + type: secondary -# Complex example with nested components -form: - id: signup-form - components: - - title: - content: Sign Up - - text: - content: Please fill in your details below. - - input: - type: email - placeholder: Email address - required: true - - input: - type: password - placeholder: Password - required: true - - checkbox: - label: Subscribe to newsletter - - button: - text: Submit - type: primary \ No newline at end of file + - form: + id: signup-form + components: + - title: + content: Sign Up + - text: + content: Please fill in your details below. + - input: + type: email + placeholder: Email address + required: true + - input: + type: password + placeholder: Password + required: true + - checkbox: + label: Subscribe to newsletter + - button: + text: Submit + type: primary \ No newline at end of file From da5d146aef12834848e96d56c54d12d49ac26346 Mon Sep 17 00:00:00 2001 From: Manuel Odendahl Date: Sat, 22 Feb 2025 11:25:27 -0500 Subject: [PATCH 10/10] :ambulance: Fix protocol providers and crashes --- cmd/go-go-mcp/cmds/server/start.go | 10 +- examples/pages/costume-contest.yaml | 41 ++++ examples/pages/gorilla.yaml | 86 ++++++++ examples/pages/halloween-party.yaml | 56 ++++++ examples/pages/haunted-house.yaml | 45 +++++ examples/pages/todo.yaml | 49 +++++ examples/pages/trick-or-treat.yaml | 50 +++++ examples/pages/welcome.yaml | 36 ++++ pkg/doc/topics/05-ui-dsl.md | 186 ++++++++++++++++++ .../config-provider/prompt_provider.go | 5 +- pkg/resources/registry.go | 21 +- pkg/server/handler.go | 71 +++++-- 12 files changed, 635 insertions(+), 21 deletions(-) create mode 100644 examples/pages/costume-contest.yaml create mode 100644 examples/pages/gorilla.yaml create mode 100644 examples/pages/halloween-party.yaml create mode 100644 examples/pages/haunted-house.yaml create mode 100644 examples/pages/todo.yaml create mode 100644 examples/pages/trick-or-treat.yaml create mode 100644 examples/pages/welcome.yaml create mode 100644 pkg/doc/topics/05-ui-dsl.md diff --git a/cmd/go-go-mcp/cmds/server/start.go b/cmd/go-go-mcp/cmds/server/start.go index fe80ada..6538402 100644 --- a/cmd/go-go-mcp/cmds/server/start.go +++ b/cmd/go-go-mcp/cmds/server/start.go @@ -13,6 +13,7 @@ import ( glazed_layers "github.com/go-go-golems/glazed/pkg/cmds/layers" "github.com/go-go-golems/glazed/pkg/cmds/parameters" "github.com/go-go-golems/go-go-mcp/cmd/go-go-mcp/cmds/server/layers" + "github.com/go-go-golems/go-go-mcp/pkg/resources" "github.com/go-go-golems/go-go-mcp/pkg/server" "github.com/go-go-golems/go-go-mcp/pkg/transport" "github.com/go-go-golems/go-go-mcp/pkg/transport/sse" @@ -116,8 +117,13 @@ func (c *StartCommand) Run( return err } - // Create and start server with transport - s := server.NewServer(logger, t, server.WithToolProvider(toolProvider)) + // Create resource provider + resourceProvider := resources.NewRegistry() + + // Create and start server with transport and providers + s := server.NewServer(logger, t, + server.WithToolProvider(toolProvider), + server.WithResourceProvider(resourceProvider)) // Create a context that will be cancelled on SIGINT/SIGTERM ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) diff --git a/examples/pages/costume-contest.yaml b/examples/pages/costume-contest.yaml new file mode 100644 index 0000000..e332977 --- /dev/null +++ b/examples/pages/costume-contest.yaml @@ -0,0 +1,41 @@ +components: + - title: + content: "🎭 Halloween Costume Contest" + id: contest-title + + - text: + content: "Vote for the spookiest, most creative costume of the year! Each contestant has put their heart (and maybe some actual organs) into their costumes." + id: contest-description + + - list: + type: ul + items: + - title: + content: "🧟‍♂️ Zombie Businessman" + - text: + content: "Bob from accounting really committed to the role - he hasn't showered in weeks!" + - button: + text: "Vote for Bob" + type: primary + onclick: "alert('Your vote for Zombie Bob has been counted!')" + - title: + content: "🧙‍♀️ Techno Witch" + - text: + content: "Sarah's costume combines traditional witchcraft with RGB lighting - truly spellbinding!" + - button: + text: "Vote for Sarah" + type: primary + onclick: "alert('Your vote for Techno Witch has been counted!')" + - title: + content: "👻 Ghost in the Machine" + - text: + content: "Mike dressed up as a haunted computer - blue screen of death included!" + - button: + text: "Vote for Mike" + type: primary + onclick: "alert('Your vote for Ghost Mike has been counted!')" + + - text: + content: "Voting closes at midnight on Halloween. The winner gets a one-way ticket to the shadow realm! (Just kidding, they get a gift card)" + id: voting-rules-text + diff --git a/examples/pages/gorilla.yaml b/examples/pages/gorilla.yaml new file mode 100644 index 0000000..2ee2eed --- /dev/null +++ b/examples/pages/gorilla.yaml @@ -0,0 +1,86 @@ +components: + - title: + content: 🦍 GORILLA GAME 🦍 + + - text: + content: Lead your gorilla through the jungle, collect bananas, and avoid dangers! + + - form: + id: game-controls + components: + - list: + type: ul + items: + - Health: + text: + content: "❤️❤️❤️" + id: health-display + - Score: + text: + content: "🍌 0" + id: score-display + - Level: + text: + content: "🌴 1" + id: level-display + + - list: + type: ul + items: + - button: + text: "⬅️ Left" + type: primary + id: move-left + - button: + text: "⬆️ Jump" + type: primary + id: move-up + - button: + text: "➡️ Right" + type: primary + id: move-right + + - list: + type: ul + items: + - button: + text: "🎮 Start Game" + type: success + id: start-game + - button: + text: "⏸️ Pause" + type: secondary + id: pause-game + - button: + text: "🔄 Restart" + type: danger + id: restart-game + + - form: + id: game-settings + components: + - title: + content: Settings + - checkbox: + label: "🔊 Sound Effects" + checked: true + id: sound-toggle + - checkbox: + label: "🎵 Background Music" + checked: true + id: music-toggle + - input: + type: text + placeholder: Enter player name + id: player-name + required: true + + - text: + content: "High Scores 🏆" + + - list: + type: ol + items: + - "King Kong: 2000 🍌" + - "Mighty Joe: 1500 🍌" + - "Donkey Kong: 1000 🍌" \ No newline at end of file diff --git a/examples/pages/halloween-party.yaml b/examples/pages/halloween-party.yaml new file mode 100644 index 0000000..db79d23 --- /dev/null +++ b/examples/pages/halloween-party.yaml @@ -0,0 +1,56 @@ +components: + - title: + content: "🎪 Halloween Party RSVP" + id: party-title + + - text: + content: "Join us for the spookiest party of the year! There will be treats, tricks, and maybe a few uninvited ghostly guests..." + id: party-description + + - form: + id: rsvp-form + components: + - input: + type: text + placeholder: "Your Name (mortal or otherwise)" + required: true + id: name-input + - input: + type: email + placeholder: "Email Address" + required: true + id: email-input + - input: + type: text + placeholder: "Your Costume Plan" + required: true + id: costume-input + - checkbox: + label: "I'll bring a spooky snack to share 🍪" + id: snack-checkbox + - checkbox: + label: "I'm bringing a plus-one (living guests only, please) 👥" + id: guest-checkbox + - list: + type: ul + items: + - text: + content: "🎵 Music preferences:" + - checkbox: + label: "Monster Mash" + id: music-1 + - checkbox: + label: "Thriller" + id: music-2 + - checkbox: + label: "Ghostbusters Theme" + id: music-3 + - textarea: + placeholder: "Any dietary restrictions? (Blood type preferences?)" + rows: 2 + id: dietary-input + - button: + text: "RSVP to Party" + type: success + onclick: "alert('Your soul is on the guest list!')" + id: submit-btn \ No newline at end of file diff --git a/examples/pages/haunted-house.yaml b/examples/pages/haunted-house.yaml new file mode 100644 index 0000000..c1b2b95 --- /dev/null +++ b/examples/pages/haunted-house.yaml @@ -0,0 +1,45 @@ +components: + - title: + content: "🏚️ Book Your Haunted House Tour" + id: tour-title + + - text: + content: "Dare to explore our haunted mansion? Fill out the form below to reserve your spot in our terrifying tour. Remember, the ghosts are waiting..." + id: tour-description + + - form: + id: tour-form + components: + - input: + type: text + placeholder: "Your Name (if you survive...)" + required: true + id: name-input + - input: + type: email + placeholder: "Email (for ghost communications)" + required: true + id: email-input + - input: + type: date + placeholder: "Select your doom date" + required: true + id: date-input + - input: + type: number + placeholder: "Number of brave souls" + required: true + id: guests-input + - checkbox: + label: "I acknowledge that the spirits may possess my soul 👻" + required: true + id: waiver-checkbox + - textarea: + placeholder: "Any last words or special requests?" + rows: 3 + id: comments-input + - button: + text: "Book If You Dare" + type: danger + onclick: "alert('Your fate is sealed...')" + id: submit-btn \ No newline at end of file diff --git a/examples/pages/todo.yaml b/examples/pages/todo.yaml new file mode 100644 index 0000000..55f1069 --- /dev/null +++ b/examples/pages/todo.yaml @@ -0,0 +1,49 @@ +components: + - title: + content: What would you like to tackle next? + id: priority-title + + - text: + content: I see you have several items that need attention. Let's organize them by priority and type. + id: context-text + + - list: + type: ul + items: + - Review Dependencies: + button: + text: Review Nokogiri Update (#316) + type: secondary + - Calendar Integration: + button: + text: Review Calendar CLI PR (#315) + type: primary + - Coaching Features: + button: + text: Review Coaching Chat PR (#311) + type: primary + + - form: + id: task-input-form + components: + - title: + content: Add New Task + - input: + type: text + placeholder: What else needs to be done? + id: new-task + required: true + - textarea: + placeholder: Add any important context or notes + rows: 3 + id: task-notes + - checkbox: + label: High Priority + id: priority-flag + - button: + text: Add Task + type: success + + - text: + content: Would you like me to help you analyze any of these items in detail? + id: help-offer \ No newline at end of file diff --git a/examples/pages/trick-or-treat.yaml b/examples/pages/trick-or-treat.yaml new file mode 100644 index 0000000..2a84e18 --- /dev/null +++ b/examples/pages/trick-or-treat.yaml @@ -0,0 +1,50 @@ +components: + - title: + content: "🎃 Trick-or-Treat Checklist" + id: checklist-title + + - text: + content: "Make sure you're prepared for a night of spooky fun! Check off these essential items before heading out." + id: checklist-description + + - form: + id: checklist-form + components: + - title: + content: "Essential Items:" + - checkbox: + label: "🎭 Costume (properly fitted for maximum candy acquisition)" + id: costume-check + - checkbox: + label: "🎒 Candy collection bag/bucket (reinforced for heavy loads)" + id: bag-check + - checkbox: + label: "🔦 Flashlight (to spot friendly ghosts)" + id: flashlight-check + - checkbox: + label: "📱 Phone (for emergency ghost selfies)" + id: phone-check + - checkbox: + label: "🧥 Warm layer (ghosts make the air chilly)" + id: warmth-check + + - title: + content: "Safety Measures:" + - checkbox: + label: "👥 Buddy system arranged" + id: buddy-check + - checkbox: + label: "🗺️ Route planned (avoiding known werewolf territories)" + id: route-check + - checkbox: + label: "⌚ Watch/time-keeping device (to return before turning into a pumpkin)" + id: time-check + - checkbox: + label: "🔋 All devices charged (for documenting paranormal activities)" + id: battery-check + + - button: + text: "Ready to Haunt!" + type: primary + onclick: "alert('Happy haunting! Remember: the ghosts are more scared of you than you are of them... maybe.')" + id: ready-btn \ No newline at end of file diff --git a/examples/pages/welcome.yaml b/examples/pages/welcome.yaml new file mode 100644 index 0000000..9e83f02 --- /dev/null +++ b/examples/pages/welcome.yaml @@ -0,0 +1,36 @@ +components: + - title: + content: "🎃 Welcome to Spookyville! 👻" + id: main-title + + - text: + content: "The night is dark, the moon is bright, welcome to our Halloween site! Explore our haunted pages and discover the spooky delights that await..." + id: welcome-text + + - list: + type: ul + items: + - title: + content: "🏚️ Haunted House Tour" + - text: + content: "Book your tour through our haunted mansion, if you dare..." + - button: + text: "Book Tour" + type: danger + onclick: "alert('Beware! The ghosts await...')" + - title: + content: "🎭 Costume Contest" + - text: + content: "Vote for the spookiest costume of the year!" + - button: + text: "Vote Now" + type: primary + onclick: "alert('Choose wisely...')" + - title: + content: "🎪 Halloween Party" + - text: + content: "Join us for a night of frightful fun!" + - button: + text: "RSVP" + type: success + onclick: "alert('We will be waiting for you...')" \ No newline at end of file diff --git a/pkg/doc/topics/05-ui-dsl.md b/pkg/doc/topics/05-ui-dsl.md new file mode 100644 index 0000000..b9abee6 --- /dev/null +++ b/pkg/doc/topics/05-ui-dsl.md @@ -0,0 +1,186 @@ +--- +Title: UI DSL Documentation +Slug: ui-dsl +Short: Learn how to create rich, interactive web interfaces using the YAML-based UI DSL +Topics: +- ui +- dsl +- yaml +- web +Commands: +- none +Flags: +- none +IsTopLevel: true +IsTemplate: false +ShowPerDefault: true +SectionType: GeneralTopic +--- + +# UI DSL Documentation + +The UI DSL (Domain Specific Language) is a YAML-based language for defining user interfaces declaratively. It allows you to create rich, interactive web interfaces without writing HTML directly. The DSL is designed to be both human-readable and machine-friendly, making it ideal for both manual creation and automated generation. + +## Basic Structure + +Every UI definition consists of a list of components under the `components` key: + +```yaml +components: + - componentType: + property1: value1 + property2: value2 +``` + +## Common Properties + +All components support these common properties: + +- `id`: Unique identifier for the component (required) +- `disabled`: Boolean to disable the component (optional) +- `data`: Map of data attributes (optional) + +## Component Types + +### Button +```yaml +- button: + text: "Click me" + type: primary # primary, secondary, danger, success + id: submit-btn + disabled: false +``` + +### Title (H1 Heading) +```yaml +- title: + content: "Welcome to My App" + id: main-title +``` + +### Text (Paragraph) +```yaml +- text: + content: "This is a paragraph of text." + id: description-text +``` + +### Input Field +```yaml +- input: + type: text # text, email, password, number, tel + placeholder: "Enter your name" + value: "" + required: true + id: name-input +``` + +### Textarea +```yaml +- textarea: + placeholder: "Enter description" + id: description-textarea + rows: 4 + cols: 50 + value: | + Default multiline + text content +``` + +### Checkbox +```yaml +- checkbox: + label: "Accept terms" + checked: false + required: true + name: terms + id: terms-checkbox +``` + +### List +```yaml +- list: + type: ul # ul or ol + items: + - "First item" + - "Second item" + - "Third item with button": + button: + id: list-item-3-btn + text: "Click me" + type: secondary +``` + +### Form +```yaml +- form: + id: signup-form + components: + - title: + content: "Sign Up" + - input: + type: email + placeholder: "Email address" + required: true + - button: + id: submit + text: "Submit" + type: primary +``` + +## Complete Example + +Here's a complete example of a todo list interface: + +```yaml +components: + - title: + content: What would you like to tackle next? + + - text: + content: I see you have several items that need attention. + + - list: + type: ul + items: + - Review Dependencies: + button: + id: review-deps-btn + text: Review Update (#316) + type: secondary + - Calendar Integration: + button: + id: review-calendar-btn + text: Review Calendar PR (#315) + type: primary + + - form: + id: task-input-form + components: + - title: + content: Add New Task + - input: + id: new-task-input + type: text + placeholder: What needs to be done? + required: true + - checkbox: + id: high-priority-check + label: High Priority + - button: + id: add-task-btn + text: Add Task + type: success +``` + +## Best Practices + +1. Always provide meaningful IDs for components that need to be referenced +2. Use semantic naming for form fields +3. Group related components inside forms +4. Use appropriate button types for different actions: + - `primary`: Main actions + - `secondary`: Alternative actions + - `danger`: Destructive actions + - `success`: Confirmation actions +5. Provide clear labels and placeholders for form inputs diff --git a/pkg/prompts/providers/config-provider/prompt_provider.go b/pkg/prompts/providers/config-provider/prompt_provider.go index d587f92..2d77ad8 100644 --- a/pkg/prompts/providers/config-provider/prompt_provider.go +++ b/pkg/prompts/providers/config-provider/prompt_provider.go @@ -1,6 +1,7 @@ package config_provider import ( + "context" "os" "path/filepath" @@ -79,7 +80,7 @@ func NewConfigPromptProvider(config_ *config.Config, profile string) (*ConfigPro } // ListPrompts implements pkg.PromptProvider interface -func (p *ConfigPromptProvider) ListPrompts(cursor string) ([]protocol.Prompt, string, error) { +func (p *ConfigPromptProvider) ListPrompts(_ context.Context, cursor string) ([]protocol.Prompt, string, error) { var prompts []protocol.Prompt // Get prompts from repositories @@ -108,7 +109,7 @@ func (p *ConfigPromptProvider) ListPrompts(cursor string) ([]protocol.Prompt, st } // GetPrompt implements pkg.PromptProvider interface -func (p *ConfigPromptProvider) GetPrompt(name string, arguments map[string]string) (*protocol.PromptMessage, error) { +func (p *ConfigPromptProvider) GetPrompt(_ context.Context, name string, arguments map[string]string) (*protocol.PromptMessage, error) { // Try repositories first if cmd, ok := p.repository.GetCommand(name); ok { return p.executeRepositoryPrompt(cmd, arguments) diff --git a/pkg/resources/registry.go b/pkg/resources/registry.go index 3838be1..692a8e3 100644 --- a/pkg/resources/registry.go +++ b/pkg/resources/registry.go @@ -1,6 +1,7 @@ package resources import ( + "context" "sort" "sync" @@ -17,6 +18,8 @@ type Registry struct { subscribers map[string][]chan struct{} } +var _ pkg.ResourceProvider = &Registry{} + // Handler is a function that provides the content for a resource type Handler func(resource protocol.Resource) (*protocol.ResourceContent, error) @@ -56,7 +59,7 @@ func (r *Registry) UnregisterResource(uri string) { } // ListResources implements ResourceProvider interface -func (r *Registry) ListResources(cursor string) ([]protocol.Resource, string, error) { +func (r *Registry) ListResources(_ context.Context, cursor string) ([]protocol.Resource, string, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -89,7 +92,7 @@ func (r *Registry) ListResources(cursor string) ([]protocol.Resource, string, er } // ReadResource implements ResourceProvider interface -func (r *Registry) ReadResource(uri string) (*protocol.ResourceContent, error) { +func (r *Registry) ReadResource(_ context.Context, uri string) ([]protocol.ResourceContent, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -99,21 +102,27 @@ func (r *Registry) ReadResource(uri string) (*protocol.ResourceContent, error) { } if handler, ok := r.handlers[uri]; ok { - return handler(resource) + content, err := handler(resource) + if err != nil { + return nil, err + } + return []protocol.ResourceContent{*content}, nil } // Return empty content if no handler is registered - return &protocol.ResourceContent{}, nil + return []protocol.ResourceContent{{ + URI: uri, + }}, nil } // ListResourceTemplates implements ResourceProvider interface -func (r *Registry) ListResourceTemplates() ([]protocol.ResourceTemplate, error) { +func (r *Registry) ListResourceTemplates(_ context.Context) ([]protocol.ResourceTemplate, error) { // This is a basic implementation that returns no templates return []protocol.ResourceTemplate{}, nil } // SubscribeToResource implements ResourceProvider interface -func (r *Registry) SubscribeToResource(uri string) (chan struct{}, func(), error) { +func (r *Registry) SubscribeToResource(_ context.Context, uri string) (chan struct{}, func(), error) { r.mu.Lock() defer r.mu.Unlock() diff --git a/pkg/server/handler.go b/pkg/server/handler.go index 9151dfc..165a41c 100644 --- a/pkg/server/handler.go +++ b/pkg/server/handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/go-go-golems/go-go-mcp/pkg/protocol" "github.com/go-go-golems/go-go-mcp/pkg/transport" @@ -27,6 +28,18 @@ func (h *RequestHandler) HandleRequest(ctx context.Context, req *protocol.Reques return nil, transport.NewInvalidRequestError("invalid JSON-RPC version") } + if strings.HasPrefix(req.Method, "notifications/") { + err := h.HandleNotification(ctx, &protocol.Notification{ + JSONRPC: req.JSONRPC, + Method: req.Method, + Params: req.Params, + }) + if err != nil { + return nil, err + } + return nil, nil + } + switch req.Method { case "initialize": return h.handleInitialize(ctx, req) @@ -69,8 +82,9 @@ func (h *RequestHandler) newSuccessResponse(id json.RawMessage, result interface } return &protocol.Response{ - ID: id, - Result: resultJSON, + JSONRPC: "2.0", + ID: id, + Result: resultJSON, }, nil } @@ -126,10 +140,19 @@ func (h *RequestHandler) handlePing(_ context.Context, req *protocol.Request) (* func (h *RequestHandler) handlePromptsList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { - Cursor string `json:"cursor"` + Cursor string `json:"cursor,omitempty"` } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return nil, transport.NewInvalidParamsError(err.Error()) + if len(req.Params) > 0 { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + } + + if h.server.promptProvider == nil { + return h.newSuccessResponse(req.ID, protocol.ListPromptsResult{ + Prompts: []protocol.Prompt{}, + NextCursor: "", + }) } prompts, nextCursor, err := h.server.promptProvider.ListPrompts(ctx, params.Cursor) @@ -156,6 +179,10 @@ func (h *RequestHandler) handlePromptsGet(ctx context.Context, req *protocol.Req return nil, transport.NewInvalidParamsError(err.Error()) } + if h.server.promptProvider == nil { + return nil, transport.NewInternalError("prompt provider not configured") + } + prompt, err := h.server.promptProvider.GetPrompt(ctx, params.Name, params.Arguments) if err != nil { return nil, transport.NewInternalError(err.Error()) @@ -166,10 +193,19 @@ func (h *RequestHandler) handlePromptsGet(ctx context.Context, req *protocol.Req func (h *RequestHandler) handleResourcesList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { - Cursor string `json:"cursor"` + Cursor string `json:"cursor,omitempty"` } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return nil, transport.NewInvalidParamsError(err.Error()) + if len(req.Params) > 0 { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + } + + if h.server.resourceProvider == nil { + return h.newSuccessResponse(req.ID, protocol.ListResourcesResult{ + Resources: []protocol.Resource{}, + NextCursor: "", + }) } resources, nextCursor, err := h.server.resourceProvider.ListResources(ctx, params.Cursor) @@ -195,6 +231,10 @@ func (h *RequestHandler) handleResourcesRead(ctx context.Context, req *protocol. return nil, transport.NewInvalidParamsError(err.Error()) } + if h.server.resourceProvider == nil { + return nil, transport.NewInternalError("resource provider not configured") + } + contents, err := h.server.resourceProvider.ReadResource(ctx, params.Name) if err != nil { return nil, transport.NewInternalError(err.Error()) @@ -207,10 +247,19 @@ func (h *RequestHandler) handleResourcesRead(ctx context.Context, req *protocol. func (h *RequestHandler) handleToolsList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { var params struct { - Cursor string `json:"cursor"` + Cursor string `json:"cursor,omitempty"` } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return nil, transport.NewInvalidParamsError(err.Error()) + if len(req.Params) > 0 { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return nil, transport.NewInvalidParamsError(err.Error()) + } + } + + if h.server.toolProvider == nil { + return h.newSuccessResponse(req.ID, protocol.ListToolsResult{ + Tools: []protocol.Tool{}, + NextCursor: "", + }) } tools, nextCursor, err := h.server.toolProvider.ListTools(ctx, params.Cursor)