diff --git a/.goreleaser.yaml b/.goreleaser.yaml index bc0b633..ffd033b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,5 @@ version: 2 -project_name: go-go-mcp +project_name: mcp before: hooks: @@ -11,7 +11,7 @@ builds: - env: - CGO_ENABLED=0 main: ./cmd/go-go-mcp - binary: go-go-mcp + binary: mcp goos: - linux # I am not able to test windows at the time @@ -34,8 +34,8 @@ signs: args: [ "--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}" ] brews: - - name: go-go-mcp - description: "go-go-mcp is a tool to serve and run MCPs" + - name: mcp + description: "mcp is a tool to serve and run MCPs" homepage: "https://github.com/go-go-golems/go-go-mcp" repository: owner: go-go-golems @@ -51,7 +51,7 @@ nfpms: maintainer: Manuel Odendahl description: |- - go-go-mcp is a tool to serve and run MCPs + mcp is a tool to serve and run MCPs license: MIT diff --git a/.vscode/launch.json b/.vscode/launch.json index 7bb38c4..4ba044e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,16 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/go-go-mcp", - "args": ["start", "--transport", "sse", "--profile", "html-extraction", "--log-level", "debug", "--port", "3000"], + "args": ["server", "start", "--profile", "all", "--log-level", "debug"], + "cwd": "${workspaceFolder}" + }, + { + "name": "Launch MCP Server (sse)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/go-go-mcp", + "args": ["server", "start", "--transport", "sse", "--profile", "html-extraction", "--log-level", "debug", "--port", "3000"], "cwd": "${workspaceFolder}" }, { @@ -28,6 +37,15 @@ "program": "${workspaceFolder}/cmd/go-go-mcp", "args": ["config", "add-tool", "html-extraction", "--dir", "~/code/wesen/corporate-headquarters/go-go-mcp/examples/html-extract"], "cwd": "${workspaceFolder}" + }, + { + "name": "Launch UI Server", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/ui-server", + "args": ["examples/pages"], + "cwd": "${workspaceFolder}" } ] } \ No newline at end of file diff --git a/Makefile b/Makefile index ca867a8..1c2cfc7 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ bump-glazed: go get github.com/go-go-golems/parka@latest go mod tidy -go-go-mcp_BINARY=$(shell which go-go-mcp) +mcp_BINARY=$(shell which mcp) install: - go build -o ./dist/go-go-mcp ./cmd/go-go-mcp && \ - cp ./dist/go-go-mcp $(go-go-mcp_BINARY) + go build -o ./dist/mcp ./cmd/go-go-mcp && \ + cp ./dist/mcp $(mcp_BINARY) diff --git a/README.md b/README.md index 1dba535..6676b8e 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 @@ -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 88e40b6..73fa19f 100644 --- a/changelog.md +++ b/changelog.md @@ -1047,4 +1047,125 @@ 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 + +# 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 + +# 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 + +# Halloween-themed UI Examples +Added a collection of Halloween-themed example pages using the UI DSL: +- Created welcome page with spooky navigation +- Added haunted house tour booking form +- Created costume contest voting interface +- Added Halloween party RSVP form with fun options +- Created trick-or-treat checklist for safety + +# UI DSL Structure Update +Updated the UI DSL to use a top-level components list for better sequence handling: +- Changed UIDefinition to use a list of components instead of a map +- Updated all example pages to use the new structure +- Modified templates to handle the new component list format +- Improved component rendering to handle nested components + +# SSE Transport Port Configuration + +Improved port configuration handling in SSE transport by properly parsing the provided address. + +- Added proper port parsing from SSE options address +- Ensures port configuration is correctly propagated from command line to transport \ No newline at end of file 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/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/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 62% rename from cmd/go-go-mcp/cmds/start.go rename to cmd/go-go-mcp/cmds/server/start.go index b3844e4..6538402 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,13 @@ 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/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" + "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 +70,60 @@ 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 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) @@ -96,7 +134,9 @@ 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") + if !errors.Is(err, context.Canceled) { + logger.Error().Err(err).Msg("failed to run file watcher") + } return err } return nil @@ -104,19 +144,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 +154,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 85ffccf..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 } @@ -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..e47b6aa 100644 --- a/cmd/go-go-mcp/main.go +++ b/cmd/go-go-mcp/main.go @@ -28,7 +28,7 @@ var ( ) var rootCmd = &cobra.Command{ - Use: "go-go-mcp", + Use: "mcp", Short: "MCP client and server implementation in Go", Long: `A Model Context Protocol (MCP) client and server implementation in Go. Supports both stdio and SSE transports for client-server communication. @@ -82,17 +82,10 @@ 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, cli.WithSkipGlazedCommandLayer()) - 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/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..d4ea640 --- /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:"components"` +} + +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..59d699f --- /dev/null +++ b/cmd/ui-server/templates.templ @@ -0,0 +1,314 @@ +package main + +import ( + "fmt" + "strings" + "gopkg.in/yaml.v3" +) + +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) { +
+
+
+
+
Rendered UI
+
+
+ for _, component := range def.Components { + for typ, props := range component { + @renderComponent(typ, props.(map[string]interface{})) + } + } +
+
+
+
+
+
+
YAML Source
+
+
+
{ yamlString(def) }
+
+
+
+
+ } +} + +templ renderComponent(typ string, props map[string]interface{}) { + switch typ { + case "button": + if id, ok := props["id"].(string); ok { + + } + 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 id, ok := props["id"].(string); ok { +
+ + 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{}: + for typ, props := range i { + @renderComponent(typ, props.(map[string]interface{})) + } + } +
  • + } + } +
+ } 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{}: + for typ, props := range i { + @renderComponent(typ, props.(map[string]interface{})) + } + } +
  2. + } + } +
+ } + } + case "form": + if id, ok := props["id"].(string); ok { +
+ 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{})) + } + } + } + } +
+ } + } +} + +func yamlString(def UIDefinition) string { + yamlBytes, err := yaml.Marshal(def) + if err != nil { + return fmt.Sprintf("Error marshaling YAML: %v", err) + } + return string(yamlBytes) +} \ 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..8e6bf24 --- /dev/null +++ b/cmd/ui-server/templates_templ.go @@ -0,0 +1,823 @@ +// 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 + } + 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 { + 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 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_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_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_Var10...) + 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_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: 83, Col: 10} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + 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_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: 93, Col: 13} + } + _, 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 "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_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: 103, 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 "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_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: 142, Col: 11} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + 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_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: 176, Col: 12} + } + _, 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{}: + 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("
  • ") + 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_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: 193, Col: 12} + } + _, 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{}: + 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("
  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 { + 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 + } + } + } + } + } + _, 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/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/go.mod b/go.mod index a04b224..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.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/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 @@ -29,10 +30,10 @@ 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/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 bc9c42e..e2f593f 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,11 @@ 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= 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= @@ -19,8 +22,9 @@ 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= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= @@ -77,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.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.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= @@ -188,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= @@ -226,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= @@ -259,6 +264,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 +277,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 +287,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 +296,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 +315,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 +343,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= 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 deleted file mode 100644 index 0387b25..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 := ToJsonSchema(desc) - 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/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 4f21d64..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" @@ -56,7 +57,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 @@ -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/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/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/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/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/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..165a41c --- /dev/null +++ b/pkg/server/handler.go @@ -0,0 +1,295 @@ +package server + +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" +) + +// 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 *protocol.Request) (*protocol.Response, error) { + // Validate JSON-RPC version + if req.JSONRPC != "2.0" { + 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) + 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 *protocol.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 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 &protocol.Response{ + JSONRPC: "2.0", + ID: id, + Result: resultJSON, + }, nil +} + +// Individual request handlers +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()) + } + + // 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 *protocol.Request) (*protocol.Response, error) { + return h.newSuccessResponse(req.ID, struct{}{}) +} + +func (h *RequestHandler) handlePromptsList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { + var params struct { + Cursor string `json:"cursor,omitempty"` + } + 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) + 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 *protocol.Request) (*protocol.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()) + } + + 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()) + } + + return h.newSuccessResponse(req.ID, prompt) +} + +func (h *RequestHandler) handleResourcesList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { + var params struct { + Cursor string `json:"cursor,omitempty"` + } + 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) + 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 *protocol.Request) (*protocol.Response, error) { + var params struct { + Name string `json:"name"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + 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()) + } + + return h.newSuccessResponse(req.ID, protocol.ResourceResult{ + Contents: contents, + }) +} + +func (h *RequestHandler) handleToolsList(ctx context.Context, req *protocol.Request) (*protocol.Response, error) { + var params struct { + Cursor string `json:"cursor,omitempty"` + } + 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) + 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 *protocol.Request) (*protocol.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 beefac9..a026a6c 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") } @@ -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..6226e36 --- /dev/null +++ b/pkg/transport/errors.go @@ -0,0 +1,68 @@ +package transport + +import ( + "fmt" + + "github.com/go-go-golems/go-go-mcp/pkg/protocol" +) + +// Common error codes +const ( + ErrCodeParse = -32700 + ErrCodeInvalidRequest = -32600 + ErrCodeMethodNotFound = -32601 + ErrCodeInvalidParams = -32602 + ErrCodeInternal = -32603 + ErrCodeTransport = -32500 + ErrCodeTimeout = -32501 +) + +// Error constructors +func NewParseError(msg string) *protocol.Error { + return &protocol.Error{ + Code: ErrCodeParse, + Message: fmt.Sprintf("Parse error: %s", msg), + } +} + +func NewInvalidRequestError(msg string) *protocol.Error { + return &protocol.Error{ + Code: ErrCodeInvalidRequest, + Message: fmt.Sprintf("Invalid request: %s", msg), + } +} + +func NewMethodNotFoundError(msg string) *protocol.Error { + return &protocol.Error{ + Code: ErrCodeMethodNotFound, + Message: fmt.Sprintf("Method not found: %s", msg), + } +} + +func NewInvalidParamsError(msg string) *protocol.Error { + return &protocol.Error{ + Code: ErrCodeInvalidParams, + Message: fmt.Sprintf("Invalid params: %s", msg), + } +} + +func NewInternalError(msg string) *protocol.Error { + return &protocol.Error{ + Code: ErrCodeInternal, + Message: fmt.Sprintf("Internal error: %s", msg), + } +} + +func NewTransportError(msg string) *protocol.Error { + return &protocol.Error{ + Code: ErrCodeTransport, + Message: fmt.Sprintf("Transport error: %s", msg), + } +} + +func NewTimeoutError(msg string) *protocol.Error { + return &protocol.Error{ + 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..d998dd7 --- /dev/null +++ b/pkg/transport/sse/transport.go @@ -0,0 +1,358 @@ +package sse + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "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" + "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 *protocol.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 + } + + // 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 +} + +// 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 *protocol.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 *protocol.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 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) + 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..9f5130c --- /dev/null +++ b/pkg/transport/stdio/transport.go @@ -0,0 +1,281 @@ +package stdio + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "sync" + "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" +) + +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 *protocol.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 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, transport.ErrCodeParse, "Parse error", err) + } + + // Handle requests vs notifications based on ID presence + if !transport.IsNotification(&request) { + 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(protocol.Notification{ + Method: request.Method, + Params: request.Params, + }) +} + +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) + } + + if response != nil { + return s.Send(context.Background(), response) + } + + return nil +} + +func (s *StdioTransport) handleNotification(notification protocol.Notification) error { + + if err := s.handler.HandleNotification(context.Background(), ¬ification); err != nil { + s.logger.Error(). + Err(err). + Str("method", notification.Method). + Msg("Error handling notification") + // Don't send error responses for notifications + } + + return nil +} + +func (s *StdioTransport) sendError(id json.RawMessage, 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 := &protocol.Response{ + Error: &protocol.Error{ + Code: code, + Message: message, + Data: errorData, + }, + 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..cf9137a --- /dev/null +++ b/pkg/transport/transport.go @@ -0,0 +1,66 @@ +package transport + +import ( + "context" + "encoding/json" + + "github.com/go-go-golems/go-go-mcp/pkg/protocol" +) + +// 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 *protocol.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 +} + +// 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 +} + +// 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 + `"`) +} + +// 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 +} + +// 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) + + // HandleNotification processes a notification (no response expected) + HandleNotification(ctx context.Context, notif *protocol.Notification) error +} 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 new file mode 100644 index 0000000..987a51a --- /dev/null +++ b/ttmp/2025-02-14/01-rfc-transport-layer.md @@ -0,0 +1,514 @@ +# 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 + Logger zerolog.Logger + + // Transport specific options + SSE *SSEOptions + Stdio *StdioOptions +} + +// SSE-specific options +type SSEOptions struct { + // HTTP server configuration + Addr string + TLSConfig *tls.Config + + // 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 +6. Update server to use new transport layer + +## 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 + +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, + }), + 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) + // ... +} +``` 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 diff --git a/ttmp/2025-02-21/changelog.md b/ttmp/2025-02-21/changelog.md new file mode 100644 index 0000000..9f3bc3c --- /dev/null +++ b/ttmp/2025-02-21/changelog.md @@ -0,0 +1,23 @@ +UI DSL Documentation + +Added comprehensive documentation for the UI DSL system in pkg/doc/topics/05-ui-dsl.md. The documentation includes: +- Complete schema description +- Component reference +- Examples and best practices +- Styling and JavaScript integration details +- Validation rules and limitations + +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 + +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 new file mode 100644 index 0000000..b36fd7e --- /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: +--- +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 + + - 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