From d5c44f0ae9aaf5056e347a00883fa5dcc026525e Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 13 Jun 2025 16:08:15 +0300 Subject: [PATCH 1/8] progress --- cmd/root.go | 42 ++-- go.mod | 19 +- go.sum | 40 +++- internal/logger/logger.go | 77 ++++++ jsserver/modules/buffer/buffer.go | 203 ++++++++++++++++ jsserver/modules/console/console.go | 95 ++++++++ jsserver/modules/crypto/crypto.go | 247 ++++++++++++++++++++ jsserver/modules/encoding/encoding.go | 103 ++++++++ jsserver/modules/fetch/fetch.go | 220 +++++++++++++++++ jsserver/modules/http/http.go | 254 ++++++++++++++++++++ jsserver/modules/kv/kv.go | 112 +++++++++ jsserver/modules/timers/timers.go | 206 ++++++++++++++++ jsserver/modules/url/url.go | 224 ++++++++++++++++++ jsserver/server.go | 325 +++++++++----------------- jsserver/vm/eventloop.go | 162 +++++++++++++ jsserver/vm/manager.go | 135 +++++++++++ jsserver/vm/module.go | 54 +++++ test_kv.json | 11 + 18 files changed, 2294 insertions(+), 235 deletions(-) create mode 100644 internal/logger/logger.go create mode 100644 jsserver/modules/buffer/buffer.go create mode 100644 jsserver/modules/console/console.go create mode 100644 jsserver/modules/crypto/crypto.go create mode 100644 jsserver/modules/encoding/encoding.go create mode 100644 jsserver/modules/fetch/fetch.go create mode 100644 jsserver/modules/http/http.go create mode 100644 jsserver/modules/kv/kv.go create mode 100644 jsserver/modules/timers/timers.go create mode 100644 jsserver/modules/url/url.go create mode 100644 jsserver/vm/eventloop.go create mode 100644 jsserver/vm/manager.go create mode 100644 jsserver/vm/module.go create mode 100644 test_kv.json diff --git a/cmd/root.go b/cmd/root.go index 77fa950..0fe3d2e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,11 +2,11 @@ package cmd import ( "fmt" - "log" "os" "slices" "strings" + "github.com/mark3labs/codebench-mcp/internal/logger" "github.com/mark3labs/codebench-mcp/jsserver" "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" @@ -15,6 +15,7 @@ import ( var ( enabledModules []string disabledModules []string + debugMode bool ) // Available modules @@ -23,15 +24,17 @@ var availableModules = []string{ "fetch", "timers", "buffer", - "cache", + "kv", "crypto", - "dom", "encoding", - "ext", - "html", - "signal", - "stream", "url", + // TODO: Add these as they're implemented + // "cache", + // "dom", + // "ext", + // "html", + // "signal", + // "stream", } // rootCmd represents the base command when called without any subcommands @@ -41,9 +44,14 @@ var rootCmd = &cobra.Command{ Long: `A Model Context Protocol (MCP) server that provides JavaScript execution capabilities with ski runtime including http, fetch, timers, buffer, crypto, and other modules.`, Run: func(cmd *cobra.Command, args []string) { + // Initialize logger first + logger.Init(debugMode) + + logger.Debug("Starting codebench-mcp server", "debug", debugMode) + // Validate module configuration if len(enabledModules) > 0 && len(disabledModules) > 0 { - log.Fatal("Error: --enabled-modules and --disabled-modules are mutually exclusive") + logger.Fatal("--enabled-modules and --disabled-modules are mutually exclusive") } // Determine which modules to enable @@ -52,8 +60,7 @@ with ski runtime including http, fetch, timers, buffer, crypto, and other module // Only enable specified modules for _, module := range enabledModules { if !slices.Contains(availableModules, module) { - log.Fatalf("Error: unknown module '%s'. Available modules: %s", - module, strings.Join(availableModules, ", ")) + logger.Fatal("unknown module", "module", module, "available", strings.Join(availableModules, ", ")) } } modulesToEnable = enabledModules @@ -61,8 +68,7 @@ with ski runtime including http, fetch, timers, buffer, crypto, and other module // Enable all modules except disabled ones for _, module := range disabledModules { if !slices.Contains(availableModules, module) { - log.Fatalf("Error: unknown module '%s'. Available modules: %s", - module, strings.Join(availableModules, ", ")) + logger.Fatal("unknown module", "module", module, "available", strings.Join(availableModules, ", ")) } } for _, module := range availableModules { @@ -72,9 +78,11 @@ with ski runtime including http, fetch, timers, buffer, crypto, and other module } } else { // Enable default modules (same as NewJSHandler default) - modulesToEnable = []string{"http", "fetch", "timers", "buffer", "crypto"} + modulesToEnable = []string{"http", "fetch", "timers", "buffer", "kv", "crypto"} } + logger.Debug("Module configuration", "enabled", modulesToEnable) + // Create server with module configuration config := jsserver.ModuleConfig{ EnabledModules: modulesToEnable, @@ -82,12 +90,14 @@ with ski runtime including http, fetch, timers, buffer, crypto, and other module jss, err := jsserver.NewJSServerWithConfig(config) if err != nil { - log.Fatalf("Failed to create server: %v", err) + logger.Fatal("Failed to create server", "error", err) } + logger.Info("Starting MCP server", "modules", modulesToEnable) + // Serve requests if err := server.ServeStdio(jss); err != nil { - log.Fatalf("Server error: %v", err) + logger.Fatal("Server error", "error", err) } }, } @@ -107,6 +117,8 @@ func init() { rootCmd.Flags().StringSliceVar(&disabledModules, "disabled-modules", nil, fmt.Sprintf("Comma-separated list of modules to disable. Available: %s", strings.Join(availableModules, ", "))) + rootCmd.Flags().BoolVar(&debugMode, "debug", false, + "Enable debug logging (outputs to stderr)") rootCmd.MarkFlagsMutuallyExclusive("enabled-modules", "disabled-modules") } diff --git a/go.mod b/go.mod index ac11c22..61496b1 100644 --- a/go.mod +++ b/go.mod @@ -3,26 +3,39 @@ module github.com/mark3labs/codebench-mcp go 1.23.10 require ( + github.com/charmbracelet/log v0.4.2 github.com/grafana/sobek v0.0.0-20250312125646-01f8811babf6 github.com/mark3labs/mcp-go v0.32.0 - github.com/shiroyk/ski v0.0.0-20250408031354-4adc80b35df6 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.37.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d8db26c..182bb5d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,19 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -7,6 +21,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= @@ -23,15 +39,24 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shiroyk/ski v0.0.0-20250408031354-4adc80b35df6 h1:MP/JX14gcF+xYACElKB+Dzr56oMW8xLO/r+2CKnsusc= -github.com/shiroyk/ski v0.0.0-20250408031354-4adc80b35df6/go.mod h1:jCP1xHey89rnssFI9hlBSNOZ34LUROFMysGNkE5otlY= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= @@ -40,12 +65,15 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..ad283b0 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,77 @@ +package logger + +import ( + "os" + + "github.com/charmbracelet/log" +) + +var ( + // Logger is the global logger instance + Logger *log.Logger + // DebugEnabled tracks if debug logging is enabled + DebugEnabled bool +) + +// Init initializes the global logger with the specified debug level +func Init(debug bool) { + DebugEnabled = debug + + // Create logger that outputs to stderr (stdin/stdout reserved for MCP) + Logger = log.NewWithOptions(os.Stderr, log.Options{ + ReportCaller: debug, // Show caller info in debug mode + ReportTimestamp: true, + TimeFormat: "15:04:05", + Prefix: "codebench-mcp", + }) + + // Set log level based on debug flag + if debug { + Logger.SetLevel(log.DebugLevel) + } else { + Logger.SetLevel(log.InfoLevel) + } +} + +// Debug logs a debug message (only if debug is enabled) +func Debug(msg interface{}, keyvals ...interface{}) { + if Logger != nil { + Logger.Debug(msg, keyvals...) + } +} + +// Info logs an info message +func Info(msg interface{}, keyvals ...interface{}) { + if Logger != nil { + Logger.Info(msg, keyvals...) + } +} + +// Warn logs a warning message +func Warn(msg interface{}, keyvals ...interface{}) { + if Logger != nil { + Logger.Warn(msg, keyvals...) + } +} + +// Error logs an error message +func Error(msg interface{}, keyvals ...interface{}) { + if Logger != nil { + Logger.Error(msg, keyvals...) + } +} + +// Fatal logs a fatal message and exits +func Fatal(msg interface{}, keyvals ...interface{}) { + if Logger != nil { + Logger.Fatal(msg, keyvals...) + } else { + // Fallback if logger not initialized + log.Fatal(msg, keyvals...) + } +} + +// GetLogger returns the global logger instance (for use in modules) +func GetLogger() *log.Logger { + return Logger +} diff --git a/jsserver/modules/buffer/buffer.go b/jsserver/modules/buffer/buffer.go new file mode 100644 index 0000000..76387c1 --- /dev/null +++ b/jsserver/modules/buffer/buffer.go @@ -0,0 +1,203 @@ +package buffer + +import ( + "encoding/base64" + "encoding/hex" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// BufferModule provides Buffer global for binary data handling +type BufferModule struct{} + +// NewBufferModule creates a new buffer module +func NewBufferModule() *BufferModule { + return &BufferModule{} +} + +// Name returns the module name +func (b *BufferModule) Name() string { + return "buffer" +} + +// Setup initializes the buffer module in the VM +func (b *BufferModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // Buffer constructor + runtime.Set("Buffer", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + var data []byte + + if len(call.Arguments) > 0 { + arg := call.Argument(0) + + // Handle different input types + if sobek.IsString(arg) { + encoding := "utf8" + if len(call.Arguments) > 1 { + encoding = call.Argument(1).String() + } + + str := arg.String() + switch encoding { + case "base64": + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + panic(runtime.NewGoError(err)) + } + data = decoded + case "hex": + decoded, err := hex.DecodeString(str) + if err != nil { + panic(runtime.NewGoError(err)) + } + data = decoded + default: // utf8 + data = []byte(str) + } + } else if sobek.IsNumber(arg) { + // Create buffer of specified size + size := arg.ToInteger() + data = make([]byte, size) + } else { + // Try to convert to array + exported := arg.Export() + if arr, ok := exported.([]interface{}); ok { + data = make([]byte, len(arr)) + for i, v := range arr { + if num, ok := v.(float64); ok { + data[i] = byte(int(num)) + } + } + } + } + } + + // Store the data + obj.Set("__data__", data) + obj.Set("length", len(data)) + + // toString method + obj.Set("toString", func(call sobek.FunctionCall) sobek.Value { + encoding := "utf8" + if len(call.Arguments) > 0 { + encoding = call.Argument(0).String() + } + + dataVal := obj.Get("__data__") + data := dataVal.Export().([]byte) + switch encoding { + case "base64": + return runtime.ToValue(base64.StdEncoding.EncodeToString(data)) + case "hex": + return runtime.ToValue(hex.EncodeToString(data)) + default: // utf8 + return runtime.ToValue(string(data)) + } + }) + + // slice method + obj.Set("slice", func(call sobek.FunctionCall) sobek.Value { + dataVal := obj.Get("__data__") + data := dataVal.Export().([]byte) + start := 0 + end := len(data) + + if len(call.Arguments) > 0 { + start = int(call.Argument(0).ToInteger()) + if start < 0 { + start = len(data) + start + } + } + if len(call.Arguments) > 1 { + end = int(call.Argument(1).ToInteger()) + if end < 0 { + end = len(data) + end + } + } + + if start < 0 { + start = 0 + } + if end > len(data) { + end = len(data) + } + if start > end { + start = end + } + + sliced := data[start:end] + + // Create new Buffer object + newBuffer := runtime.NewObject() + newBuffer.Set("__data__", sliced) + newBuffer.Set("length", len(sliced)) + + // Copy methods to new buffer + newBuffer.Set("toString", obj.Get("toString")) + newBuffer.Set("slice", obj.Get("slice")) + + return newBuffer + }) + + return nil + }) + + // Buffer.from static method + bufferObj := runtime.Get("Buffer").ToObject(runtime) + bufferObj.Set("from", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return runtime.NewObject() + } + + // Create new Buffer using constructor logic + constructor, _ := sobek.AssertFunction(runtime.Get("Buffer")) + result, err := constructor(sobek.Undefined(), call.Arguments...) + if err != nil { + panic(runtime.NewGoError(err)) + } + return result + }) + + // Buffer.alloc static method + bufferObj.Set("alloc", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return runtime.NewObject() + } + + size := call.Argument(0).ToInteger() + fill := byte(0) + if len(call.Arguments) > 1 { + fill = byte(call.Argument(1).ToInteger()) + } + + data := make([]byte, size) + for i := range data { + data[i] = fill + } + + newBuffer := runtime.NewObject() + newBuffer.Set("__data__", data) + newBuffer.Set("length", len(data)) + + // Add methods + newBuffer.Set("toString", bufferObj.Get("toString")) + newBuffer.Set("slice", bufferObj.Get("slice")) + + return newBuffer + }) + + return nil +} + +// Cleanup performs any necessary cleanup +func (b *BufferModule) Cleanup() error { + // Buffer module doesn't need cleanup + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (b *BufferModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["buffer"] + return exists && enabled +} diff --git a/jsserver/modules/console/console.go b/jsserver/modules/console/console.go new file mode 100644 index 0000000..5637e6e --- /dev/null +++ b/jsserver/modules/console/console.go @@ -0,0 +1,95 @@ +package console + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// ConsoleModule provides console.log, console.error, etc. +type ConsoleModule struct { + logger *slog.Logger +} + +// NewConsoleModule creates a new console module +func NewConsoleModule(logger *slog.Logger) *ConsoleModule { + if logger == nil { + logger = slog.Default() + } + return &ConsoleModule{ + logger: logger, + } +} + +// Name returns the module name +func (c *ConsoleModule) Name() string { + return "console" +} + +// formatArgs formats console arguments for output +func (c *ConsoleModule) formatArgs(args []sobek.Value) string { + var parts []string + for _, arg := range args { + exported := arg.Export() + parts = append(parts, fmt.Sprintf("%v", exported)) + } + return strings.Join(parts, " ") +} + +// Setup initializes the console module in the VM +func (c *ConsoleModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + console := runtime.NewObject() + + // console.log + console.Set("log", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.logger.Info(message) + return sobek.Undefined() + }) + + // console.error + console.Set("error", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.logger.Error(message) + return sobek.Undefined() + }) + + // console.warn + console.Set("warn", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.logger.Warn(message) + return sobek.Undefined() + }) + + // console.info + console.Set("info", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.logger.Info(message) + return sobek.Undefined() + }) + + // console.debug + console.Set("debug", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.logger.Debug(message) + return sobek.Undefined() + }) + + runtime.Set("console", console) + return nil +} + +// Cleanup performs any necessary cleanup +func (c *ConsoleModule) Cleanup() error { + // Console module doesn't need cleanup + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (c *ConsoleModule) IsEnabled(enabledModules map[string]bool) bool { + // Console is always enabled as it's essential for debugging + return true +} diff --git a/jsserver/modules/crypto/crypto.go b/jsserver/modules/crypto/crypto.go new file mode 100644 index 0000000..606c88e --- /dev/null +++ b/jsserver/modules/crypto/crypto.go @@ -0,0 +1,247 @@ +package crypto + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "hash" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// CryptoModule provides cryptographic functions +type CryptoModule struct{} + +// NewCryptoModule creates a new crypto module +func NewCryptoModule() *CryptoModule { + return &CryptoModule{} +} + +// Name returns the module name +func (c *CryptoModule) Name() string { + return "crypto" +} + +// Encoder represents encoded data that can be output in different formats +type Encoder struct { + data []byte +} + +// hex returns the hex encoding of the data +func (e *Encoder) hex() string { + return hex.EncodeToString(e.data) +} + +// base64 returns the base64 encoding of the data +func (e *Encoder) base64() string { + return base64.StdEncoding.EncodeToString(e.data) +} + +// bytes returns the raw bytes +func (e *Encoder) bytes() []byte { + return e.data +} + +// Setup initializes the crypto module in the VM +func (c *CryptoModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // Create a require function that can load the crypto module + existingRequire := runtime.Get("require") + runtime.Set("require", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return sobek.Undefined() + } + + moduleName := call.Argument(0).String() + + switch moduleName { + case "crypto", "ski/crypto": + // Return the crypto object + return c.createCryptoObject(runtime) + default: + // For other modules, call existing require if it exists + if existingRequire != nil && !sobek.IsUndefined(existingRequire) { + if fn, ok := sobek.AssertFunction(existingRequire); ok { + result, err := fn(sobek.Undefined(), call.Arguments...) + if err != nil { + panic(runtime.NewGoError(err)) + } + return result + } + } + return sobek.Undefined() + } + }) + + return nil +} + +// createCryptoObject creates the crypto module object +func (c *CryptoModule) createCryptoObject(runtime *sobek.Runtime) sobek.Value { + crypto := runtime.NewObject() + + // Hash functions + crypto.Set("md5", func(call sobek.FunctionCall) sobek.Value { + return c.hash(runtime, "md5", call.Arguments) + }) + + crypto.Set("sha1", func(call sobek.FunctionCall) sobek.Value { + return c.hash(runtime, "sha1", call.Arguments) + }) + + crypto.Set("sha256", func(call sobek.FunctionCall) sobek.Value { + return c.hash(runtime, "sha256", call.Arguments) + }) + + crypto.Set("sha384", func(call sobek.FunctionCall) sobek.Value { + return c.hash(runtime, "sha384", call.Arguments) + }) + + crypto.Set("sha512", func(call sobek.FunctionCall) sobek.Value { + return c.hash(runtime, "sha512", call.Arguments) + }) + + // HMAC functions + crypto.Set("hmac", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) < 3 { + panic(runtime.NewTypeError("hmac requires algorithm, key, and data")) + } + algorithm := call.Argument(0).String() + key := call.Argument(1) + data := call.Argument(2) + return c.hmac(runtime, algorithm, key, data) + }) + + // Random bytes + crypto.Set("randomBytes", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(runtime.NewTypeError("randomBytes requires size argument")) + } + size := int(call.Argument(0).ToInteger()) + if size < 1 { + panic(runtime.NewTypeError("invalid size")) + } + bytes := make([]byte, size) + if _, err := rand.Read(bytes); err != nil { + panic(runtime.NewGoError(err)) + } + return runtime.ToValue(bytes) + }) + + return crypto +} + +// hash performs hashing with the specified algorithm +func (c *CryptoModule) hash(runtime *sobek.Runtime, algorithm string, args []sobek.Value) sobek.Value { + if len(args) == 0 { + panic(runtime.NewTypeError("hash function requires data argument")) + } + + data := c.toBytes(args[0]) + hasher := c.getHasher(algorithm) + if hasher == nil { + panic(runtime.NewTypeError("unsupported hash algorithm: " + algorithm)) + } + + hasher.Write(data) + result := hasher.Sum(nil) + + encoder := &Encoder{data: result} + + // Create encoder object with methods + encoderObj := runtime.NewObject() + encoderObj.Set("hex", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(encoder.hex()) + }) + encoderObj.Set("base64", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(encoder.base64()) + }) + encoderObj.Set("bytes", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(encoder.bytes()) + }) + + return encoderObj +} + +// hmac performs HMAC with the specified algorithm +func (c *CryptoModule) hmac(runtime *sobek.Runtime, algorithm string, key, data sobek.Value) sobek.Value { + keyBytes := c.toBytes(key) + dataBytes := c.toBytes(data) + + hasher := c.getHasher(algorithm) + if hasher == nil { + panic(runtime.NewTypeError("unsupported hash algorithm: " + algorithm)) + } + + h := hmac.New(func() hash.Hash { return c.getHasher(algorithm) }, keyBytes) + h.Write(dataBytes) + result := h.Sum(nil) + + encoder := &Encoder{data: result} + + // Create encoder object with methods + encoderObj := runtime.NewObject() + encoderObj.Set("hex", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(encoder.hex()) + }) + encoderObj.Set("base64", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(encoder.base64()) + }) + encoderObj.Set("bytes", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(encoder.bytes()) + }) + + return encoderObj +} + +// getHasher returns a hash function for the given algorithm +func (c *CryptoModule) getHasher(algorithm string) hash.Hash { + switch algorithm { + case "md5": + return md5.New() + case "sha1": + return sha1.New() + case "sha256": + return sha256.New() + case "sha384": + return sha512.New384() + case "sha512": + return sha512.New() + default: + return nil + } +} + +// toBytes converts a Sobek value to bytes +func (c *CryptoModule) toBytes(value sobek.Value) []byte { + if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) { + return []byte{} + } + + // Try to get as bytes first + if exported := value.Export(); exported != nil { + if bytes, ok := exported.([]byte); ok { + return bytes + } + } + + // Convert to string and then bytes + return []byte(value.String()) +} + +// Cleanup performs any necessary cleanup +func (c *CryptoModule) Cleanup() error { + // Crypto module doesn't need cleanup + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (c *CryptoModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["crypto"] + return exists && enabled +} diff --git a/jsserver/modules/encoding/encoding.go b/jsserver/modules/encoding/encoding.go new file mode 100644 index 0000000..736dca4 --- /dev/null +++ b/jsserver/modules/encoding/encoding.go @@ -0,0 +1,103 @@ +package encoding + +import ( + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// EncodingModule provides TextEncoder and TextDecoder +type EncodingModule struct{} + +// NewEncodingModule creates a new encoding module +func NewEncodingModule() *EncodingModule { + return &EncodingModule{} +} + +// Name returns the module name +func (e *EncodingModule) Name() string { + return "encoding" +} + +// Setup initializes the encoding module in the VM +func (e *EncodingModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // TextEncoder constructor + runtime.Set("TextEncoder", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + + // encode method + obj.Set("encode", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return runtime.ToValue([]byte{}) + } + text := call.Argument(0).String() + return runtime.ToValue([]byte(text)) + }) + + // encoding property + obj.Set("encoding", "utf-8") + + return nil + }) + + // TextDecoder constructor + runtime.Set("TextDecoder", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + + encoding := "utf-8" + if len(call.Arguments) > 0 { + encoding = call.Argument(0).String() + } + + // decode method + obj.Set("decode", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return runtime.ToValue("") + } + + arg := call.Argument(0) + var bytes []byte + + // Handle different input types + if exported := arg.Export(); exported != nil { + switch v := exported.(type) { + case []byte: + bytes = v + case []any: + // Convert array of numbers to bytes + bytes = make([]byte, len(v)) + for i, val := range v { + if num, ok := val.(float64); ok { + bytes[i] = byte(int(num)) + } + } + default: + // Convert to string and then bytes + bytes = []byte(arg.String()) + } + } else { + bytes = []byte(arg.String()) + } + + return runtime.ToValue(string(bytes)) + }) + + // encoding property + obj.Set("encoding", encoding) + + return nil + }) + + return nil +} + +// Cleanup performs any necessary cleanup +func (e *EncodingModule) Cleanup() error { + // Encoding module doesn't need cleanup + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (e *EncodingModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["encoding"] + return exists && enabled +} diff --git a/jsserver/modules/fetch/fetch.go b/jsserver/modules/fetch/fetch.go new file mode 100644 index 0000000..9fd891b --- /dev/null +++ b/jsserver/modules/fetch/fetch.go @@ -0,0 +1,220 @@ +package fetch + +import ( + "io" + "net/http" + "strings" + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// FetchModule provides fetch API functionality +type FetchModule struct { + client *http.Client +} + +// NewFetchModule creates a new fetch module +func NewFetchModule() *FetchModule { + return &FetchModule{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Name returns the module name +func (f *FetchModule) Name() string { + return "fetch" +} + +// Setup initializes the fetch module in the VM +func (f *FetchModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // fetch(url, options) + runtime.Set("fetch", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(runtime.NewTypeError("fetch: URL is required")) + } + + url := call.Argument(0).String() + + // Default options + method := "GET" + var body io.Reader + headers := make(map[string]string) + + // Parse options if provided + if len(call.Arguments) > 1 && !sobek.IsUndefined(call.Argument(1)) { + options := call.Argument(1).ToObject(runtime) + + if methodVal := options.Get("method"); methodVal != nil && !sobek.IsUndefined(methodVal) { + method = strings.ToUpper(methodVal.String()) + } + + if bodyVal := options.Get("body"); bodyVal != nil && !sobek.IsUndefined(bodyVal) { + bodyStr := bodyVal.String() + body = strings.NewReader(bodyStr) + } + + if headersVal := options.Get("headers"); headersVal != nil && !sobek.IsUndefined(headersVal) { + headersObj := headersVal.ToObject(runtime) + for _, key := range headersObj.Keys() { + headers[key] = headersObj.Get(key).String() + } + } + } + + // Create HTTP request + req, err := http.NewRequest(method, url, body) + if err != nil { + panic(runtime.NewGoError(err)) + } + + // Set headers + for key, value := range headers { + req.Header.Set(key, value) + } + + // Make the request + resp, err := f.client.Do(req) + if err != nil { + panic(runtime.NewGoError(err)) + } + + // Create Response object + responseObj := runtime.NewObject() + responseObj.Set("status", resp.StatusCode) + responseObj.Set("statusText", resp.Status) + responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) + responseObj.Set("url", resp.Request.URL.String()) + + // Headers object + headersObj := runtime.NewObject() + for key, values := range resp.Header { + if len(values) > 0 { + headersObj.Set(key, values[0]) + } + } + responseObj.Set("headers", headersObj) + + // Read response body + bodyBytes, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + panic(runtime.NewGoError(err)) + } + + // text() method + responseObj.Set("text", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(string(bodyBytes)) + }) + + // json() method + responseObj.Set("json", func(call sobek.FunctionCall) sobek.Value { + var result interface{} + if err := runtime.ExportTo(runtime.ToValue(string(bodyBytes)), &result); err != nil { + // Try to parse as JSON + jsonVal, err := runtime.RunString("JSON.parse(" + runtime.ToValue(string(bodyBytes)).String() + ")") + if err != nil { + panic(runtime.NewGoError(err)) + } + return jsonVal + } + return runtime.ToValue(result) + }) + + // arrayBuffer() method + responseObj.Set("arrayBuffer", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(bodyBytes) + }) + + return responseObj + }) + + // Request constructor + runtime.Set("Request", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + if len(call.Arguments) > 0 { + obj.Set("url", call.Argument(0).String()) + } + if len(call.Arguments) > 1 { + obj.Set("options", call.Argument(1)) + } + return nil + }) + + // Response constructor + runtime.Set("Response", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + if len(call.Arguments) > 0 { + obj.Set("body", call.Argument(0)) + } + if len(call.Arguments) > 1 { + obj.Set("options", call.Argument(1)) + } + return nil + }) + + // Headers constructor + runtime.Set("Headers", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + obj.Set("get", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) > 0 { + key := call.Argument(0).String() + return obj.Get(key) + } + return sobek.Undefined() + }) + obj.Set("set", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) > 1 { + key := call.Argument(0).String() + value := call.Argument(1).String() + obj.Set(key, value) + } + return sobek.Undefined() + }) + return nil + }) + + // FormData constructor + runtime.Set("FormData", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + data := make(map[string]string) + + obj.Set("append", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) > 1 { + key := call.Argument(0).String() + value := call.Argument(1).String() + data[key] = value + } + return sobek.Undefined() + }) + + obj.Set("get", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) > 0 { + key := call.Argument(0).String() + if value, exists := data[key]; exists { + return runtime.ToValue(value) + } + } + return sobek.Undefined() + }) + + return nil + }) + + return nil +} + +// Cleanup performs any necessary cleanup +func (f *FetchModule) Cleanup() error { + // HTTP client doesn't need explicit cleanup + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (f *FetchModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["fetch"] + return exists && enabled +} diff --git a/jsserver/modules/http/http.go b/jsserver/modules/http/http.go new file mode 100644 index 0000000..677077e --- /dev/null +++ b/jsserver/modules/http/http.go @@ -0,0 +1,254 @@ +package http + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// HTTPModule provides HTTP server functionality +type HTTPModule struct{} + +// NewHTTPModule creates a new HTTP module +func NewHTTPModule() *HTTPModule { + return &HTTPModule{} +} + +// Name returns the module name +func (h *HTTPModule) Name() string { + return "http" +} + +// httpServer represents a running HTTP server instance +type httpServer struct { + runtime *sobek.Runtime + server *http.Server + hostname string + port int + handler sobek.Callable + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex + running bool +} + +// Setup initializes the HTTP module in the VM +func (h *HTTPModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // Create a require function that can load the HTTP server + runtime.Set("require", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return sobek.Undefined() + } + + moduleName := call.Argument(0).String() + + switch moduleName { + case "ski/http/server": + // Return the serve function + return runtime.ToValue(func(call sobek.FunctionCall) sobek.Value { + return h.createServer(call, runtime) + }) + default: + // For other modules, return undefined + return sobek.Undefined() + } + }) + + return nil +} + +// createServer creates and starts an HTTP server +func (h *HTTPModule) createServer(call sobek.FunctionCall, runtime *sobek.Runtime) sobek.Value { + if len(call.Arguments) == 0 { + panic(runtime.NewTypeError("serve requires at least one argument")) + } + + // Default configuration + port := 8000 + hostname := "127.0.0.1" + var handler sobek.Callable + + // Parse arguments + arg0 := call.Argument(0) + if sobek.IsNumber(arg0) { + // serve(port, handler) + port = int(arg0.ToInteger()) + if len(call.Arguments) > 1 { + var ok bool + handler, ok = sobek.AssertFunction(call.Argument(1)) + if !ok { + panic(runtime.NewTypeError("handler must be a function")) + } + } + } else { + // serve(handler) or serve(options, handler) + var ok bool + handler, ok = sobek.AssertFunction(arg0) + if !ok { + panic(runtime.NewTypeError("handler must be a function")) + } + } + + if handler == nil { + panic(runtime.NewTypeError("handler is required")) + } + + // Create server context + ctx, cancel := context.WithCancel(context.Background()) + + server := &httpServer{ + runtime: runtime, + hostname: hostname, + port: port, + handler: handler, + ctx: ctx, + cancel: cancel, + } + + // Create HTTP server + addr := fmt.Sprintf("%s:%d", hostname, port) + server.server = &http.Server{ + Addr: addr, + Handler: http.HandlerFunc(server.handleRequest), + } + + // Start server in goroutine + go func() { + server.mu.Lock() + server.running = true + server.mu.Unlock() + + logger.Debug("Starting HTTP server", "addr", addr) + + if err := server.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP server error", "error", err) + } + + server.mu.Lock() + server.running = false + server.mu.Unlock() + }() + + // Create server object to return + serverObj := runtime.NewObject() + + // Add properties + serverObj.Set("url", fmt.Sprintf("http://%s:%d", hostname, port)) + serverObj.Set("port", port) + serverObj.Set("hostname", hostname) + + // Add methods + serverObj.Set("close", func(call sobek.FunctionCall) sobek.Value { + server.shutdown() + return sobek.Undefined() + }) + + serverObj.Set("shutdown", func(call sobek.FunctionCall) sobek.Value { + server.shutdown() + return sobek.Undefined() + }) + + // Store server reference for cleanup + runtime.Set("__http_server__", server) + + return serverObj +} + +// handleRequest handles incoming HTTP requests +func (s *httpServer) handleRequest(w http.ResponseWriter, r *http.Request) { + // Create request object for JavaScript + reqObj := s.runtime.NewObject() + reqObj.Set("method", r.Method) + reqObj.Set("url", r.URL.Path) + reqObj.Set("path", r.URL.Path) + + // Headers + headersObj := s.runtime.NewObject() + for key, values := range r.Header { + if len(values) > 0 { + headersObj.Set(key, values[0]) + } + } + reqObj.Set("headers", headersObj) + + // Call the JavaScript handler + result, err := s.handler(sobek.Undefined(), s.runtime.ToValue(reqObj)) + if err != nil { + logger.Error("Handler error", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Process the response + if result != nil && !sobek.IsUndefined(result) { + responseObj := result.ToObject(s.runtime) + + // Get status code + status := 200 + if statusVal := responseObj.Get("status"); statusVal != nil && !sobek.IsUndefined(statusVal) { + status = int(statusVal.ToInteger()) + } + + // Get headers + if headersVal := responseObj.Get("headers"); headersVal != nil && !sobek.IsUndefined(headersVal) { + headersObj := headersVal.ToObject(s.runtime) + for _, key := range headersObj.Keys() { + value := headersObj.Get(key).String() + w.Header().Set(key, value) + } + } + + // Get body + body := "" + if bodyVal := responseObj.Get("body"); bodyVal != nil && !sobek.IsUndefined(bodyVal) { + body = bodyVal.String() + } + + // Write response + w.WriteHeader(status) + w.Write([]byte(body)) + } else { + // Default response + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } +} + +// shutdown gracefully shuts down the server +func (s *httpServer) shutdown() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running && s.server != nil { + logger.Debug("Shutting down HTTP server") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := s.server.Shutdown(ctx); err != nil { + logger.Error("Server shutdown error", "error", err) + } + s.running = false + } + + if s.cancel != nil { + s.cancel() + } +} + +// Cleanup performs any necessary cleanup +func (h *HTTPModule) Cleanup() error { + // HTTP module cleanup is handled by individual server instances + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (h *HTTPModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["http"] + return exists && enabled +} diff --git a/jsserver/modules/kv/kv.go b/jsserver/modules/kv/kv.go new file mode 100644 index 0000000..959ca44 --- /dev/null +++ b/jsserver/modules/kv/kv.go @@ -0,0 +1,112 @@ +package kv + +import ( + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// KVModule provides key-value storage per VM instance +type KVModule struct { + store map[string]interface{} // Per-VM instance storage +} + +// NewKVModule creates a new KV module with isolated storage +func NewKVModule() *KVModule { + return &KVModule{ + store: make(map[string]interface{}), + } +} + +// Name returns the module name +func (kv *KVModule) Name() string { + return "kv" +} + +// Setup initializes the KV module in the VM +func (kv *KVModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + kvObj := runtime.NewObject() + + // kv.get(key) - retrieve a value + kvObj.Set("get", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return sobek.Undefined() + } + key := call.Argument(0).String() + value, exists := kv.store[key] + if !exists { + return sobek.Undefined() + } + return runtime.ToValue(value) + }) + + // kv.set(key, value) - store a value + kvObj.Set("set", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) < 2 { + return runtime.ToValue(false) + } + key := call.Argument(0).String() + value := call.Argument(1).Export() + kv.store[key] = value + return runtime.ToValue(true) + }) + + // kv.delete(key) - remove a value + kvObj.Set("delete", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return runtime.ToValue(false) + } + key := call.Argument(0).String() + _, exists := kv.store[key] + if exists { + delete(kv.store, key) + return runtime.ToValue(true) + } + return runtime.ToValue(false) + }) + + // kv.list() - list all keys + kvObj.Set("list", func(call sobek.FunctionCall) sobek.Value { + keys := make([]string, 0, len(kv.store)) + for key := range kv.store { + keys = append(keys, key) + } + return runtime.ToValue(keys) + }) + + // kv.clear() - clear all data + kvObj.Set("clear", func(call sobek.FunctionCall) sobek.Value { + kv.store = make(map[string]interface{}) + return runtime.ToValue(true) + }) + + // kv.has(key) - check if key exists + kvObj.Set("has", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return runtime.ToValue(false) + } + key := call.Argument(0).String() + _, exists := kv.store[key] + return runtime.ToValue(exists) + }) + + // kv.size() - get number of stored items + kvObj.Set("size", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(len(kv.store)) + }) + + runtime.Set("kv", kvObj) + return nil +} + +// Cleanup performs any necessary cleanup +func (kv *KVModule) Cleanup() error { + // Clear the store on cleanup + kv.store = nil + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (kv *KVModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["kv"] + return exists && enabled +} diff --git a/jsserver/modules/timers/timers.go b/jsserver/modules/timers/timers.go new file mode 100644 index 0000000..93b2933 --- /dev/null +++ b/jsserver/modules/timers/timers.go @@ -0,0 +1,206 @@ +package timers + +import ( + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// TimersModule provides setTimeout, setInterval, clearTimeout, clearInterval +type TimersModule struct{} + +// NewTimersModule creates a new timers module +func NewTimersModule() *TimersModule { + return &TimersModule{} +} + +// Name returns the module name +func (t *TimersModule) Name() string { + return "timers" +} + +// Setup initializes the timers module in the VM +func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // setTimeout + runtime.Set("setTimeout", func(call sobek.FunctionCall) sobek.Value { + callback, ok := sobek.AssertFunction(call.Argument(0)) + if !ok { + panic(runtime.NewTypeError("setTimeout: first argument must be a function")) + } + + i := call.Argument(1).ToInteger() + if i < 1 || i > 2147483647 { + i = 1 + } + delay := time.Duration(i) * time.Millisecond + + var args []sobek.Value + if len(call.Arguments) > 2 { + args = call.Arguments[2:] + } + + enqueue := vm.EnqueueJob(runtime) + t := rtTimers(runtime).new(delay, false) + vm.Cleanup(runtime, t.stop) + task := func() error { + defer t.stop() + _, err := callback(sobek.Undefined(), args...) + return err + } + + go func() { + select { + case <-t.timer: + enqueue(task) + case <-t.done: + enqueue(nothing) + } + }() + + return runtime.ToValue(t.id) + }) + + // clearTimeout + runtime.Set("clearTimeout", func(call sobek.FunctionCall) sobek.Value { + id := call.Argument(0).ToInteger() + rtTimers(runtime).stop(id) + return sobek.Undefined() + }) + + // setInterval + runtime.Set("setInterval", func(call sobek.FunctionCall) sobek.Value { + callback, ok := sobek.AssertFunction(call.Argument(0)) + if !ok { + panic(runtime.NewTypeError("setInterval: first argument must be a function")) + } + + i := call.Argument(1).ToInteger() + if i < 1 || i > 2147483647 { + i = 1 + } + delay := time.Duration(i) * time.Millisecond + + var args []sobek.Value + if len(call.Arguments) > 2 { + args = call.Arguments[2:] + } + + enqueue := vm.EnqueueJob(runtime) + t := rtTimers(runtime).new(delay, true) + vm.Cleanup(runtime, t.stop) + task := func() error { + _, err := callback(sobek.Undefined(), args...) + return err + } + + go func() { + for { + select { + case <-t.timer: + enqueue(task) + enqueue = vm.EnqueueJob(runtime) + case <-t.done: + enqueue(nothing) + return + } + } + }() + + return runtime.ToValue(t.id) + }) + + // clearInterval + runtime.Set("clearInterval", func(call sobek.FunctionCall) sobek.Value { + id := call.Argument(0).ToInteger() + rtTimers(runtime).stop(id) + return sobek.Undefined() + }) + + return nil +} + +// Cleanup performs any necessary cleanup +func (t *TimersModule) Cleanup() error { + // Cleanup is handled per-runtime via the symbol-based timers + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (t *TimersModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["timers"] + return exists && enabled +} + +// timer represents a single timer instance (copied from ski) +type timer struct { + id int64 + timer <-chan time.Time + done chan struct{} + cleanup func() +} + +func (t *timer) stop() { + select { + case _, ok := <-t.done: + if !ok { + return + } + default: + } + close(t.done) + t.cleanup() +} + +// timers manages all timers for a runtime (copied from ski) +type timers struct { + id int64 + timer map[int64]*timer +} + +func (t *timers) new(delay time.Duration, repeat bool) *timer { + t.id++ + id := t.id + n := &timer{ + id: id, + done: make(chan struct{}), + } + if repeat { + t1 := time.NewTicker(delay) + n.timer = t1.C + n.cleanup = func() { + delete(t.timer, id) + t1.Stop() + } + } else { + t1 := time.NewTimer(delay) + n.timer = t1.C + n.cleanup = func() { + delete(t.timer, id) + t1.Stop() + } + } + t.timer[id] = n + return n +} + +func (t *timers) stop(id int64) { + if v, ok := t.timer[id]; ok { + v.stop() + } +} + +var symTimers = sobek.NewSymbol(`Symbol.__timers__`) + +func rtTimers(rt *sobek.Runtime) *timers { + global := rt.GlobalObject() + v := global.GetSymbol(symTimers) + if v == nil { + t := &timers{timer: make(map[int64]*timer)} + _ = global.SetSymbol(symTimers, t) + return t + } + return v.Export().(*timers) +} + +func nothing() error { return nil } diff --git a/jsserver/modules/url/url.go b/jsserver/modules/url/url.go new file mode 100644 index 0000000..f1a4ec7 --- /dev/null +++ b/jsserver/modules/url/url.go @@ -0,0 +1,224 @@ +package url + +import ( + "net/url" + "strings" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// URLModule provides URL and URLSearchParams +type URLModule struct{} + +// NewURLModule creates a new URL module +func NewURLModule() *URLModule { + return &URLModule{} +} + +// Name returns the module name +func (u *URLModule) Name() string { + return "url" +} + +// Setup initializes the URL module in the VM +func (u *URLModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // URL constructor + runtime.Set("URL", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + + if len(call.Arguments) == 0 { + panic(runtime.NewTypeError("URL constructor requires a URL string")) + } + + urlStr := call.Argument(0).String() + baseURL := "" + if len(call.Arguments) > 1 { + baseURL = call.Argument(1).String() + } + + // Parse the URL + var parsedURL *url.URL + var err error + + if baseURL != "" { + base, err := url.Parse(baseURL) + if err != nil { + panic(runtime.NewTypeError("Invalid base URL: " + err.Error())) + } + parsedURL, err = base.Parse(urlStr) + } else { + parsedURL, err = url.Parse(urlStr) + } + + if err != nil { + panic(runtime.NewTypeError("Invalid URL: " + err.Error())) + } + + // Set properties + obj.Set("href", parsedURL.String()) + obj.Set("protocol", parsedURL.Scheme+":") + obj.Set("hostname", parsedURL.Hostname()) + obj.Set("port", parsedURL.Port()) + obj.Set("pathname", parsedURL.Path) + obj.Set("search", func() string { + if parsedURL.RawQuery != "" { + return "?" + parsedURL.RawQuery + } + return "" + }()) + obj.Set("hash", func() string { + if parsedURL.Fragment != "" { + return "#" + parsedURL.Fragment + } + return "" + }()) + obj.Set("host", parsedURL.Host) + obj.Set("origin", parsedURL.Scheme+"://"+parsedURL.Host) + + // searchParams property + searchParams := u.createURLSearchParams(runtime, parsedURL.Query()) + obj.Set("searchParams", searchParams) + + // toString method + obj.Set("toString", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(parsedURL.String()) + }) + + return nil + }) + + // URLSearchParams constructor + runtime.Set("URLSearchParams", func(call sobek.ConstructorCall) *sobek.Object { + obj := call.This + + params := url.Values{} + + if len(call.Arguments) > 0 { + arg := call.Argument(0) + if !sobek.IsUndefined(arg) && !sobek.IsNull(arg) { + // Parse from string + if sobek.IsString(arg) { + queryStr := arg.String() + if strings.HasPrefix(queryStr, "?") { + queryStr = queryStr[1:] + } + parsed, err := url.ParseQuery(queryStr) + if err == nil { + params = parsed + } + } + } + } + + return u.setupURLSearchParams(runtime, obj, params) + }) + + return nil +} + +// createURLSearchParams creates a URLSearchParams object +func (u *URLModule) createURLSearchParams(runtime *sobek.Runtime, params url.Values) sobek.Value { + obj := runtime.NewObject() + return u.setupURLSearchParams(runtime, obj, params) +} + +// setupURLSearchParams sets up URLSearchParams methods +func (u *URLModule) setupURLSearchParams(runtime *sobek.Runtime, obj *sobek.Object, params url.Values) *sobek.Object { + // append method + obj.Set("append", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) >= 2 { + key := call.Argument(0).String() + value := call.Argument(1).String() + params.Add(key, value) + } + return sobek.Undefined() + }) + + // delete method + obj.Set("delete", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) >= 1 { + key := call.Argument(0).String() + params.Del(key) + } + return sobek.Undefined() + }) + + // get method + obj.Set("get", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) >= 1 { + key := call.Argument(0).String() + value := params.Get(key) + if value != "" { + return runtime.ToValue(value) + } + } + return sobek.Null() + }) + + // getAll method + obj.Set("getAll", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) >= 1 { + key := call.Argument(0).String() + values := params[key] + return runtime.ToValue(values) + } + return runtime.ToValue([]string{}) + }) + + // has method + obj.Set("has", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) >= 1 { + key := call.Argument(0).String() + return runtime.ToValue(params.Has(key)) + } + return runtime.ToValue(false) + }) + + // set method + obj.Set("set", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) >= 2 { + key := call.Argument(0).String() + value := call.Argument(1).String() + params.Set(key, value) + } + return sobek.Undefined() + }) + + // toString method + obj.Set("toString", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(params.Encode()) + }) + + // keys method + obj.Set("keys", func(call sobek.FunctionCall) sobek.Value { + var keys []string + for key := range params { + keys = append(keys, key) + } + return runtime.ToValue(keys) + }) + + // values method + obj.Set("values", func(call sobek.FunctionCall) sobek.Value { + var values []string + for _, vals := range params { + values = append(values, vals...) + } + return runtime.ToValue(values) + }) + + return obj +} + +// Cleanup performs any necessary cleanup +func (u *URLModule) Cleanup() error { + // URL module doesn't need cleanup + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (u *URLModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["url"] + return exists && enabled +} diff --git a/jsserver/server.go b/jsserver/server.go index 799fbe7..652a9a0 100644 --- a/jsserver/server.go +++ b/jsserver/server.go @@ -11,24 +11,19 @@ import ( "github.com/grafana/sobek" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - "github.com/shiroyk/ski" - "github.com/shiroyk/ski/js" - - // Import ski modules - _ "github.com/shiroyk/ski/modules/buffer" - _ "github.com/shiroyk/ski/modules/cache" - _ "github.com/shiroyk/ski/modules/crypto" - _ "github.com/shiroyk/ski/modules/dom" - _ "github.com/shiroyk/ski/modules/encoding" - _ "github.com/shiroyk/ski/modules/ext" - _ "github.com/shiroyk/ski/modules/fetch" - _ "github.com/shiroyk/ski/modules/html" - _ "github.com/shiroyk/ski/modules/http" - httpmodule "github.com/shiroyk/ski/modules/http" - _ "github.com/shiroyk/ski/modules/signal" - _ "github.com/shiroyk/ski/modules/stream" - _ "github.com/shiroyk/ski/modules/timers" - _ "github.com/shiroyk/ski/modules/url" + + // Import our new VM system + "github.com/mark3labs/codebench-mcp/internal/logger" + "github.com/mark3labs/codebench-mcp/jsserver/modules/buffer" + "github.com/mark3labs/codebench-mcp/jsserver/modules/console" + "github.com/mark3labs/codebench-mcp/jsserver/modules/crypto" + "github.com/mark3labs/codebench-mcp/jsserver/modules/encoding" + "github.com/mark3labs/codebench-mcp/jsserver/modules/fetch" + "github.com/mark3labs/codebench-mcp/jsserver/modules/http" + "github.com/mark3labs/codebench-mcp/jsserver/modules/kv" + "github.com/mark3labs/codebench-mcp/jsserver/modules/timers" + "github.com/mark3labs/codebench-mcp/jsserver/modules/url" + "github.com/mark3labs/codebench-mcp/jsserver/vm" ) var Version = "dev" @@ -62,43 +57,41 @@ type ModuleConfig struct { } type JSHandler struct { - config ModuleConfig + vmManager *vm.VMManager + config ModuleConfig } func NewJSHandler() *JSHandler { return NewJSHandlerWithConfig(ModuleConfig{ - EnabledModules: []string{"http", "fetch", "timers", "buffer", "crypto"}, + EnabledModules: []string{"http", "fetch", "timers", "buffer", "kv", "crypto"}, }) } func NewJSHandlerWithConfig(config ModuleConfig) *JSHandler { - return &JSHandler{ - config: config, + // Create VM manager with enabled modules + enabledModules := config.EnabledModules + if len(enabledModules) == 0 && len(config.DisabledModules) == 0 { + // Default modules if none specified + enabledModules = []string{"fetch", "timers", "buffer", "kv"} } -} -func (h *JSHandler) isModuleEnabled(module string) bool { - // If disabled modules list is provided, check if module is not in it - if len(h.config.DisabledModules) > 0 { - for _, disabled := range h.config.DisabledModules { - if disabled == module { - return false - } - } - return true - } + vmManager := vm.NewVMManager(enabledModules) - // Otherwise check enabled modules list - if len(h.config.EnabledModules) == 0 { - return true // If no config, enable all - } + // Register all available modules + vmManager.RegisterModule(console.NewConsoleModule(nil)) // Console always enabled + vmManager.RegisterModule(kv.NewKVModule()) + vmManager.RegisterModule(timers.NewTimersModule()) + vmManager.RegisterModule(fetch.NewFetchModule()) + vmManager.RegisterModule(buffer.NewBufferModule()) + vmManager.RegisterModule(http.NewHTTPModule()) + vmManager.RegisterModule(crypto.NewCryptoModule()) + vmManager.RegisterModule(encoding.NewEncodingModule()) + vmManager.RegisterModule(url.NewURLModule()) - for _, enabled := range h.config.EnabledModules { - if enabled == module { - return true - } + return &JSHandler{ + vmManager: vmManager, + config: config, } - return false } func (h *JSHandler) handleExecuteJS( @@ -110,13 +103,17 @@ func (h *JSHandler) handleExecuteJS( return nil, err } + logger.Debug("Executing JavaScript code", "length", len(code)) + // Check if this looks like HTTP server code isServerCode := strings.Contains(code, "serve(") || strings.Contains(code, "require('ski/http/server')") if isServerCode { + logger.Debug("Detected server code, running in background") // For server code, run in a goroutine and return immediately return h.handleServerCode(ctx, code) } else { + logger.Debug("Running regular JavaScript code") // For regular code, run synchronously return h.handleRegularCode(ctx, code) } @@ -128,35 +125,27 @@ func (h *JSHandler) handleServerCode(ctx context.Context, code string) (*mcp.Cal captureHandler := &captureLogger{buffer: &output} logger := slog.New(captureHandler) - // Create context with custom logger - ctx = js.WithLogger(ctx, logger) - // Channel to signal if a server was actually started serverStarted := make(chan bool, 1) // Run the server code in a goroutine go func() { - // Create a custom scheduler for this server - schedulerOpts := ski.SchedulerOptions{ - InitialVMs: 1, - MaxVMs: 1, + // Create VM with custom logger for console output + vm, err := h.vmManager.CreateVM(ctx) + if err != nil { + logger.Error("Failed to create VM", "error", err) + serverStarted <- false + return } - scheduler := ski.NewScheduler(schedulerOpts) - ski.SetScheduler(scheduler) - defer scheduler.Close() + defer vm.Close() - // Create a VM with proper module initialization - vm := js.NewVM() - - // Override the HTTP server module if enabled - if h.isModuleEnabled("http") { - h.setupHTTPModuleWithCallback(vm, serverStarted) - } + // Override console module with our capture logger + consoleModule := console.NewConsoleModule(logger) + consoleModule.Setup(vm.Runtime(), h.vmManager) // Execute the JavaScript code - _, err := vm.RunString(context.Background(), code) + _, err = vm.RunStringWithEventLoop(code) if err != nil { - // Log error but don't return it since we're in a goroutine logger.Error("Server execution error", "error", err) serverStarted <- false return @@ -195,167 +184,97 @@ func (h *JSHandler) handleServerCode(ctx context.Context, code string) (*mcp.Cal }, nil } -func (h *JSHandler) setupHTTPModuleWithCallback(vm js.VM, serverStarted chan bool) { - // Create a custom module loader that wraps the HTTP server - vm.Runtime().Set("__originalRequire", vm.Runtime().Get("require")) - vm.Runtime().Set("require", vm.Runtime().ToValue(func(call sobek.FunctionCall) sobek.Value { - moduleName := call.Argument(0).String() - - // If requesting the HTTP server module, return our wrapped version - if moduleName == "ski/http/server" { - httpServer := &httpmodule.Server{} - value, err := httpServer.Instantiate(vm.Runtime()) - if err != nil { - panic(vm.Runtime().NewGoError(err)) - } - - // Wrap the serve function to detect when a server is actually started - wrappedServe := vm.Runtime().ToValue(func(call sobek.FunctionCall) sobek.Value { - // Call the original serve function - serveFunc, ok := sobek.AssertFunction(value) - if !ok { - panic(vm.Runtime().NewTypeError("serve is not a function")) - } - - result, err := serveFunc(sobek.Undefined(), call.Arguments...) - if err != nil { - panic(vm.Runtime().NewGoError(err)) - } - - // Signal that a server was started - select { - case serverStarted <- true: - default: - // Channel already has a value - } - - return result - }) - - return wrappedServe - } - - // For all other modules, use the original require - originalRequire, _ := sobek.AssertFunction(vm.Runtime().Get("__originalRequire")) - result, err := originalRequire(sobek.Undefined(), call.Arguments...) - if err != nil { - panic(vm.Runtime().NewGoError(err)) - } - return result - })) -} - func (h *JSHandler) handleRegularCode(ctx context.Context, code string) (*mcp.CallToolResult, error) { // Capture console output var output bytes.Buffer captureHandler := &captureLogger{buffer: &output} logger := slog.New(captureHandler) - // Create context with custom logger - ctx = js.WithLogger(ctx, logger) - - // Create a custom scheduler with limited modules based on config - schedulerOpts := ski.SchedulerOptions{ - InitialVMs: 1, - MaxVMs: 1, + // Create VM instance for this execution + vm, err := h.vmManager.CreateVM(ctx) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Failed to create VM: %v", err), + }, + }, + IsError: true, + }, nil } + defer vm.Close() - scheduler := ski.NewScheduler(schedulerOpts) - ski.SetScheduler(scheduler) - defer scheduler.Close() - - // Create a VM with proper module initialization - vm := js.NewVM() - - // Override the HTTP server module if enabled - if h.isModuleEnabled("http") { - h.setupHTTPModule(vm) - } + // Override console module with our capture logger + consoleModule := console.NewConsoleModule(logger) + consoleModule.Setup(vm.Runtime(), h.vmManager) // Execute the JavaScript code with a timeout for regular code execCtx, cancel := context.WithTimeout(ctx, time.Second*10) defer cancel() - result, err := vm.RunString(execCtx, code) + // Execute in a goroutine to respect timeout + resultChan := make(chan sobek.Value, 1) + errorChan := make(chan error, 1) - if err != nil { + go func() { + result, err := vm.RunStringWithEventLoop(code) + if err != nil { + errorChan <- err + } else { + resultChan <- result + } + }() + + select { + case <-execCtx.Done(): return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", - Text: fmt.Sprintf("JavaScript execution error: %v\n\nOutput:\n%s", err, output.String()), + Text: fmt.Sprintf("JavaScript execution timeout\n\nOutput:\n%s", output.String()), }, }, IsError: true, }, nil - } - - // Get the result value - var resultStr string - if result != nil && !sobek.IsUndefined(result) && !sobek.IsNull(result) { - exported := result.Export() - if exported != nil { - resultStr = fmt.Sprintf("Result: %v\n", exported) - } - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("%s%s", resultStr, output.String()), + case err := <-errorChan: + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("JavaScript execution error: %v\n\nOutput:\n%s", err, output.String()), + }, }, - }, - }, nil -} - -func (h *JSHandler) setupHTTPModule(vm js.VM) { - // Create a custom module loader that wraps the HTTP server - vm.Runtime().Set("__originalRequire", vm.Runtime().Get("require")) - vm.Runtime().Set("require", vm.Runtime().ToValue(func(call sobek.FunctionCall) sobek.Value { - moduleName := call.Argument(0).String() - - // If requesting the HTTP server module, return our wrapped version - if moduleName == "ski/http/server" { - httpServer := &httpmodule.Server{} - value, err := httpServer.Instantiate(vm.Runtime()) - if err != nil { - panic(vm.Runtime().NewGoError(err)) + IsError: true, + }, nil + case result := <-resultChan: + // Get the result value + var resultStr string + if result != nil && !sobek.IsUndefined(result) && !sobek.IsNull(result) { + exported := result.Export() + if exported != nil { + resultStr = fmt.Sprintf("Result: %v\n", exported) } - - // Don't wrap or unref - let the server run normally - return value } - // For all other modules, use the original require - originalRequire, _ := sobek.AssertFunction(vm.Runtime().Get("__originalRequire")) - result, err := originalRequire(sobek.Undefined(), call.Arguments...) - if err != nil { - panic(vm.Runtime().NewGoError(err)) - } - return result - })) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("%s%s", resultStr, output.String()), + }, + }, + }, nil + } } func (h *JSHandler) getAvailableModules() []string { - allModules := []string{ - "http", "fetch", "timers", "buffer", "cache", "crypto", "dom", - "encoding", "ext", "html", "signal", "stream", "url", - } - - // Always filter through isModuleEnabled for consistency - var enabled []string - for _, module := range allModules { - if h.isModuleEnabled(module) { - enabled = append(enabled, module) - } - } - return enabled + return h.vmManager.GetEnabledModules() } func NewJSServer() (*server.MCPServer, error) { return NewJSServerWithConfig(ModuleConfig{ - EnabledModules: []string{"http", "fetch", "timers", "buffer", "crypto"}, + EnabledModules: []string{"http", "fetch", "timers", "buffer", "kv", "crypto"}, }) } @@ -397,21 +316,17 @@ func buildToolDescription(enabledModules []string) string { description.WriteString("Available modules:\n") - // Define module descriptions with ski's actual features and require paths + // Define module descriptions moduleDescriptions := map[string]string{ "http": "HTTP server creation and management (const serve = require('ski/http/server'))", "fetch": "Modern fetch API with Request, Response, Headers, FormData (available globally)", "timers": "setTimeout, setInterval, clearTimeout, clearInterval (available globally)", "buffer": "Buffer, Blob, File APIs for binary data handling (available globally)", - "cache": "In-memory caching with TTL support (const cache = require('ski/cache'))", "crypto": "Cryptographic functions (hashing, encryption, HMAC) (const crypto = require('ski/crypto'))", - "dom": "DOM Event and EventTarget APIs (const dom = require('ski/dom'))", - "encoding": "TextEncoder, TextDecoder for text encoding/decoding (available globally)", - "ext": "Extended context and utility functions (const ext = require('ski/ext'))", - "html": "HTML parsing and manipulation (const html = require('ski/html'))", - "signal": "AbortController and AbortSignal for cancellation (available globally)", - "stream": "ReadableStream and streaming APIs (available globally)", - "url": "URL and URLSearchParams APIs (available globally)", + "kv": "Key-value store per VM instance with get, set, delete, list (available globally)", + "console": "Console logging with structured output (available globally)", + "encoding": "TextEncoder/TextDecoder for UTF-8 encoding/decoding (available globally)", + "url": "URL parsing and URLSearchParams manipulation (available globally)", } // Add enabled modules with descriptions @@ -451,13 +366,6 @@ func buildToolDescription(enabledModules []string) string { description.WriteString("console.log('Server running at:', server.url);\n\n") } - if enabledSet["cache"] { - description.WriteString("// Cache operations (require import)\n") - description.WriteString("const cache = require('ski/cache');\n") - description.WriteString("cache.set('key', 'value');\n") - description.WriteString("console.log(cache.get('key'));\n\n") - } - if enabledSet["crypto"] { description.WriteString("// Crypto operations (require import)\n") description.WriteString("const crypto = require('ski/crypto');\n") @@ -482,12 +390,7 @@ func buildToolDescription(enabledModules []string) string { description.WriteString("\nImportant notes:\n") description.WriteString("• Use require() for modules, NOT import statements\n") description.WriteString("• Modern JavaScript features supported (const/let, arrow functions, destructuring, etc.)\n") - - // Add HTTP-specific note only if HTTP is enabled - if enabledSet["http"] { - description.WriteString("• HTTP servers automatically run in background and don't block execution\n") - } - + description.WriteString("• HTTP servers automatically run in background and don't block execution\n") description.WriteString("• Async/await and Promises are fully supported\n") return description.String() diff --git a/jsserver/vm/eventloop.go b/jsserver/vm/eventloop.go new file mode 100644 index 0000000..0a8c143 --- /dev/null +++ b/jsserver/vm/eventloop.go @@ -0,0 +1,162 @@ +package vm + +import ( + "sync" + + "github.com/grafana/sobek" +) + +// EventLoop implements an event loop for asynchronous JavaScript operations +type EventLoop struct { + queue []func() error // queue to store the job to be executed + cleanup []func() // job of cleanup + enqueue uint // Count of job in the event loop + cond *sync.Cond // Condition variable for synchronization +} + +// NewEventLoop creates a new EventLoop instance +func NewEventLoop() *EventLoop { + return &EventLoop{ + cond: sync.NewCond(new(sync.Mutex)), + cleanup: make([]func(), 0), + } +} + +// Start the event loop and execute the provided function +func (e *EventLoop) Start(task func() error) (err error) { + e.cond.L.Lock() + e.queue = []func() error{task} + e.cond.L.Unlock() + + for { + e.cond.L.Lock() + + if len(e.queue) > 0 { + queue := e.queue + e.queue = make([]func() error, 0, len(queue)) + e.cond.L.Unlock() + + for _, job := range queue { + if err2 := job(); err2 != nil { + if err != nil { + err = append(err.(joinError), err2) + } else { + err = joinError{err2} + } + } + } + continue + } + + if e.enqueue > 0 { + e.cond.Wait() + e.cond.L.Unlock() + continue + } + + if len(e.cleanup) > 0 { + cleanup := e.cleanup + e.cleanup = e.cleanup[:0] + e.cond.L.Unlock() + + for _, clean := range cleanup { + clean() + } + } else { + e.cond.L.Unlock() + } + + return + } +} + +// Enqueue add a job to the job queue. +type Enqueue func(func() error) + +// EnqueueJob return a function Enqueue to add a job to the job queue. +func (e *EventLoop) EnqueueJob() Enqueue { + e.cond.L.Lock() + called := false + e.enqueue++ + e.cond.L.Unlock() + return func(job func() error) { + e.cond.L.Lock() + defer e.cond.L.Unlock() + switch { + case called: + panic("Enqueue already called") + case e.enqueue == 0: + return // Eventloop stopped + } + e.queue = append(e.queue, job) // Add the job to the queue + called = true + e.enqueue-- + e.cond.Signal() // Signal the condition variable + } +} + +// Stop the eventloop with the provided error +func (e *EventLoop) Stop(err error) { + e.cond.L.Lock() + defer e.cond.L.Unlock() + // clean the queue + e.queue = append(e.queue[:0], func() error { return err }) + e.enqueue = 0 + e.cond.Signal() +} + +// Cleanup add a function to execute when run finish. +func (e *EventLoop) Cleanup(job ...func()) { + e.cond.L.Lock() + defer e.cond.L.Unlock() + + e.cleanup = append(e.cleanup, job...) +} + +// joinError represents multiple errors joined together +type joinError []error + +func (je joinError) Error() string { + if len(je) == 0 { + return "" + } + if len(je) == 1 { + return je[0].Error() + } + + result := je[0].Error() + for _, err := range je[1:] { + result += "; " + err.Error() + } + return result +} + +// Helper functions for runtime integration + +var symbolVM = sobek.NewSymbol("Symbol.__vm__") + +// vmSelf holds a reference to the VM for runtime access +type vmSelf struct { + vm *VM +} + +// EnqueueJob returns a function to enqueue jobs for the given runtime +func EnqueueJob(rt *sobek.Runtime) Enqueue { + return getVMFromRuntime(rt).eventLoop.EnqueueJob() +} + +// Cleanup adds cleanup functions for the given runtime +func Cleanup(rt *sobek.Runtime, job ...func()) { + getVMFromRuntime(rt).eventLoop.Cleanup(job...) +} + +// getVMFromRuntime extracts the VM instance from the runtime +func getVMFromRuntime(rt *sobek.Runtime) *VM { + value := rt.GlobalObject().GetSymbol(symbolVM) + if value != nil { + if vmSelf, ok := value.Export().(*vmSelf); ok { + return vmSelf.vm + } + } + panic(rt.NewTypeError("VM symbol not found in runtime - this shouldn't happen")) +} \ No newline at end of file diff --git a/jsserver/vm/manager.go b/jsserver/vm/manager.go new file mode 100644 index 0000000..3c25bed --- /dev/null +++ b/jsserver/vm/manager.go @@ -0,0 +1,135 @@ +package vm + +import ( + "context" + + "github.com/grafana/sobek" +) + +// VMManager manages Sobek VM instances +type VMManager struct { + enabledModules map[string]bool + registry *ModuleRegistry +} + +// NewVMManager creates a new VM manager with specified enabled modules +func NewVMManager(enabledModules []string) *VMManager { + enabledMap := make(map[string]bool) + for _, module := range enabledModules { + enabledMap[module] = true + } + + return &VMManager{ + enabledModules: enabledMap, + registry: NewModuleRegistry(), + } +} + +// RegisterModule adds a module to the manager +func (m *VMManager) RegisterModule(module Module) error { + m.registry.Register(module) + return nil +} + +// CreateVM creates a new VM instance with all enabled modules +// Each VM is completely isolated +func (m *VMManager) CreateVM(ctx context.Context) (*VM, error) { + // Create new Sobek runtime + rt := sobek.New() + + // Create event loop + eventLoop := NewEventLoop() + + vm := &VM{ + runtime: rt, + manager: m, + ctx: ctx, + eventLoop: eventLoop, + } + + // Store VM reference in runtime for event loop access + _ = rt.GlobalObject().SetSymbol(symbolVM, &vmSelf{vm: vm}) + + // Setup all enabled modules + enabledModules := m.registry.GetEnabled(m.enabledModules) + for _, module := range enabledModules { + if err := module.Setup(rt, m); err != nil { + return nil, err + } + } + + return vm, nil +} + +// GetEnabledModules returns the list of enabled module names +func (m *VMManager) GetEnabledModules() []string { + var enabled []string + for module := range m.enabledModules { + enabled = append(enabled, module) + } + return enabled +} + +// VM wraps a Sobek runtime with event loop support +type VM struct { + runtime *sobek.Runtime + manager *VMManager + ctx context.Context + eventLoop *EventLoop +} + +// RunString executes JavaScript code in the VM +func (vm *VM) RunString(code string) (sobek.Value, error) { + return vm.runtime.RunString(code) +} + +// RunStringWithEventLoop executes JavaScript code with event loop support +// This matches ski's pattern where RunString calls Run() which uses the event loop +func (vm *VM) RunStringWithEventLoop(code string) (ret sobek.Value, err error) { + err = vm.runWithEventLoop(func() error { + ret, err = vm.runtime.RunString(code) + return err + }) + return +} + +// runWithEventLoop executes a task in the event loop (similar to ski's Run method) +func (vm *VM) runWithEventLoop(task func() error) error { + // Clear any previous interrupt + vm.runtime.ClearInterrupt() + + // Set up context cancellation to interrupt the runtime if needed + if vm.ctx != nil { + go func() { + <-vm.ctx.Done() + vm.runtime.Interrupt(vm.ctx.Err()) + vm.eventLoop.Stop(vm.ctx.Err()) + }() + } + + return vm.eventLoop.Start(task) +} + +// SetGlobal sets a global variable in the VM +func (vm *VM) SetGlobal(name string, value interface{}) { + vm.runtime.Set(name, value) +} + +// Runtime returns the underlying Sobek runtime +func (vm *VM) Runtime() *sobek.Runtime { + return vm.runtime +} + +// Close cleans up the VM and its modules +func (vm *VM) Close() error { + // Cleanup all modules + enabledModules := vm.manager.registry.GetEnabled(vm.manager.enabledModules) + for _, module := range enabledModules { + if err := module.Cleanup(); err != nil { + // Log error but continue cleanup + continue + } + } + + return nil +} diff --git a/jsserver/vm/module.go b/jsserver/vm/module.go new file mode 100644 index 0000000..34fc108 --- /dev/null +++ b/jsserver/vm/module.go @@ -0,0 +1,54 @@ +package vm + +import "github.com/grafana/sobek" + +// Module interface defines how modules integrate with the VM +type Module interface { + Name() string + Setup(runtime *sobek.Runtime, manager *VMManager) error + Cleanup() error + IsEnabled(enabledModules map[string]bool) bool +} + +// ModuleRegistry manages available modules +type ModuleRegistry struct { + modules map[string]Module +} + +// NewModuleRegistry creates a new module registry +func NewModuleRegistry() *ModuleRegistry { + return &ModuleRegistry{ + modules: make(map[string]Module), + } +} + +// Register adds a module to the registry +func (r *ModuleRegistry) Register(module Module) { + r.modules[module.Name()] = module +} + +// Get retrieves a module by name +func (r *ModuleRegistry) Get(name string) (Module, bool) { + module, exists := r.modules[name] + return module, exists +} + +// GetEnabled returns all enabled modules based on configuration +func (r *ModuleRegistry) GetEnabled(enabledModules map[string]bool) []Module { + var enabled []Module + for _, module := range r.modules { + if module.IsEnabled(enabledModules) { + enabled = append(enabled, module) + } + } + return enabled +} + +// List returns all registered module names +func (r *ModuleRegistry) List() []string { + var names []string + for name := range r.modules { + names = append(names, name) + } + return names +} diff --git a/test_kv.json b/test_kv.json new file mode 100644 index 0000000..a253844 --- /dev/null +++ b/test_kv.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "executeJS", + "arguments": { + "code": "// Test KV module\nconsole.log('Testing KV module');\nkv.set('name', 'CodeBench');\nkv.set('version', '1.0');\nconsole.log('Stored values');\nconsole.log('name:', kv.get('name'));\nconsole.log('version:', kv.get('version'));\nconsole.log('keys:', kv.list());\nconsole.log('size:', kv.size());\n'KV test completed'" + } + } +} From 47c0ec70adf53e684cdedcca48f0c5c418e418b6 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 13 Jun 2025 17:22:30 +0300 Subject: [PATCH 2/8] more progress --- cmd/root.go | 4 +- jsserver/modules/buffer/buffer.go | 13 +- jsserver/modules/cache/cache.go | 229 ++++++++++++++++++++++++++++ jsserver/modules/console/console.go | 55 +++---- jsserver/modules/crypto/crypto.go | 34 +---- jsserver/modules/fetch/fetch.go | 223 +++++++++++++++------------ jsserver/modules/http/http.go | 27 +--- jsserver/modules/kv/kv.go | 20 ++- jsserver/modules/timers/timers.go | 81 ++++++++-- jsserver/server.go | 73 +++------ jsserver/vm/eventloop.go | 33 +++- jsserver/vm/loader.go | 105 +++++++++++++ jsserver/vm/manager.go | 32 +++- jsserver/vm/module.go | 8 +- 14 files changed, 681 insertions(+), 256 deletions(-) create mode 100644 jsserver/modules/cache/cache.go create mode 100644 jsserver/vm/loader.go diff --git a/cmd/root.go b/cmd/root.go index 0fe3d2e..d4a1dd2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,8 +28,8 @@ var availableModules = []string{ "crypto", "encoding", "url", + "cache", // TODO: Add these as they're implemented - // "cache", // "dom", // "ext", // "html", @@ -78,7 +78,7 @@ with ski runtime including http, fetch, timers, buffer, crypto, and other module } } else { // Enable default modules (same as NewJSHandler default) - modulesToEnable = []string{"http", "fetch", "timers", "buffer", "kv", "crypto"} + modulesToEnable = []string{"http", "fetch", "timers", "buffer", "kv", "crypto", "encoding", "url", "cache"} } logger.Debug("Module configuration", "enabled", modulesToEnable) diff --git a/jsserver/modules/buffer/buffer.go b/jsserver/modules/buffer/buffer.go index 76387c1..82febf8 100644 --- a/jsserver/modules/buffer/buffer.go +++ b/jsserver/modules/buffer/buffer.go @@ -62,10 +62,15 @@ func (b *BufferModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) erro } else { // Try to convert to array exported := arg.Export() - if arr, ok := exported.([]interface{}); ok { - data = make([]byte, len(arr)) - for i, v := range arr { - if num, ok := v.(float64); ok { + switch v := exported.(type) { + case []byte: + // Direct byte array + data = v + case []any: + // Array of any (same as []interface{}) + data = make([]byte, len(v)) + for i, val := range v { + if num, ok := val.(float64); ok { data[i] = byte(int(num)) } } diff --git a/jsserver/modules/cache/cache.go b/jsserver/modules/cache/cache.go new file mode 100644 index 0000000..ef57078 --- /dev/null +++ b/jsserver/modules/cache/cache.go @@ -0,0 +1,229 @@ +package cache + +import ( + "context" + "sync" + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/jsserver/vm" +) + +// CacheModule provides in-memory caching with TTL support +type CacheModule struct { + cache Cache +} + +// NewCacheModule creates a new cache module +func NewCacheModule() *CacheModule { + return &CacheModule{ + cache: NewCache(), + } +} + +// Name returns the module name +func (c *CacheModule) Name() string { + return "cache" +} + +// Setup initializes the cache module in the VM +func (c *CacheModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // No setup needed - the module will be available via require() + return nil +} + +// CreateModuleObject creates the cache object when required +func (c *CacheModule) CreateModuleObject(runtime *sobek.Runtime) sobek.Value { + return c.createCacheObject(runtime) +} + +// createCacheObject creates the cache object with all methods +func (c *CacheModule) createCacheObject(runtime *sobek.Runtime) sobek.Value { + cache := runtime.NewObject() + + // get(key) - returns string value or undefined + cache.Set("get", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return sobek.Undefined() + } + + key := call.Argument(0).String() + if bytes, err := c.cache.Get(context.Background(), key); err == nil && bytes != nil { + return runtime.ToValue(string(bytes)) + } + return sobek.Undefined() + }) + + // getBytes(key) - returns ArrayBuffer or undefined + cache.Set("getBytes", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return sobek.Undefined() + } + + key := call.Argument(0).String() + if bytes, err := c.cache.Get(context.Background(), key); err == nil && bytes != nil { + return runtime.ToValue(runtime.NewArrayBuffer(bytes)) + } + return sobek.Undefined() + }) + + // set(key, value, ttlMs?) - stores string value with optional TTL in milliseconds + cache.Set("set", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) < 2 { + panic(runtime.NewTypeError("cache.set requires at least 2 arguments")) + } + + key := call.Argument(0).String() + value := []byte(call.Argument(1).String()) + + var timeout time.Duration + if len(call.Arguments) > 2 && !sobek.IsUndefined(call.Argument(2)) { + timeout = time.Millisecond * time.Duration(call.Argument(2).ToInteger()) + } + + err := c.cache.Set(context.Background(), key, value, timeout) + if err != nil { + panic(runtime.NewGoError(err)) + } + + return sobek.Undefined() + }) + + // setBytes(key, arrayBuffer, ttlMs?) - stores ArrayBuffer with optional TTL + cache.Set("setBytes", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) < 2 { + panic(runtime.NewTypeError("cache.setBytes requires at least 2 arguments")) + } + + key := call.Argument(0).String() + + // Convert value to bytes + var value []byte + arg := call.Argument(1) + if exported := arg.Export(); exported != nil { + switch v := exported.(type) { + case []byte: + value = v + case []any: + // Convert array of numbers to bytes + value = make([]byte, len(v)) + for i, val := range v { + if num, ok := val.(float64); ok { + value[i] = byte(int(num)) + } + } + default: + // Convert to string and then bytes + value = []byte(arg.String()) + } + } else { + value = []byte(arg.String()) + } + + var timeout time.Duration + if len(call.Arguments) > 2 && !sobek.IsUndefined(call.Argument(2)) { + timeout = time.Millisecond * time.Duration(call.Argument(2).ToInteger()) + } + + err := c.cache.Set(context.Background(), key, value, timeout) + if err != nil { + panic(runtime.NewGoError(err)) + } + + return sobek.Undefined() + }) + + // del(key) - removes key from cache + cache.Set("del", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + return sobek.Undefined() + } + + key := call.Argument(0).String() + err := c.cache.Del(context.Background(), key) + if err != nil { + panic(runtime.NewGoError(err)) + } + + return sobek.Undefined() + }) + + return cache +} + +// Cleanup performs any necessary cleanup +func (c *CacheModule) Cleanup() error { + // Memory cache doesn't need explicit cleanup + return nil +} + +// IsEnabled checks if the module should be enabled based on configuration +func (c *CacheModule) IsEnabled(enabledModules map[string]bool) bool { + enabled, exists := enabledModules["cache"] + return exists && enabled +} + +// Cache interface for storing bytes with TTL +type Cache interface { + Get(ctx context.Context, key string) ([]byte, error) + Set(ctx context.Context, key string, value []byte, timeout time.Duration) error + Del(ctx context.Context, key string) error +} + +// memoryCache is an implementation of Cache that stores bytes in in-memory +type memoryCache struct { + sync.Mutex + items map[string][]byte + timeout map[string]int64 +} + +// Get returns the []byte if existing and not expired +func (c *memoryCache) Get(_ context.Context, key string) ([]byte, error) { + c.Lock() + defer c.Unlock() + + if ddl, exist := c.timeout[key]; exist { + if time.Now().UnixMilli() > ddl { + delete(c.items, key) + delete(c.timeout, key) + return nil, nil + } + } + + return c.items[key], nil +} + +// Set saves []byte to the cache with key and optional timeout +func (c *memoryCache) Set(_ context.Context, key string, value []byte, timeout time.Duration) error { + c.Lock() + defer c.Unlock() + + c.items[key] = value + if timeout > 0 { + c.timeout[key] = time.Now().Add(timeout).UnixMilli() + } else { + // No timeout - store indefinitely + delete(c.timeout, key) + } + + return nil +} + +// Del removes key from the cache +func (c *memoryCache) Del(_ context.Context, key string) error { + c.Lock() + defer c.Unlock() + + delete(c.items, key) + delete(c.timeout, key) + + return nil +} + +// NewCache returns a new Cache that will store items in in-memory +func NewCache() Cache { + return &memoryCache{ + items: make(map[string][]byte), + timeout: make(map[string]int64), + } +} \ No newline at end of file diff --git a/jsserver/modules/console/console.go b/jsserver/modules/console/console.go index 5637e6e..3bbc2a2 100644 --- a/jsserver/modules/console/console.go +++ b/jsserver/modules/console/console.go @@ -2,25 +2,23 @@ package console import ( "fmt" - "log/slog" "strings" "github.com/grafana/sobek" - "github.com/mark3labs/codebench-mcp/jsserver/vm" ) // ConsoleModule provides console.log, console.error, etc. type ConsoleModule struct { - logger *slog.Logger + output *strings.Builder } // NewConsoleModule creates a new console module -func NewConsoleModule(logger *slog.Logger) *ConsoleModule { - if logger == nil { - logger = slog.Default() +func NewConsoleModule(output *strings.Builder) *ConsoleModule { + if output == nil { + output = &strings.Builder{} } return &ConsoleModule{ - logger: logger, + output: output, } } @@ -39,57 +37,62 @@ func (c *ConsoleModule) formatArgs(args []sobek.Value) string { return strings.Join(parts, " ") } +// writeMessage writes a message to the output +func (c *ConsoleModule) writeMessage(message string) { + if c.output != nil { + c.output.WriteString(message) + c.output.WriteString("\n") + } +} + +// GetOutput returns the captured console output +func (c *ConsoleModule) GetOutput() string { + if c.output == nil { + return "" + } + return c.output.String() +} + // Setup initializes the console module in the VM -func (c *ConsoleModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { +func (c *ConsoleModule) Setup(runtime *sobek.Runtime) error { console := runtime.NewObject() // console.log console.Set("log", func(call sobek.FunctionCall) sobek.Value { message := c.formatArgs(call.Arguments) - c.logger.Info(message) + c.writeMessage(message) return sobek.Undefined() }) // console.error console.Set("error", func(call sobek.FunctionCall) sobek.Value { message := c.formatArgs(call.Arguments) - c.logger.Error(message) + c.writeMessage(message) return sobek.Undefined() }) // console.warn console.Set("warn", func(call sobek.FunctionCall) sobek.Value { message := c.formatArgs(call.Arguments) - c.logger.Warn(message) + c.writeMessage(message) return sobek.Undefined() }) // console.info console.Set("info", func(call sobek.FunctionCall) sobek.Value { message := c.formatArgs(call.Arguments) - c.logger.Info(message) + c.writeMessage(message) return sobek.Undefined() }) // console.debug console.Set("debug", func(call sobek.FunctionCall) sobek.Value { message := c.formatArgs(call.Arguments) - c.logger.Debug(message) + c.writeMessage(message) return sobek.Undefined() }) + // Set console as global runtime.Set("console", console) return nil -} - -// Cleanup performs any necessary cleanup -func (c *ConsoleModule) Cleanup() error { - // Console module doesn't need cleanup - return nil -} - -// IsEnabled checks if the module should be enabled based on configuration -func (c *ConsoleModule) IsEnabled(enabledModules map[string]bool) bool { - // Console is always enabled as it's essential for debugging - return true -} +} \ No newline at end of file diff --git a/jsserver/modules/crypto/crypto.go b/jsserver/modules/crypto/crypto.go index 606c88e..d56f576 100644 --- a/jsserver/modules/crypto/crypto.go +++ b/jsserver/modules/crypto/crypto.go @@ -50,37 +50,15 @@ func (e *Encoder) bytes() []byte { // Setup initializes the crypto module in the VM func (c *CryptoModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { - // Create a require function that can load the crypto module - existingRequire := runtime.Get("require") - runtime.Set("require", func(call sobek.FunctionCall) sobek.Value { - if len(call.Arguments) == 0 { - return sobek.Undefined() - } - - moduleName := call.Argument(0).String() - - switch moduleName { - case "crypto", "ski/crypto": - // Return the crypto object - return c.createCryptoObject(runtime) - default: - // For other modules, call existing require if it exists - if existingRequire != nil && !sobek.IsUndefined(existingRequire) { - if fn, ok := sobek.AssertFunction(existingRequire); ok { - result, err := fn(sobek.Undefined(), call.Arguments...) - if err != nil { - panic(runtime.NewGoError(err)) - } - return result - } - } - return sobek.Undefined() - } - }) - + // No setup needed - the module will be available via require() return nil } +// CreateModuleObject creates the crypto object when required +func (c *CryptoModule) CreateModuleObject(runtime *sobek.Runtime) sobek.Value { + return c.createCryptoObject(runtime) +} + // createCryptoObject creates the crypto module object func (c *CryptoModule) createCryptoObject(runtime *sobek.Runtime) sobek.Value { crypto := runtime.NewObject() diff --git a/jsserver/modules/fetch/fetch.go b/jsserver/modules/fetch/fetch.go index 9fd891b..b3739a3 100644 --- a/jsserver/modules/fetch/fetch.go +++ b/jsserver/modules/fetch/fetch.go @@ -3,6 +3,7 @@ package fetch import ( "io" "net/http" + "net/http/cookiejar" "strings" "time" @@ -17,9 +18,13 @@ type FetchModule struct { // NewFetchModule creates a new fetch module func NewFetchModule() *FetchModule { + // Create cookie jar for automatic cookie handling + jar, _ := cookiejar.New(nil) + return &FetchModule{ client: &http.Client{ Timeout: 30 * time.Second, + Jar: jar, }, } } @@ -31,107 +36,28 @@ func (f *FetchModule) Name() string { // Setup initializes the fetch module in the VM func (f *FetchModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { - // fetch(url, options) - runtime.Set("fetch", func(call sobek.FunctionCall) sobek.Value { - if len(call.Arguments) == 0 { - panic(runtime.NewTypeError("fetch: URL is required")) - } - - url := call.Argument(0).String() - - // Default options - method := "GET" - var body io.Reader - headers := make(map[string]string) - - // Parse options if provided - if len(call.Arguments) > 1 && !sobek.IsUndefined(call.Argument(1)) { - options := call.Argument(1).ToObject(runtime) - - if methodVal := options.Get("method"); methodVal != nil && !sobek.IsUndefined(methodVal) { - method = strings.ToUpper(methodVal.String()) - } - - if bodyVal := options.Get("body"); bodyVal != nil && !sobek.IsUndefined(bodyVal) { - bodyStr := bodyVal.String() - body = strings.NewReader(bodyStr) - } - - if headersVal := options.Get("headers"); headersVal != nil && !sobek.IsUndefined(headersVal) { - headersObj := headersVal.ToObject(runtime) - for _, key := range headersObj.Keys() { - headers[key] = headersObj.Get(key).String() - } - } - } - - // Create HTTP request - req, err := http.NewRequest(method, url, body) - if err != nil { - panic(runtime.NewGoError(err)) - } - - // Set headers - for key, value := range headers { - req.Header.Set(key, value) - } - - // Make the request - resp, err := f.client.Do(req) - if err != nil { - panic(runtime.NewGoError(err)) - } - - // Create Response object - responseObj := runtime.NewObject() - responseObj.Set("status", resp.StatusCode) - responseObj.Set("statusText", resp.Status) - responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) - responseObj.Set("url", resp.Request.URL.String()) - - // Headers object - headersObj := runtime.NewObject() - for key, values := range resp.Header { - if len(values) > 0 { - headersObj.Set(key, values[0]) - } - } - responseObj.Set("headers", headersObj) - - // Read response body - bodyBytes, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - panic(runtime.NewGoError(err)) - } - - // text() method - responseObj.Set("text", func(call sobek.FunctionCall) sobek.Value { - return runtime.ToValue(string(bodyBytes)) - }) - - // json() method - responseObj.Set("json", func(call sobek.FunctionCall) sobek.Value { - var result interface{} - if err := runtime.ExportTo(runtime.ToValue(string(bodyBytes)), &result); err != nil { - // Try to parse as JSON - jsonVal, err := runtime.RunString("JSON.parse(" + runtime.ToValue(string(bodyBytes)).String() + ")") - if err != nil { - panic(runtime.NewGoError(err)) - } - return jsonVal - } - return runtime.ToValue(result) - }) + // No setup needed - fetch will be available as a global + return nil +} - // arrayBuffer() method - responseObj.Set("arrayBuffer", func(call sobek.FunctionCall) sobek.Value { - return runtime.ToValue(bodyBytes) - }) +// GetGlobalName returns the global name for this module +func (f *FetchModule) GetGlobalName() string { + return "fetch" +} - return responseObj +// CreateGlobalObject creates the fetch function and related objects for global access +func (f *FetchModule) CreateGlobalObject(runtime *sobek.Runtime) sobek.Value { + // Set up all fetch-related globals + f.setupFetchGlobals(runtime) + + // Return the main fetch function + return runtime.ToValue(func(call sobek.FunctionCall) sobek.Value { + return f.handleFetch(call, runtime) }) +} +// setupFetchGlobals sets up Request, Response, Headers, FormData constructors +func (f *FetchModule) setupFetchGlobals(runtime *sobek.Runtime) { // Request constructor runtime.Set("Request", func(call sobek.ConstructorCall) *sobek.Object { obj := call.This @@ -203,8 +129,107 @@ func (f *FetchModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error return nil }) +} - return nil +// handleFetch handles the main fetch function call +func (f *FetchModule) handleFetch(call sobek.FunctionCall, runtime *sobek.Runtime) sobek.Value { + if len(call.Arguments) == 0 { + panic(runtime.NewTypeError("fetch: URL is required")) + } + + url := call.Argument(0).String() + + // Default options + method := "GET" + var body io.Reader + headers := make(map[string]string) + + // Parse options if provided + if len(call.Arguments) > 1 && !sobek.IsUndefined(call.Argument(1)) { + options := call.Argument(1).ToObject(runtime) + + if methodVal := options.Get("method"); methodVal != nil && !sobek.IsUndefined(methodVal) { + method = strings.ToUpper(methodVal.String()) + } + + if bodyVal := options.Get("body"); bodyVal != nil && !sobek.IsUndefined(bodyVal) { + bodyStr := bodyVal.String() + body = strings.NewReader(bodyStr) + } + + if headersVal := options.Get("headers"); headersVal != nil && !sobek.IsUndefined(headersVal) { + headersObj := headersVal.ToObject(runtime) + for _, key := range headersObj.Keys() { + headers[key] = headersObj.Get(key).String() + } + } + } + + // Create HTTP request + req, err := http.NewRequest(method, url, body) + if err != nil { + panic(runtime.NewGoError(err)) + } + + // Set headers + for key, value := range headers { + req.Header.Set(key, value) + } + + // Make the request + resp, err := f.client.Do(req) + if err != nil { + panic(runtime.NewGoError(err)) + } + + // Create Response object + responseObj := runtime.NewObject() + responseObj.Set("status", resp.StatusCode) + responseObj.Set("statusText", resp.Status) + responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) + responseObj.Set("url", resp.Request.URL.String()) + + // Headers object + headersObj := runtime.NewObject() + for key, values := range resp.Header { + if len(values) > 0 { + headersObj.Set(key, values[0]) + } + } + responseObj.Set("headers", headersObj) + + // Read response body + bodyBytes, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + panic(runtime.NewGoError(err)) + } + + // text() method + responseObj.Set("text", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(string(bodyBytes)) + }) + + // json() method + responseObj.Set("json", func(call sobek.FunctionCall) sobek.Value { + var result any + if err := runtime.ExportTo(runtime.ToValue(string(bodyBytes)), &result); err != nil { + // Try to parse as JSON + jsonVal, err := runtime.RunString("JSON.parse(" + runtime.ToValue(string(bodyBytes)).String() + ")") + if err != nil { + panic(runtime.NewGoError(err)) + } + return jsonVal + } + return runtime.ToValue(result) + }) + + // arrayBuffer() method + responseObj.Set("arrayBuffer", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(bodyBytes) + }) + + return responseObj } // Cleanup performs any necessary cleanup @@ -217,4 +242,4 @@ func (f *FetchModule) Cleanup() error { func (f *FetchModule) IsEnabled(enabledModules map[string]bool) bool { enabled, exists := enabledModules["fetch"] return exists && enabled -} +} \ No newline at end of file diff --git a/jsserver/modules/http/http.go b/jsserver/modules/http/http.go index 677077e..f511993 100644 --- a/jsserver/modules/http/http.go +++ b/jsserver/modules/http/http.go @@ -40,27 +40,16 @@ type httpServer struct { // Setup initializes the HTTP module in the VM func (h *HTTPModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { - // Create a require function that can load the HTTP server - runtime.Set("require", func(call sobek.FunctionCall) sobek.Value { - if len(call.Arguments) == 0 { - return sobek.Undefined() - } + // No setup needed - the module will be available via require() + return nil +} - moduleName := call.Argument(0).String() - - switch moduleName { - case "ski/http/server": - // Return the serve function - return runtime.ToValue(func(call sobek.FunctionCall) sobek.Value { - return h.createServer(call, runtime) - }) - default: - // For other modules, return undefined - return sobek.Undefined() - } +// CreateModuleObject creates the HTTP server module when required +func (h *HTTPModule) CreateModuleObject(runtime *sobek.Runtime) sobek.Value { + // Return the serve function directly for http/server + return runtime.ToValue(func(call sobek.FunctionCall) sobek.Value { + return h.createServer(call, runtime) }) - - return nil } // createServer creates and starts an HTTP server diff --git a/jsserver/modules/kv/kv.go b/jsserver/modules/kv/kv.go index 959ca44..3590319 100644 --- a/jsserver/modules/kv/kv.go +++ b/jsserver/modules/kv/kv.go @@ -7,13 +7,13 @@ import ( // KVModule provides key-value storage per VM instance type KVModule struct { - store map[string]interface{} // Per-VM instance storage + store map[string]any // Per-VM instance storage } // NewKVModule creates a new KV module with isolated storage func NewKVModule() *KVModule { return &KVModule{ - store: make(map[string]interface{}), + store: make(map[string]any), } } @@ -24,6 +24,17 @@ func (kv *KVModule) Name() string { // Setup initializes the KV module in the VM func (kv *KVModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { + // No setup needed - kv will be available as a global + return nil +} + +// GetGlobalName returns the global name for this module +func (kv *KVModule) GetGlobalName() string { + return "kv" +} + +// CreateGlobalObject creates the kv object for global access +func (kv *KVModule) CreateGlobalObject(runtime *sobek.Runtime) sobek.Value { kvObj := runtime.NewObject() // kv.get(key) - retrieve a value @@ -75,7 +86,7 @@ func (kv *KVModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { // kv.clear() - clear all data kvObj.Set("clear", func(call sobek.FunctionCall) sobek.Value { - kv.store = make(map[string]interface{}) + kv.store = make(map[string]any) return runtime.ToValue(true) }) @@ -94,8 +105,7 @@ func (kv *KVModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { return runtime.ToValue(len(kv.store)) }) - runtime.Set("kv", kvObj) - return nil + return kvObj } // Cleanup performs any necessary cleanup diff --git a/jsserver/modules/timers/timers.go b/jsserver/modules/timers/timers.go index 93b2933..f65ecb4 100644 --- a/jsserver/modules/timers/timers.go +++ b/jsserver/modules/timers/timers.go @@ -4,6 +4,7 @@ import ( "time" "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" "github.com/mark3labs/codebench-mcp/jsserver/vm" ) @@ -22,8 +23,12 @@ func (t *TimersModule) Name() string { // Setup initializes the timers module in the VM func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { - // setTimeout + logger.Debug("Setting up timers module") + + // setTimeout - copied exactly from ski runtime.Set("setTimeout", func(call sobek.FunctionCall) sobek.Value { + logger.Debug("setTimeout called", "args", len(call.Arguments)) + callback, ok := sobek.AssertFunction(call.Argument(0)) if !ok { panic(runtime.NewTypeError("setTimeout: first argument must be a function")) @@ -34,42 +39,63 @@ func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) erro i = 1 } delay := time.Duration(i) * time.Millisecond + logger.Debug("setTimeout delay", "ms", i) var args []sobek.Value if len(call.Arguments) > 2 { args = call.Arguments[2:] } + logger.Debug("Getting enqueue function") enqueue := vm.EnqueueJob(runtime) + logger.Debug("Creating timer") t := rtTimers(runtime).new(delay, false) + logger.Debug("Timer created", "id", t.id) vm.Cleanup(runtime, t.stop) + vm.AddPending(runtime) // Track this timer as a pending operation + task := func() error { + logger.Debug("Timer task executing", "id", t.id) defer t.stop() + defer vm.RemovePending(runtime) // Remove pending operation when timer completes _, err := callback(sobek.Undefined(), args...) + logger.Debug("Timer task completed", "id", t.id, "error", err) return err } + logger.Debug("Starting timer goroutine", "id", t.id) go func() { + logger.Debug("Timer goroutine started", "id", t.id) select { case <-t.timer: + logger.Debug("Timer fired, enqueueing task", "id", t.id) enqueue(task) + logger.Debug("Task enqueued", "id", t.id) case <-t.done: + logger.Debug("Timer cancelled, enqueueing nothing", "id", t.id) + vm.RemovePending(runtime) // Remove pending operation when timer is cancelled enqueue(nothing) + logger.Debug("Nothing enqueued", "id", t.id) } + logger.Debug("Timer goroutine finished", "id", t.id) }() + logger.Debug("setTimeout returning", "id", t.id) return runtime.ToValue(t.id) }) - // clearTimeout + // clearTimeout - copied exactly from ski runtime.Set("clearTimeout", func(call sobek.FunctionCall) sobek.Value { id := call.Argument(0).ToInteger() + logger.Debug("clearTimeout called", "id", id) rtTimers(runtime).stop(id) return sobek.Undefined() }) - // setInterval + // setInterval - copied exactly from ski runtime.Set("setInterval", func(call sobek.FunctionCall) sobek.Value { + logger.Debug("setInterval called", "args", len(call.Arguments)) + callback, ok := sobek.AssertFunction(call.Argument(0)) if !ok { panic(runtime.NewTypeError("setInterval: first argument must be a function")) @@ -80,6 +106,7 @@ func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) erro i = 1 } delay := time.Duration(i) * time.Millisecond + logger.Debug("setInterval delay", "ms", i) var args []sobek.Value if len(call.Arguments) > 2 { @@ -89,19 +116,29 @@ func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) erro enqueue := vm.EnqueueJob(runtime) t := rtTimers(runtime).new(delay, true) vm.Cleanup(runtime, t.stop) + vm.AddPending(runtime) // Track this interval as a pending operation task := func() error { + logger.Debug("Interval task executing", "id", t.id) _, err := callback(sobek.Undefined(), args...) + logger.Debug("Interval task completed", "id", t.id, "error", err) return err } + logger.Debug("Starting interval goroutine", "id", t.id) go func() { + logger.Debug("Interval goroutine started", "id", t.id) for { select { case <-t.timer: + logger.Debug("Interval fired, enqueueing task", "id", t.id) enqueue(task) + logger.Debug("Interval task enqueued, getting new enqueue", "id", t.id) enqueue = vm.EnqueueJob(runtime) case <-t.done: + logger.Debug("Interval cancelled, enqueueing nothing", "id", t.id) + vm.RemovePending(runtime) // Remove pending operation when interval is cancelled enqueue(nothing) + logger.Debug("Interval goroutine finished", "id", t.id) return } } @@ -110,13 +147,15 @@ func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) erro return runtime.ToValue(t.id) }) - // clearInterval + // clearInterval - copied exactly from ski runtime.Set("clearInterval", func(call sobek.FunctionCall) sobek.Value { id := call.Argument(0).ToInteger() + logger.Debug("clearInterval called", "id", id) rtTimers(runtime).stop(id) return sobek.Undefined() }) + logger.Debug("Timers module setup complete") return nil } @@ -132,7 +171,7 @@ func (t *TimersModule) IsEnabled(enabledModules map[string]bool) bool { return exists && enabled } -// timer represents a single timer instance (copied from ski) +// timer represents a single timer instance (copied exactly from ski) type timer struct { id int64 timer <-chan time.Time @@ -141,18 +180,21 @@ type timer struct { } func (t *timer) stop() { + logger.Debug("Stopping timer", "id", t.id) select { - case _, ok := <-t.done: - if !ok { - return - } + case <-t.done: + // Channel already closed + logger.Debug("Timer already stopped", "id", t.id) + return default: + // Channel not closed, close it + close(t.done) + t.cleanup() + logger.Debug("Timer stopped", "id", t.id) } - close(t.done) - t.cleanup() } -// timers manages all timers for a runtime (copied from ski) +// timers manages all timers for a runtime (copied exactly from ski) type timers struct { id int64 timer map[int64]*timer @@ -161,6 +203,8 @@ type timers struct { func (t *timers) new(delay time.Duration, repeat bool) *timer { t.id++ id := t.id + logger.Debug("Creating new timer", "id", id, "delay", delay, "repeat", repeat) + n := &timer{ id: id, done: make(chan struct{}), @@ -169,6 +213,7 @@ func (t *timers) new(delay time.Duration, repeat bool) *timer { t1 := time.NewTicker(delay) n.timer = t1.C n.cleanup = func() { + logger.Debug("Cleaning up ticker", "id", id) delete(t.timer, id) t1.Stop() } @@ -176,17 +221,22 @@ func (t *timers) new(delay time.Duration, repeat bool) *timer { t1 := time.NewTimer(delay) n.timer = t1.C n.cleanup = func() { + logger.Debug("Cleaning up timer", "id", id) delete(t.timer, id) t1.Stop() } } t.timer[id] = n + logger.Debug("Timer created and stored", "id", id) return n } func (t *timers) stop(id int64) { + logger.Debug("Stopping timer by ID", "id", id) if v, ok := t.timer[id]; ok { v.stop() + } else { + logger.Debug("Timer not found", "id", id) } } @@ -196,11 +246,16 @@ func rtTimers(rt *sobek.Runtime) *timers { global := rt.GlobalObject() v := global.GetSymbol(symTimers) if v == nil { + logger.Debug("Creating new timers instance for runtime") t := &timers{timer: make(map[int64]*timer)} _ = global.SetSymbol(symTimers, t) return t } + logger.Debug("Using existing timers instance") return v.Export().(*timers) } -func nothing() error { return nil } +func nothing() error { + logger.Debug("Nothing function called") + return nil +} diff --git a/jsserver/server.go b/jsserver/server.go index 652a9a0..538dbb9 100644 --- a/jsserver/server.go +++ b/jsserver/server.go @@ -1,10 +1,8 @@ package jsserver import ( - "bytes" "context" "fmt" - "log/slog" "strings" "time" @@ -15,6 +13,7 @@ import ( // Import our new VM system "github.com/mark3labs/codebench-mcp/internal/logger" "github.com/mark3labs/codebench-mcp/jsserver/modules/buffer" + "github.com/mark3labs/codebench-mcp/jsserver/modules/cache" "github.com/mark3labs/codebench-mcp/jsserver/modules/console" "github.com/mark3labs/codebench-mcp/jsserver/modules/crypto" "github.com/mark3labs/codebench-mcp/jsserver/modules/encoding" @@ -28,29 +27,6 @@ import ( var Version = "dev" -// captureLogger captures log output to a buffer -type captureLogger struct { - buffer *bytes.Buffer -} - -func (c *captureLogger) Enabled(context.Context, slog.Level) bool { - return true -} - -func (c *captureLogger) Handle(ctx context.Context, record slog.Record) error { - c.buffer.WriteString(record.Message) - c.buffer.WriteString("\n") - return nil -} - -func (c *captureLogger) WithAttrs(attrs []slog.Attr) slog.Handler { - return c -} - -func (c *captureLogger) WithGroup(name string) slog.Handler { - return c -} - type ModuleConfig struct { EnabledModules []string DisabledModules []string @@ -63,7 +39,7 @@ type JSHandler struct { func NewJSHandler() *JSHandler { return NewJSHandlerWithConfig(ModuleConfig{ - EnabledModules: []string{"http", "fetch", "timers", "buffer", "kv", "crypto"}, + EnabledModules: []string{"http", "fetch", "timers", "buffer", "kv", "crypto", "encoding", "url", "cache"}, }) } @@ -77,8 +53,7 @@ func NewJSHandlerWithConfig(config ModuleConfig) *JSHandler { vmManager := vm.NewVMManager(enabledModules) - // Register all available modules - vmManager.RegisterModule(console.NewConsoleModule(nil)) // Console always enabled + // Register all available modules (except console which is handled per-execution) vmManager.RegisterModule(kv.NewKVModule()) vmManager.RegisterModule(timers.NewTimersModule()) vmManager.RegisterModule(fetch.NewFetchModule()) @@ -87,6 +62,7 @@ func NewJSHandlerWithConfig(config ModuleConfig) *JSHandler { vmManager.RegisterModule(crypto.NewCryptoModule()) vmManager.RegisterModule(encoding.NewEncodingModule()) vmManager.RegisterModule(url.NewURLModule()) + vmManager.RegisterModule(cache.NewCacheModule()) return &JSHandler{ vmManager: vmManager, @@ -106,7 +82,7 @@ func (h *JSHandler) handleExecuteJS( logger.Debug("Executing JavaScript code", "length", len(code)) // Check if this looks like HTTP server code - isServerCode := strings.Contains(code, "serve(") || strings.Contains(code, "require('ski/http/server')") + isServerCode := strings.Contains(code, "serve(") || strings.Contains(code, "require('http/server')") if isServerCode { logger.Debug("Detected server code, running in background") @@ -121,9 +97,7 @@ func (h *JSHandler) handleExecuteJS( func (h *JSHandler) handleServerCode(ctx context.Context, code string) (*mcp.CallToolResult, error) { // Capture console output - var output bytes.Buffer - captureHandler := &captureLogger{buffer: &output} - logger := slog.New(captureHandler) + var output strings.Builder // Channel to signal if a server was actually started serverStarted := make(chan bool, 1) @@ -133,18 +107,18 @@ func (h *JSHandler) handleServerCode(ctx context.Context, code string) (*mcp.Cal // Create VM with custom logger for console output vm, err := h.vmManager.CreateVM(ctx) if err != nil { - logger.Error("Failed to create VM", "error", err) + logger.Debug("Failed to create VM", "error", err) serverStarted <- false return } defer vm.Close() - // Override console module with our capture logger - consoleModule := console.NewConsoleModule(logger) - consoleModule.Setup(vm.Runtime(), h.vmManager) + // Setup console module to capture output + consoleModule := console.NewConsoleModule(&output) + consoleModule.Setup(vm.Runtime()) // Execute the JavaScript code - _, err = vm.RunStringWithEventLoop(code) + _, err = vm.RunString(code) if err != nil { logger.Error("Server execution error", "error", err) serverStarted <- false @@ -186,13 +160,12 @@ func (h *JSHandler) handleServerCode(ctx context.Context, code string) (*mcp.Cal func (h *JSHandler) handleRegularCode(ctx context.Context, code string) (*mcp.CallToolResult, error) { // Capture console output - var output bytes.Buffer - captureHandler := &captureLogger{buffer: &output} - logger := slog.New(captureHandler) + var output strings.Builder // Create VM instance for this execution vm, err := h.vmManager.CreateVM(ctx) if err != nil { + logger.Debug("Failed to create VM", "error", err) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ @@ -205,9 +178,9 @@ func (h *JSHandler) handleRegularCode(ctx context.Context, code string) (*mcp.Ca } defer vm.Close() - // Override console module with our capture logger - consoleModule := console.NewConsoleModule(logger) - consoleModule.Setup(vm.Runtime(), h.vmManager) + // Setup console module to capture output + consoleModule := console.NewConsoleModule(&output) + consoleModule.Setup(vm.Runtime()) // Execute the JavaScript code with a timeout for regular code execCtx, cancel := context.WithTimeout(ctx, time.Second*10) @@ -218,7 +191,7 @@ func (h *JSHandler) handleRegularCode(ctx context.Context, code string) (*mcp.Ca errorChan := make(chan error, 1) go func() { - result, err := vm.RunStringWithEventLoop(code) + result, err := vm.RunString(code) if err != nil { errorChan <- err } else { @@ -261,7 +234,7 @@ func (h *JSHandler) handleRegularCode(ctx context.Context, code string) (*mcp.Ca Content: []mcp.Content{ mcp.TextContent{ Type: "text", - Text: fmt.Sprintf("%s%s", resultStr, output.String()), + Text: fmt.Sprintf("%s%s", output.String(), resultStr), }, }, }, nil @@ -294,7 +267,7 @@ func NewJSServerWithConfig(config ModuleConfig) (*server.MCPServer, error) { "executeJS", mcp.WithDescription(description), mcp.WithString("code", - mcp.Description("Complete JavaScript source code to execute in the ski runtime environment. This parameter accepts a full JavaScript program including variable declarations, function definitions, control flow statements, and module imports via require(). The code will be executed in a sandboxed environment with access to enabled ski modules. Supports modern JavaScript syntax (ES2020+) including arrow functions, destructuring, template literals, and promises. Use require() for module imports (e.g., 'const serve = require(\"ski/http/server\")') rather than ES6 import statements. Note: Top-level async/await is not supported - wrap async code in an async function and call it (e.g., '(async () => { await fetch(...); })()' or define and call an async function). The execution context includes a console object for output, and any returned values will be displayed along with console output. For HTTP servers, they will run in the background without blocking execution completion."), + mcp.Description("Complete JavaScript source code to execute in the ski runtime environment. This parameter accepts a full JavaScript program including variable declarations, function definitions, control flow statements, and module imports via require(). The code will be executed in a sandboxed environment with access to enabled ski modules. Supports modern JavaScript syntax (ES2020+) including arrow functions, destructuring, template literals, and promises. Use require() for module imports (e.g., 'const serve = require(\"http/server\")') rather than ES6 import statements. Note: Top-level async/await is not supported - wrap async code in an async function and call it (e.g., '(async () => { await fetch(...); })()' or define and call an async function). The execution context includes a console object for output, and any returned values will be displayed along with console output. For HTTP servers, they will run in the background without blocking execution completion."), mcp.Required(), ), ), h.handleExecuteJS) @@ -318,11 +291,11 @@ func buildToolDescription(enabledModules []string) string { // Define module descriptions moduleDescriptions := map[string]string{ - "http": "HTTP server creation and management (const serve = require('ski/http/server'))", + "http": "HTTP server creation and management (const serve = require('http/server'))", "fetch": "Modern fetch API with Request, Response, Headers, FormData (available globally)", "timers": "setTimeout, setInterval, clearTimeout, clearInterval (available globally)", "buffer": "Buffer, Blob, File APIs for binary data handling (available globally)", - "crypto": "Cryptographic functions (hashing, encryption, HMAC) (const crypto = require('ski/crypto'))", + "crypto": "Cryptographic functions (hashing, encryption, HMAC) (const crypto = require('crypto'))", "kv": "Key-value store per VM instance with get, set, delete, list (available globally)", "console": "Console logging with structured output (available globally)", "encoding": "TextEncoder/TextDecoder for UTF-8 encoding/decoding (available globally)", @@ -359,7 +332,7 @@ func buildToolDescription(enabledModules []string) string { if enabledSet["http"] { description.WriteString("// HTTP server (require import - NOT import statement)\n") - description.WriteString("const serve = require('ski/http/server');\n") + description.WriteString("const serve = require('http/server');\n") description.WriteString("const server = serve(8000, async (req) => {\n") description.WriteString(" return new Response(`Hello ${req.method} ${req.url}!`);\n") description.WriteString("});\n") @@ -368,7 +341,7 @@ func buildToolDescription(enabledModules []string) string { if enabledSet["crypto"] { description.WriteString("// Crypto operations (require import)\n") - description.WriteString("const crypto = require('ski/crypto');\n") + description.WriteString("const crypto = require('crypto');\n") description.WriteString("const hash = crypto.md5('hello').hex();\n") description.WriteString("console.log('MD5 hash:', hash);\n\n") } diff --git a/jsserver/vm/eventloop.go b/jsserver/vm/eventloop.go index 0a8c143..1cc6978 100644 --- a/jsserver/vm/eventloop.go +++ b/jsserver/vm/eventloop.go @@ -4,6 +4,7 @@ import ( "sync" "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" ) // EventLoop implements an event loop for asynchronous JavaScript operations @@ -11,6 +12,7 @@ type EventLoop struct { queue []func() error // queue to store the job to be executed cleanup []func() // job of cleanup enqueue uint // Count of job in the event loop + pending uint // Count of pending async operations (timers, etc.) cond *sync.Cond // Condition variable for synchronization } @@ -48,7 +50,7 @@ func (e *EventLoop) Start(task func() error) (err error) { continue } - if e.enqueue > 0 { + if e.enqueue > 0 || e.pending > 0 { e.cond.Wait() e.cond.L.Unlock() continue @@ -131,6 +133,25 @@ func (je joinError) Error() string { return result } +// AddPending increments the pending operation counter +func (e *EventLoop) AddPending() { + e.cond.L.Lock() + defer e.cond.L.Unlock() + e.pending++ + logger.Debug("Added pending operation", "pending", e.pending) +} + +// RemovePending decrements the pending operation counter +func (e *EventLoop) RemovePending() { + e.cond.L.Lock() + defer e.cond.L.Unlock() + if e.pending > 0 { + e.pending-- + } + logger.Debug("Removed pending operation", "pending", e.pending) + e.cond.Signal() +} + // Helper functions for runtime integration var symbolVM = sobek.NewSymbol("Symbol.__vm__") @@ -150,6 +171,16 @@ func Cleanup(rt *sobek.Runtime, job ...func()) { getVMFromRuntime(rt).eventLoop.Cleanup(job...) } +// AddPending adds a pending operation for the given runtime +func AddPending(rt *sobek.Runtime) { + getVMFromRuntime(rt).eventLoop.AddPending() +} + +// RemovePending removes a pending operation for the given runtime +func RemovePending(rt *sobek.Runtime) { + getVMFromRuntime(rt).eventLoop.RemovePending() +} + // getVMFromRuntime extracts the VM instance from the runtime func getVMFromRuntime(rt *sobek.Runtime) *VM { value := rt.GlobalObject().GetSymbol(symbolVM) diff --git a/jsserver/vm/loader.go b/jsserver/vm/loader.go new file mode 100644 index 0000000..bd3dd26 --- /dev/null +++ b/jsserver/vm/loader.go @@ -0,0 +1,105 @@ +package vm + +import ( + "fmt" + "sync" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" +) + +// ModuleLoader provides a global require system for modules +// Based on ski's loader pattern but simplified for our use case +type ModuleLoader struct { + modules sync.Map // map[string]Module + aliases sync.Map // map[string]string - maps alias to module name +} + +// NewModuleLoader creates a new module loader +func NewModuleLoader() *ModuleLoader { + return &ModuleLoader{} +} + +// RegisterModule registers a module with the loader +func (l *ModuleLoader) RegisterModule(module Module) { + l.modules.Store(module.Name(), module) + logger.Debug("Module registered with loader", "name", module.Name()) + + // Register common aliases + switch module.Name() { + case "http": + l.aliases.Store("http/server", "http") + logger.Debug("Module alias registered", "alias", "http/server", "module", "http") + case "crypto": + l.aliases.Store("crypto", "crypto") + logger.Debug("Module alias registered", "alias", "crypto", "module", "crypto") + case "cache": + l.aliases.Store("cache", "cache") + logger.Debug("Module alias registered", "alias", "cache", "module", "cache") + } +} + +// EnableRequire sets up the global require function in the runtime +func (l *ModuleLoader) EnableRequire(rt *sobek.Runtime) { + rt.Set("require", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(rt.NewTypeError("require() expects a module name")) + } + + moduleName := call.Argument(0).String() + logger.Debug("Require called", "module", moduleName) + + // Check for aliases first + if aliasTarget, ok := l.aliases.Load(moduleName); ok { + moduleName = aliasTarget.(string) + logger.Debug("Module alias resolved", "alias", call.Argument(0).String(), "target", moduleName) + } + + // Look up the module + if moduleInterface, ok := l.modules.Load(moduleName); ok { + module := moduleInterface.(Module) + logger.Debug("Module found", "name", moduleName) + + // Create the module object + if moduleCreator, ok := module.(ModuleCreator); ok { + return moduleCreator.CreateModuleObject(rt) + } + + // Fallback: return undefined for modules that don't implement ModuleCreator + logger.Debug("Module doesn't implement ModuleCreator", "name", moduleName) + return sobek.Undefined() + } + + // Module not found + logger.Debug("Module not found", "name", moduleName) + panic(rt.NewTypeError(fmt.Sprintf("Cannot find module '%s'", moduleName))) + }) + logger.Debug("Global require function enabled") +} + +// ModuleCreator interface for modules that can create their own objects +// This replaces the old require override pattern +type ModuleCreator interface { + CreateModuleObject(runtime *sobek.Runtime) sobek.Value +} + +// GlobalModule interface for modules that provide global objects +// These modules will be automatically available as globals (like fetch, console) +type GlobalModule interface { + GetGlobalName() string + CreateGlobalObject(runtime *sobek.Runtime) sobek.Value +} + +// SetupGlobals sets up global objects for modules that implement GlobalModule +func (l *ModuleLoader) SetupGlobals(rt *sobek.Runtime) { + l.modules.Range(func(key, value interface{}) bool { + module := value.(Module) + if globalModule, ok := module.(GlobalModule); ok { + globalName := globalModule.GetGlobalName() + globalObject := globalModule.CreateGlobalObject(rt) + rt.Set(globalName, globalObject) + logger.Debug("Global object set", "name", globalName) + } + return true + }) +} \ No newline at end of file diff --git a/jsserver/vm/manager.go b/jsserver/vm/manager.go index 3c25bed..22b8536 100644 --- a/jsserver/vm/manager.go +++ b/jsserver/vm/manager.go @@ -4,12 +4,14 @@ import ( "context" "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" ) // VMManager manages Sobek VM instances type VMManager struct { enabledModules map[string]bool registry *ModuleRegistry + loader *ModuleLoader } // NewVMManager creates a new VM manager with specified enabled modules @@ -22,18 +24,22 @@ func NewVMManager(enabledModules []string) *VMManager { return &VMManager{ enabledModules: enabledMap, registry: NewModuleRegistry(), + loader: NewModuleLoader(), } } // RegisterModule adds a module to the manager func (m *VMManager) RegisterModule(module Module) error { m.registry.Register(module) + m.loader.RegisterModule(module) return nil } // CreateVM creates a new VM instance with all enabled modules // Each VM is completely isolated func (m *VMManager) CreateVM(ctx context.Context) (*VM, error) { + logger.Debug("Creating new VM instance") + // Create new Sobek runtime rt := sobek.New() @@ -49,15 +55,29 @@ func (m *VMManager) CreateVM(ctx context.Context) (*VM, error) { // Store VM reference in runtime for event loop access _ = rt.GlobalObject().SetSymbol(symbolVM, &vmSelf{vm: vm}) + logger.Debug("VM symbol stored in runtime") + + // Setup global require function + m.loader.EnableRequire(rt) + logger.Debug("Global require function enabled") // Setup all enabled modules enabledModules := m.registry.GetEnabled(m.enabledModules) + logger.Debug("Setting up enabled modules", "count", len(enabledModules)) for _, module := range enabledModules { + logger.Debug("Setting up module", "name", module.Name()) if err := module.Setup(rt, m); err != nil { + logger.Debug("Module setup failed", "name", module.Name(), "error", err) return nil, err } + logger.Debug("Module setup completed", "name", module.Name()) } + // Setup global objects for modules that provide them + m.loader.SetupGlobals(rt) + logger.Debug("Global objects setup completed") + + logger.Debug("VM creation completed") return vm, nil } @@ -67,6 +87,7 @@ func (m *VMManager) GetEnabledModules() []string { for module := range m.enabledModules { enabled = append(enabled, module) } + logger.Debug("Enabled modules", "modules", enabled) return enabled } @@ -78,14 +99,9 @@ type VM struct { eventLoop *EventLoop } -// RunString executes JavaScript code in the VM -func (vm *VM) RunString(code string) (sobek.Value, error) { - return vm.runtime.RunString(code) -} - -// RunStringWithEventLoop executes JavaScript code with event loop support -// This matches ski's pattern where RunString calls Run() which uses the event loop -func (vm *VM) RunStringWithEventLoop(code string) (ret sobek.Value, err error) { +// RunString executes JavaScript code in the VM with event loop support +// This matches ski's pattern where RunString always uses the event loop +func (vm *VM) RunString(code string) (ret sobek.Value, err error) { err = vm.runWithEventLoop(func() error { ret, err = vm.runtime.RunString(code) return err diff --git a/jsserver/vm/module.go b/jsserver/vm/module.go index 34fc108..ebe7ca4 100644 --- a/jsserver/vm/module.go +++ b/jsserver/vm/module.go @@ -1,6 +1,9 @@ package vm -import "github.com/grafana/sobek" +import ( + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" +) // Module interface defines how modules integrate with the VM type Module interface { @@ -35,12 +38,15 @@ func (r *ModuleRegistry) Get(name string) (Module, bool) { // GetEnabled returns all enabled modules based on configuration func (r *ModuleRegistry) GetEnabled(enabledModules map[string]bool) []Module { + logger.Debug("Getting enabled modules", "enabledMap", enabledModules) var enabled []Module for _, module := range r.modules { + logger.Debug("Checking module", "name", module.Name(), "enabled", module.IsEnabled(enabledModules)) if module.IsEnabled(enabledModules) { enabled = append(enabled, module) } } + logger.Debug("Enabled modules found", "count", len(enabled)) return enabled } From 76898101fcf55d766bceb7b7afc0ccf2821f64f5 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 13 Jun 2025 17:26:09 +0300 Subject: [PATCH 3/8] cleanup readme --- README.md | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8a51d3a..9f57a91 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ The `executeJS` tool provides: - **Fetch API**: Modern `fetch()` with Request, Response, Headers, FormData (global) - **Timers**: `setTimeout()`, `setInterval()`, `clearTimeout()`, `clearInterval()` (global) - **Buffer**: Buffer, Blob, File APIs for binary data handling (global) -- **Crypto**: Cryptographic functions - hashing, encryption, HMAC (via `require('ski/crypto')`) -- **Cache**: In-memory caching with TTL support (via `require('ski/cache')`) -- **Additional modules**: dom, encoding (global), ext, html, signal (global), stream (global), url (global) +- **Crypto**: Cryptographic functions - hashing, encryption, HMAC (via `require('crypto')`) +- **Cache**: In-memory caching with TTL support (via `require('cache')`) +- **Additional modules**: encoding (global), url (global) ## Getting Started @@ -51,17 +51,12 @@ codebench-mcp --help - `fetch` - Modern fetch API with Request, Response, Headers, FormData (available globally) - `timers` - setTimeout, setInterval, clearTimeout, clearInterval (available globally) - `buffer` - Buffer, Blob, File APIs for binary data handling (available globally) -- `cache` - In-memory caching with TTL support (import cache from 'ski/cache') -- `crypto` - Cryptographic functions (hashing, encryption, HMAC) (import crypto from 'ski/crypto') -- `dom` - DOM Event and EventTarget APIs +- `cache` - In-memory caching with TTL support (require('cache')) +- `crypto` - Cryptographic functions (hashing, encryption, HMAC) (require('crypto')) - `encoding` - TextEncoder, TextDecoder for text encoding/decoding (available globally) -- `ext` - Extended context and utility functions -- `html` - HTML parsing and manipulation -- `signal` - AbortController and AbortSignal for cancellation (available globally) -- `stream` - ReadableStream and streaming APIs (available globally) - `url` - URL and URLSearchParams APIs (available globally) -**Default modules:** `http`, `fetch`, `timers`, `buffer`, `crypto` +**Default modules:** `http`, `fetch`, `timers`, `buffer`, `kv` **Note:** The `executeJS` tool description dynamically updates to show only the enabled modules and includes detailed information about what each module provides. @@ -236,12 +231,12 @@ serve(8000, async (req) => { }); // Cache operations (require import) -const cache = require('ski/cache'); +const cache = require('cache'); cache.set('key', 'value'); console.log(cache.get('key')); // Crypto operations (require import) -const crypto = require('ski/crypto'); +const crypto = require('crypto'); const hash = crypto.md5('hello').hex(); console.log('MD5 hash:', hash); From 1212616a85e62d114e1412cd75beb7b0c4aa9f94 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 13 Jun 2025 17:31:40 +0300 Subject: [PATCH 4/8] more cleanup --- README.md | 2 +- jsserver/server.go | 4 ++-- jsserver/vm/loader.go | 25 ++++++++++++++++++------- jsserver/vm/manager.go | 4 ++-- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9f57a91..2d00938 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ codebench-mcp --help - `encoding` - TextEncoder, TextDecoder for text encoding/decoding (available globally) - `url` - URL and URLSearchParams APIs (available globally) -**Default modules:** `http`, `fetch`, `timers`, `buffer`, `kv` +All modules are enabled by default. You can selectively enable or disable modules using CLI flags. **Note:** The `executeJS` tool description dynamically updates to show only the enabled modules and includes detailed information about what each module provides. diff --git a/jsserver/server.go b/jsserver/server.go index 538dbb9..1d80216 100644 --- a/jsserver/server.go +++ b/jsserver/server.go @@ -47,8 +47,8 @@ func NewJSHandlerWithConfig(config ModuleConfig) *JSHandler { // Create VM manager with enabled modules enabledModules := config.EnabledModules if len(enabledModules) == 0 && len(config.DisabledModules) == 0 { - // Default modules if none specified - enabledModules = []string{"fetch", "timers", "buffer", "kv"} + // Enable all modules by default if none specified + enabledModules = []string{"http", "fetch", "timers", "buffer", "kv", "crypto", "encoding", "url", "cache"} } vmManager := vm.NewVMManager(enabledModules) diff --git a/jsserver/vm/loader.go b/jsserver/vm/loader.go index bd3dd26..8ae3ee1 100644 --- a/jsserver/vm/loader.go +++ b/jsserver/vm/loader.go @@ -40,7 +40,7 @@ func (l *ModuleLoader) RegisterModule(module Module) { } // EnableRequire sets up the global require function in the runtime -func (l *ModuleLoader) EnableRequire(rt *sobek.Runtime) { +func (l *ModuleLoader) EnableRequire(rt *sobek.Runtime, enabledModules map[string]bool) { rt.Set("require", func(call sobek.FunctionCall) sobek.Value { if len(call.Arguments) == 0 { panic(rt.NewTypeError("require() expects a module name")) @@ -60,6 +60,12 @@ func (l *ModuleLoader) EnableRequire(rt *sobek.Runtime) { module := moduleInterface.(Module) logger.Debug("Module found", "name", moduleName) + // Check if module is enabled + if !module.IsEnabled(enabledModules) { + logger.Debug("Module not enabled", "name", moduleName) + panic(rt.NewTypeError(fmt.Sprintf("Module '%s' is not enabled", moduleName))) + } + // Create the module object if moduleCreator, ok := module.(ModuleCreator); ok { return moduleCreator.CreateModuleObject(rt) @@ -91,14 +97,19 @@ type GlobalModule interface { } // SetupGlobals sets up global objects for modules that implement GlobalModule -func (l *ModuleLoader) SetupGlobals(rt *sobek.Runtime) { - l.modules.Range(func(key, value interface{}) bool { +func (l *ModuleLoader) SetupGlobals(rt *sobek.Runtime, enabledModules map[string]bool) { + l.modules.Range(func(key, value any) bool { module := value.(Module) if globalModule, ok := module.(GlobalModule); ok { - globalName := globalModule.GetGlobalName() - globalObject := globalModule.CreateGlobalObject(rt) - rt.Set(globalName, globalObject) - logger.Debug("Global object set", "name", globalName) + // Check if module is enabled + if module.IsEnabled(enabledModules) { + globalName := globalModule.GetGlobalName() + globalObject := globalModule.CreateGlobalObject(rt) + rt.Set(globalName, globalObject) + logger.Debug("Global object set", "name", globalName) + } else { + logger.Debug("Global module not enabled", "name", module.Name()) + } } return true }) diff --git a/jsserver/vm/manager.go b/jsserver/vm/manager.go index 22b8536..fa9344e 100644 --- a/jsserver/vm/manager.go +++ b/jsserver/vm/manager.go @@ -58,7 +58,7 @@ func (m *VMManager) CreateVM(ctx context.Context) (*VM, error) { logger.Debug("VM symbol stored in runtime") // Setup global require function - m.loader.EnableRequire(rt) + m.loader.EnableRequire(rt, m.enabledModules) logger.Debug("Global require function enabled") // Setup all enabled modules @@ -74,7 +74,7 @@ func (m *VMManager) CreateVM(ctx context.Context) (*VM, error) { } // Setup global objects for modules that provide them - m.loader.SetupGlobals(rt) + m.loader.SetupGlobals(rt, m.enabledModules) logger.Debug("Global objects setup completed") logger.Debug("VM creation completed") From 36192c8f7ad24caaf6e2bf07bfd1d9be7a542c4d Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 13 Jun 2025 17:35:58 +0300 Subject: [PATCH 5/8] update descriptions --- jsserver/description_test.go | 122 +++++++++++++++++++++++++++-------- jsserver/server.go | 1 + 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/jsserver/description_test.go b/jsserver/description_test.go index b55c622..1653ee2 100644 --- a/jsserver/description_test.go +++ b/jsserver/description_test.go @@ -1,6 +1,8 @@ package jsserver import ( + "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -90,32 +92,100 @@ func TestBuildToolDescription(t *testing.T) { } } -func TestToolDescriptionDynamicUpdate(t *testing.T) { - // Test that different configurations produce different descriptions - config1 := ModuleConfig{EnabledModules: []string{"http", "fetch"}} - config2 := ModuleConfig{EnabledModules: []string{"timers"}} - - server1, err := NewJSServerWithConfig(config1) - assert.NoError(t, err) - assert.NotNil(t, server1) - - server2, err := NewJSServerWithConfig(config2) - assert.NoError(t, err) - assert.NotNil(t, server2) - - // The descriptions should be different - desc1 := buildToolDescription(config1.EnabledModules) - desc2 := buildToolDescription(config2.EnabledModules) - - assert.NotEqual(t, desc1, desc2, "Different module configurations should produce different descriptions") +func TestAllModulesRepresentedInDescription(t *testing.T) { + // Get all available modules from the actual modules directory + allModules := []string{"http", "fetch", "timers", "buffer", "crypto", "cache", "kv", "encoding", "url"} + + // Test with all modules enabled + description := buildToolDescription(allModules) + + // Verify each module is mentioned in the description + expectedModuleDescriptions := map[string]string{ + "http": "HTTP server creation and management", + "fetch": "Modern fetch API with Request, Response, Headers, FormData", + "timers": "setTimeout, setInterval, clearTimeout, clearInterval", + "buffer": "Buffer, Blob, File APIs for binary data handling", + "crypto": "Cryptographic functions (hashing, encryption, HMAC)", + "cache": "In-memory caching with TTL support", + "kv": "Key-value store per VM instance with get, set, delete, list", + "encoding": "TextEncoder/TextDecoder for UTF-8 encoding/decoding", + "url": "URL parsing and URLSearchParams manipulation", + } + + for module, expectedDesc := range expectedModuleDescriptions { + t.Run(fmt.Sprintf("Module_%s", module), func(t *testing.T) { + // Check that the module is listed + assert.Contains(t, description, fmt.Sprintf("• %s:", module), + "Module %s should be listed in description", module) + + // Check that the description contains key parts of the expected description + assert.Contains(t, description, expectedDesc, + "Module %s should have correct description containing: %s", module, expectedDesc) + }) + } + + // Verify the description contains the modules section + assert.Contains(t, description, "Available modules:") + + // Verify no modules are missing by checking the count + moduleCount := 0 + for _, module := range allModules { + if strings.Contains(description, fmt.Sprintf("• %s:", module)) { + moduleCount++ + } + } + assert.Equal(t, len(allModules), moduleCount, + "All %d modules should be represented in description, found %d", len(allModules), moduleCount) +} - // Config1 should mention http and fetch - assert.Contains(t, desc1, "• http:") - assert.Contains(t, desc1, "• fetch:") - assert.NotContains(t, desc1, "• timers:") +func TestModuleDescriptionConsistency(t *testing.T) { + // Test that require() vs global availability is correctly indicated + description := buildToolDescription([]string{"http", "fetch", "crypto", "cache", "timers", "buffer"}) + + // Modules that require require() should mention it + requireModules := []string{"http", "crypto", "cache"} + for _, module := range requireModules { + assert.Contains(t, description, fmt.Sprintf("require('%s", module), + "Module %s should show require() usage", module) + } + + // Global modules should mention "available globally" + globalModules := []string{"fetch", "timers", "buffer"} + for _, module := range globalModules { + moduleLineRegex := fmt.Sprintf("• %s:.*available globally", module) + assert.Regexp(t, moduleLineRegex, description, + "Module %s should mention 'available globally'", module) + } +} - // Config2 should mention timers - assert.Contains(t, desc2, "• timers:") - assert.NotContains(t, desc2, "• http:") - assert.NotContains(t, desc2, "• fetch:") +func TestNoMissingModulesInDescription(t *testing.T) { + // This test ensures that if we add a new module to the codebase, + // we don't forget to add it to the description builder + + // Get the module descriptions map from the buildToolDescription function + // We'll test this by checking that all modules we know exist have descriptions + allKnownModules := []string{"http", "fetch", "timers", "buffer", "crypto", "cache", "kv", "encoding", "url"} + + // Build description with all modules + description := buildToolDescription(allKnownModules) + + // Count how many modules are actually described + describedModules := 0 + for _, module := range allKnownModules { + if strings.Contains(description, fmt.Sprintf("• %s:", module)) { + describedModules++ + } + } + + // This test will fail if: + // 1. We add a new module but forget to add it to moduleDescriptions map + // 2. We add a module to allKnownModules but it's not in the description + assert.Equal(t, len(allKnownModules), describedModules, + "All known modules should be described. Known: %d, Described: %d. "+ + "If you added a new module, make sure to add it to the moduleDescriptions map in buildToolDescription()", + len(allKnownModules), describedModules) + + // Also verify that the description doesn't contain any undefined modules + assert.NotContains(t, description, "• undefined:", + "Description should not contain undefined modules") } diff --git a/jsserver/server.go b/jsserver/server.go index 1d80216..bc479bf 100644 --- a/jsserver/server.go +++ b/jsserver/server.go @@ -296,6 +296,7 @@ func buildToolDescription(enabledModules []string) string { "timers": "setTimeout, setInterval, clearTimeout, clearInterval (available globally)", "buffer": "Buffer, Blob, File APIs for binary data handling (available globally)", "crypto": "Cryptographic functions (hashing, encryption, HMAC) (const crypto = require('crypto'))", + "cache": "In-memory caching with TTL support (const cache = require('cache'))", "kv": "Key-value store per VM instance with get, set, delete, list (available globally)", "console": "Console logging with structured output (available globally)", "encoding": "TextEncoder/TextDecoder for UTF-8 encoding/decoding (available globally)", From afa7e787836f59db4bcb45a57a9a1218cc77fdbc Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 13 Jun 2025 17:47:22 +0300 Subject: [PATCH 6/8] cleanup --- README.md | 22 +++++++------- cmd/root.go | 12 ++++---- {jsserver => server}/description_test.go | 8 ++--- {jsserver => server}/inprocess_test.go | 10 +++---- {jsserver => server}/module_test.go | 4 +-- {jsserver => server}/modules/buffer/buffer.go | 2 +- {jsserver => server}/modules/cache/cache.go | 2 +- .../modules/console/console.go | 0 {jsserver => server}/modules/crypto/crypto.go | 2 +- .../modules/encoding/encoding.go | 2 +- {jsserver => server}/modules/fetch/fetch.go | 2 +- {jsserver => server}/modules/http/http.go | 2 +- {jsserver => server}/modules/kv/kv.go | 2 +- {jsserver => server}/modules/timers/timers.go | 14 ++++----- {jsserver => server}/modules/url/url.go | 2 +- {jsserver => server}/server.go | 30 +++++++++---------- {jsserver => server}/server_test.go | 2 +- {jsserver => server}/vm/eventloop.go | 0 {jsserver => server}/vm/loader.go | 2 +- {jsserver => server}/vm/manager.go | 4 +-- {jsserver => server}/vm/module.go | 0 21 files changed, 62 insertions(+), 62 deletions(-) rename {jsserver => server}/description_test.go (98%) rename {jsserver => server}/inprocess_test.go (93%) rename {jsserver => server}/module_test.go (97%) rename {jsserver => server}/modules/buffer/buffer.go (98%) rename {jsserver => server}/modules/cache/cache.go (99%) rename {jsserver => server}/modules/console/console.go (100%) rename {jsserver => server}/modules/crypto/crypto.go (99%) rename {jsserver => server}/modules/encoding/encoding.go (97%) rename {jsserver => server}/modules/fetch/fetch.go (99%) rename {jsserver => server}/modules/http/http.go (99%) rename {jsserver => server}/modules/kv/kv.go (98%) rename {jsserver => server}/modules/timers/timers.go (94%) rename {jsserver => server}/modules/url/url.go (99%) rename {jsserver => server}/server.go (87%) rename {jsserver => server}/server_test.go (99%) rename {jsserver => server}/vm/eventloop.go (100%) rename {jsserver => server}/vm/loader.go (98%) rename {jsserver => server}/vm/manager.go (97%) rename {jsserver => server}/vm/module.go (100%) diff --git a/README.md b/README.md index 2d00938..e6f787e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # JavaScript Executor MCP Server -This MCP server provides JavaScript execution capabilities with ski runtime. +This MCP server provides JavaScript execution capabilities with a modern runtime. ## Features The `executeJS` tool provides: - **Console API**: `console.log()`, `console.error()`, `console.warn()` (built-in) -- **HTTP Server**: `serve()` for server creation (via `require('ski/http/server')`) +- **HTTP Server**: `serve()` for server creation (via `require('http/server')`) - **Fetch API**: Modern `fetch()` with Request, Response, Headers, FormData (global) - **Timers**: `setTimeout()`, `setInterval()`, `clearTimeout()`, `clearInterval()` (global) - **Buffer**: Buffer, Blob, File APIs for binary data handling (global) @@ -47,7 +47,7 @@ codebench-mcp --help ``` **Available modules:** -- `http` - HTTP server creation and client requests (import serve from 'ski/http/server') +- `http` - HTTP server creation and client requests (require('http/server')) - `fetch` - Modern fetch API with Request, Response, Headers, FormData (available globally) - `timers` - setTimeout, setInterval, clearTimeout, clearInterval (available globally) - `buffer` - Buffer, Blob, File APIs for binary data handling (available globally) @@ -68,13 +68,13 @@ package main import ( "log" - "github.com/mark3labs/codebench-mcp/jsserver" + "github.com/mark3labs/codebench-mcp/server" "github.com/mark3labs/mcp-go/server" ) func main() { // Create a new JavaScript executor server - jss, err := jsserver.NewJSServer() + jss, err := server.NewJSServer() if err != nil { log.Fatalf("Failed to create server: %v", err) } @@ -95,17 +95,17 @@ import ( "context" "log" - "github.com/mark3labs/codebench-mcp/jsserver" + "github.com/mark3labs/codebench-mcp/server" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) func main() { // Create the JS server with custom module configuration - config := jsserver.ModuleConfig{ + config := server.ModuleConfig{ EnabledModules: []string{"fetch", "crypto", "buffer"}, } - jsServer, err := jsserver.NewJSServerWithConfig(config) + jsServer, err := server.NewJSServerWithConfig(config) if err != nil { log.Fatalf("Failed to create server: %v", err) } @@ -207,7 +207,7 @@ To integrate the Docker image with apps that support MCP: ### executeJS -Execute JavaScript code with ski runtime environment. +Execute JavaScript code with a modern runtime environment. **Parameters:** - `code` (required): JavaScript code to execute @@ -225,7 +225,7 @@ const response = await fetch('https://api.example.com/data'); const data = await response.json(); // HTTP server (require import) -const serve = require('ski/http/server'); +const serve = require('http/server'); serve(8000, async (req) => { return new Response('Hello World'); }); @@ -255,7 +255,7 @@ console.log('Pathname:', url.pathname); ## Limitations -- **No fs or process modules** - File system and process APIs are not available in ski runtime +- **No fs or process modules** - File system and process APIs are not available in the runtime - **Module access varies** - Some modules are global (fetch, http), others may need require() - **Each execution creates a fresh VM** - For isolation, each execution starts with a clean state - **Module filtering** - Configuration exists but actual runtime filtering not fully implemented diff --git a/cmd/root.go b/cmd/root.go index d4a1dd2..0b037dd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,8 +7,8 @@ import ( "strings" "github.com/mark3labs/codebench-mcp/internal/logger" - "github.com/mark3labs/codebench-mcp/jsserver" - "github.com/mark3labs/mcp-go/server" + "github.com/mark3labs/codebench-mcp/server" + mcpserver "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" ) @@ -42,7 +42,7 @@ var rootCmd = &cobra.Command{ Use: "codebench-mcp", Short: "JavaScript Executor MCP Server", Long: `A Model Context Protocol (MCP) server that provides JavaScript execution capabilities -with ski runtime including http, fetch, timers, buffer, crypto, and other modules.`, +with a modern runtime including http, fetch, timers, buffer, crypto, and other modules.`, Run: func(cmd *cobra.Command, args []string) { // Initialize logger first logger.Init(debugMode) @@ -84,11 +84,11 @@ with ski runtime including http, fetch, timers, buffer, crypto, and other module logger.Debug("Module configuration", "enabled", modulesToEnable) // Create server with module configuration - config := jsserver.ModuleConfig{ + config := server.ModuleConfig{ EnabledModules: modulesToEnable, } - jss, err := jsserver.NewJSServerWithConfig(config) + jss, err := server.NewJSServerWithConfig(config) if err != nil { logger.Fatal("Failed to create server", "error", err) } @@ -96,7 +96,7 @@ with ski runtime including http, fetch, timers, buffer, crypto, and other module logger.Info("Starting MCP server", "modules", modulesToEnable) // Serve requests - if err := server.ServeStdio(jss); err != nil { + if err := mcpserver.ServeStdio(jss); err != nil { logger.Fatal("Server error", "error", err) } }, diff --git a/jsserver/description_test.go b/server/description_test.go similarity index 98% rename from jsserver/description_test.go rename to server/description_test.go index 1653ee2..67a910b 100644 --- a/jsserver/description_test.go +++ b/server/description_test.go @@ -1,4 +1,4 @@ -package jsserver +package server import ( "fmt" @@ -19,7 +19,7 @@ func TestBuildToolDescription(t *testing.T) { name: "All modules enabled", enabledModules: []string{"http", "fetch", "timers", "buffer", "crypto"}, expectedContent: []string{ - "ski runtime", + "modern runtime", "Node.js-like APIs", "Available modules:", "• http: HTTP server creation and management", @@ -34,7 +34,7 @@ func TestBuildToolDescription(t *testing.T) { name: "Only http and fetch", enabledModules: []string{"http", "fetch"}, expectedContent: []string{ - "ski runtime", + "modern runtime", "Available modules:", "• http: HTTP server creation and management", "• fetch: Modern fetch API with Request, Response, Headers, FormData", @@ -50,7 +50,7 @@ func TestBuildToolDescription(t *testing.T) { name: "No modules enabled", enabledModules: []string{}, expectedContent: []string{ - "ski runtime", + "modern runtime", "No modules are currently enabled", "Only basic JavaScript execution is available", }, diff --git a/jsserver/inprocess_test.go b/server/inprocess_test.go similarity index 93% rename from jsserver/inprocess_test.go rename to server/inprocess_test.go index 75791f5..c65f479 100644 --- a/jsserver/inprocess_test.go +++ b/server/inprocess_test.go @@ -1,10 +1,10 @@ -package jsserver_test +package server_test import ( "context" "testing" - "github.com/mark3labs/codebench-mcp/jsserver" + "github.com/mark3labs/codebench-mcp/server" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" @@ -13,7 +13,7 @@ import ( func TestInProcessTransport(t *testing.T) { // Create the JS server - jsServer, err := jsserver.NewJSServer() + jsServer, err := server.NewJSServer() require.NoError(t, err) // Create an in-process client @@ -34,7 +34,7 @@ func TestInProcessTransport(t *testing.T) { } result, err := mcpClient.Initialize(context.Background(), initRequest) require.NoError(t, err) - assert.Equal(t, "javascript-executor", result.ServerInfo.Name) + assert.Equal(t, "codebench-mcp", result.ServerInfo.Name) // List tools to verify executeJS is available toolsResult, err := mcpClient.ListTools(context.Background(), mcp.ListToolsRequest{}) @@ -76,7 +76,7 @@ func TestInProcessTransport(t *testing.T) { func TestInProcessTransport_ErrorHandling(t *testing.T) { // Create the JS server - jsServer, err := jsserver.NewJSServer() + jsServer, err := server.NewJSServer() require.NoError(t, err) // Create an in-process client diff --git a/jsserver/module_test.go b/server/module_test.go similarity index 97% rename from jsserver/module_test.go rename to server/module_test.go index 146c7e5..d474b8f 100644 --- a/jsserver/module_test.go +++ b/server/module_test.go @@ -1,4 +1,4 @@ -package jsserver +package server import ( "context" @@ -75,7 +75,7 @@ func TestModuleConfiguration_DisabledModules(t *testing.T) { } func TestModuleConfiguration_NoConsole(t *testing.T) { - // Test with basic execution - console.log should work in ski runtime + // Test with basic execution - console.log should work in the runtime config := ModuleConfig{ EnabledModules: []string{}, // No modules enabled } diff --git a/jsserver/modules/buffer/buffer.go b/server/modules/buffer/buffer.go similarity index 98% rename from jsserver/modules/buffer/buffer.go rename to server/modules/buffer/buffer.go index 82febf8..1819bb2 100644 --- a/jsserver/modules/buffer/buffer.go +++ b/server/modules/buffer/buffer.go @@ -5,7 +5,7 @@ import ( "encoding/hex" "github.com/grafana/sobek" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // BufferModule provides Buffer global for binary data handling diff --git a/jsserver/modules/cache/cache.go b/server/modules/cache/cache.go similarity index 99% rename from jsserver/modules/cache/cache.go rename to server/modules/cache/cache.go index ef57078..7dd085b 100644 --- a/jsserver/modules/cache/cache.go +++ b/server/modules/cache/cache.go @@ -6,7 +6,7 @@ import ( "time" "github.com/grafana/sobek" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // CacheModule provides in-memory caching with TTL support diff --git a/jsserver/modules/console/console.go b/server/modules/console/console.go similarity index 100% rename from jsserver/modules/console/console.go rename to server/modules/console/console.go diff --git a/jsserver/modules/crypto/crypto.go b/server/modules/crypto/crypto.go similarity index 99% rename from jsserver/modules/crypto/crypto.go rename to server/modules/crypto/crypto.go index d56f576..c5b541b 100644 --- a/jsserver/modules/crypto/crypto.go +++ b/server/modules/crypto/crypto.go @@ -12,7 +12,7 @@ import ( "hash" "github.com/grafana/sobek" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // CryptoModule provides cryptographic functions diff --git a/jsserver/modules/encoding/encoding.go b/server/modules/encoding/encoding.go similarity index 97% rename from jsserver/modules/encoding/encoding.go rename to server/modules/encoding/encoding.go index 736dca4..9ec639a 100644 --- a/jsserver/modules/encoding/encoding.go +++ b/server/modules/encoding/encoding.go @@ -2,7 +2,7 @@ package encoding import ( "github.com/grafana/sobek" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // EncodingModule provides TextEncoder and TextDecoder diff --git a/jsserver/modules/fetch/fetch.go b/server/modules/fetch/fetch.go similarity index 99% rename from jsserver/modules/fetch/fetch.go rename to server/modules/fetch/fetch.go index b3739a3..a18fd1d 100644 --- a/jsserver/modules/fetch/fetch.go +++ b/server/modules/fetch/fetch.go @@ -8,7 +8,7 @@ import ( "time" "github.com/grafana/sobek" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // FetchModule provides fetch API functionality diff --git a/jsserver/modules/http/http.go b/server/modules/http/http.go similarity index 99% rename from jsserver/modules/http/http.go rename to server/modules/http/http.go index f511993..9059460 100644 --- a/jsserver/modules/http/http.go +++ b/server/modules/http/http.go @@ -9,7 +9,7 @@ import ( "github.com/grafana/sobek" "github.com/mark3labs/codebench-mcp/internal/logger" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // HTTPModule provides HTTP server functionality diff --git a/jsserver/modules/kv/kv.go b/server/modules/kv/kv.go similarity index 98% rename from jsserver/modules/kv/kv.go rename to server/modules/kv/kv.go index 3590319..4a4f3b9 100644 --- a/jsserver/modules/kv/kv.go +++ b/server/modules/kv/kv.go @@ -2,7 +2,7 @@ package kv import ( "github.com/grafana/sobek" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // KVModule provides key-value storage per VM instance diff --git a/jsserver/modules/timers/timers.go b/server/modules/timers/timers.go similarity index 94% rename from jsserver/modules/timers/timers.go rename to server/modules/timers/timers.go index f65ecb4..85140f7 100644 --- a/jsserver/modules/timers/timers.go +++ b/server/modules/timers/timers.go @@ -5,7 +5,7 @@ import ( "github.com/grafana/sobek" "github.com/mark3labs/codebench-mcp/internal/logger" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // TimersModule provides setTimeout, setInterval, clearTimeout, clearInterval @@ -25,7 +25,7 @@ func (t *TimersModule) Name() string { func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { logger.Debug("Setting up timers module") - // setTimeout - copied exactly from ski + // setTimeout - standard implementation runtime.Set("setTimeout", func(call sobek.FunctionCall) sobek.Value { logger.Debug("setTimeout called", "args", len(call.Arguments)) @@ -84,7 +84,7 @@ func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) erro return runtime.ToValue(t.id) }) - // clearTimeout - copied exactly from ski + // clearTimeout - standard implementation runtime.Set("clearTimeout", func(call sobek.FunctionCall) sobek.Value { id := call.Argument(0).ToInteger() logger.Debug("clearTimeout called", "id", id) @@ -92,7 +92,7 @@ func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) erro return sobek.Undefined() }) - // setInterval - copied exactly from ski + // setInterval - standard implementation runtime.Set("setInterval", func(call sobek.FunctionCall) sobek.Value { logger.Debug("setInterval called", "args", len(call.Arguments)) @@ -147,7 +147,7 @@ func (t *TimersModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) erro return runtime.ToValue(t.id) }) - // clearInterval - copied exactly from ski + // clearInterval - standard implementation runtime.Set("clearInterval", func(call sobek.FunctionCall) sobek.Value { id := call.Argument(0).ToInteger() logger.Debug("clearInterval called", "id", id) @@ -171,7 +171,7 @@ func (t *TimersModule) IsEnabled(enabledModules map[string]bool) bool { return exists && enabled } -// timer represents a single timer instance (copied exactly from ski) +// timer represents a single timer instance (standard implementation) type timer struct { id int64 timer <-chan time.Time @@ -194,7 +194,7 @@ func (t *timer) stop() { } } -// timers manages all timers for a runtime (copied exactly from ski) +// timers manages all timers for a runtime (standard implementation) type timers struct { id int64 timer map[int64]*timer diff --git a/jsserver/modules/url/url.go b/server/modules/url/url.go similarity index 99% rename from jsserver/modules/url/url.go rename to server/modules/url/url.go index f1a4ec7..3093f9d 100644 --- a/jsserver/modules/url/url.go +++ b/server/modules/url/url.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/grafana/sobek" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/vm" ) // URLModule provides URL and URLSearchParams diff --git a/jsserver/server.go b/server/server.go similarity index 87% rename from jsserver/server.go rename to server/server.go index bc479bf..6805372 100644 --- a/jsserver/server.go +++ b/server/server.go @@ -1,4 +1,4 @@ -package jsserver +package server import ( "context" @@ -12,17 +12,17 @@ import ( // Import our new VM system "github.com/mark3labs/codebench-mcp/internal/logger" - "github.com/mark3labs/codebench-mcp/jsserver/modules/buffer" - "github.com/mark3labs/codebench-mcp/jsserver/modules/cache" - "github.com/mark3labs/codebench-mcp/jsserver/modules/console" - "github.com/mark3labs/codebench-mcp/jsserver/modules/crypto" - "github.com/mark3labs/codebench-mcp/jsserver/modules/encoding" - "github.com/mark3labs/codebench-mcp/jsserver/modules/fetch" - "github.com/mark3labs/codebench-mcp/jsserver/modules/http" - "github.com/mark3labs/codebench-mcp/jsserver/modules/kv" - "github.com/mark3labs/codebench-mcp/jsserver/modules/timers" - "github.com/mark3labs/codebench-mcp/jsserver/modules/url" - "github.com/mark3labs/codebench-mcp/jsserver/vm" + "github.com/mark3labs/codebench-mcp/server/modules/buffer" + "github.com/mark3labs/codebench-mcp/server/modules/cache" + "github.com/mark3labs/codebench-mcp/server/modules/console" + "github.com/mark3labs/codebench-mcp/server/modules/crypto" + "github.com/mark3labs/codebench-mcp/server/modules/encoding" + "github.com/mark3labs/codebench-mcp/server/modules/fetch" + "github.com/mark3labs/codebench-mcp/server/modules/http" + "github.com/mark3labs/codebench-mcp/server/modules/kv" + "github.com/mark3labs/codebench-mcp/server/modules/timers" + "github.com/mark3labs/codebench-mcp/server/modules/url" + "github.com/mark3labs/codebench-mcp/server/vm" ) var Version = "dev" @@ -255,7 +255,7 @@ func NewJSServerWithConfig(config ModuleConfig) (*server.MCPServer, error) { h := NewJSHandlerWithConfig(config) s := server.NewMCPServer( - "javascript-executor", + "codebench-mcp", Version, ) @@ -267,7 +267,7 @@ func NewJSServerWithConfig(config ModuleConfig) (*server.MCPServer, error) { "executeJS", mcp.WithDescription(description), mcp.WithString("code", - mcp.Description("Complete JavaScript source code to execute in the ski runtime environment. This parameter accepts a full JavaScript program including variable declarations, function definitions, control flow statements, and module imports via require(). The code will be executed in a sandboxed environment with access to enabled ski modules. Supports modern JavaScript syntax (ES2020+) including arrow functions, destructuring, template literals, and promises. Use require() for module imports (e.g., 'const serve = require(\"http/server\")') rather than ES6 import statements. Note: Top-level async/await is not supported - wrap async code in an async function and call it (e.g., '(async () => { await fetch(...); })()' or define and call an async function). The execution context includes a console object for output, and any returned values will be displayed along with console output. For HTTP servers, they will run in the background without blocking execution completion."), + mcp.Description("Complete JavaScript source code to execute in a modern runtime environment. This parameter accepts a full JavaScript program including variable declarations, function definitions, control flow statements, and module imports via require(). The code will be executed in a sandboxed environment with access to enabled modules. Supports modern JavaScript syntax (ES2020+) including arrow functions, destructuring, template literals, and promises. Use require() for module imports (e.g., 'const serve = require(\"http/server\")') rather than ES6 import statements. Note: Top-level async/await is not supported - wrap async code in an async function and call it (e.g., '(async () => { await fetch(...); })()' or define and call an async function). The execution context includes a console object for output, and any returned values will be displayed along with console output. For HTTP servers, they will run in the background without blocking execution completion."), mcp.Required(), ), ), h.handleExecuteJS) @@ -278,7 +278,7 @@ func NewJSServerWithConfig(config ModuleConfig) (*server.MCPServer, error) { func buildToolDescription(enabledModules []string) string { var description strings.Builder - description.WriteString("Execute JavaScript code with Node.js-like APIs powered by ski runtime. ") + description.WriteString("Execute JavaScript code with Node.js-like APIs powered by a modern runtime. ") description.WriteString("Supports modern JavaScript (ES2020+), CommonJS modules via require(), promises, and comprehensive JavaScript APIs. ") description.WriteString("ES6 import statements are not supported in direct execution - use require() instead.\n\n") diff --git a/jsserver/server_test.go b/server/server_test.go similarity index 99% rename from jsserver/server_test.go rename to server/server_test.go index 159873d..05d6e49 100644 --- a/jsserver/server_test.go +++ b/server/server_test.go @@ -1,4 +1,4 @@ -package jsserver +package server import ( "context" diff --git a/jsserver/vm/eventloop.go b/server/vm/eventloop.go similarity index 100% rename from jsserver/vm/eventloop.go rename to server/vm/eventloop.go diff --git a/jsserver/vm/loader.go b/server/vm/loader.go similarity index 98% rename from jsserver/vm/loader.go rename to server/vm/loader.go index 8ae3ee1..1272f52 100644 --- a/jsserver/vm/loader.go +++ b/server/vm/loader.go @@ -9,7 +9,7 @@ import ( ) // ModuleLoader provides a global require system for modules -// Based on ski's loader pattern but simplified for our use case +// Based on a standard loader pattern but simplified for our use case type ModuleLoader struct { modules sync.Map // map[string]Module aliases sync.Map // map[string]string - maps alias to module name diff --git a/jsserver/vm/manager.go b/server/vm/manager.go similarity index 97% rename from jsserver/vm/manager.go rename to server/vm/manager.go index fa9344e..2148ff3 100644 --- a/jsserver/vm/manager.go +++ b/server/vm/manager.go @@ -100,7 +100,7 @@ type VM struct { } // RunString executes JavaScript code in the VM with event loop support -// This matches ski's pattern where RunString always uses the event loop +// This matches the standard pattern where RunString always uses the event loop func (vm *VM) RunString(code string) (ret sobek.Value, err error) { err = vm.runWithEventLoop(func() error { ret, err = vm.runtime.RunString(code) @@ -109,7 +109,7 @@ func (vm *VM) RunString(code string) (ret sobek.Value, err error) { return } -// runWithEventLoop executes a task in the event loop (similar to ski's Run method) +// runWithEventLoop executes a task in the event loop (similar to standard Run method) func (vm *VM) runWithEventLoop(task func() error) error { // Clear any previous interrupt vm.runtime.ClearInterrupt() diff --git a/jsserver/vm/module.go b/server/vm/module.go similarity index 100% rename from jsserver/vm/module.go rename to server/vm/module.go From 05aa9251e0647d3f2314cca31e521af662188d5e Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 13 Jun 2025 18:27:22 +0300 Subject: [PATCH 7/8] fix http --- server/modules/http/http.go | 541 ++++++++++++++++++++++++++++-------- server/server.go | 117 +++++--- 2 files changed, 503 insertions(+), 155 deletions(-) diff --git a/server/modules/http/http.go b/server/modules/http/http.go index 9059460..8a8edb1 100644 --- a/server/modules/http/http.go +++ b/server/modules/http/http.go @@ -2,9 +2,14 @@ package http import ( "context" + "errors" "fmt" + "io" + "net" "net/http" + "strings" "sync" + "sync/atomic" "time" "github.com/grafana/sobek" @@ -25,19 +30,6 @@ func (h *HTTPModule) Name() string { return "http" } -// httpServer represents a running HTTP server instance -type httpServer struct { - runtime *sobek.Runtime - server *http.Server - hostname string - port int - handler sobek.Callable - ctx context.Context - cancel context.CancelFunc - mu sync.Mutex - running bool -} - // Setup initializes the HTTP module in the VM func (h *HTTPModule) Setup(runtime *sobek.Runtime, manager *vm.VMManager) error { // No setup needed - the module will be available via require() @@ -54,111 +46,399 @@ func (h *HTTPModule) CreateModuleObject(runtime *sobek.Runtime) sobek.Value { // createServer creates and starts an HTTP server func (h *HTTPModule) createServer(call sobek.FunctionCall, runtime *sobek.Runtime) sobek.Value { + serv := &httpServer{ + rt: runtime, + port: 8000, + hostname: "127.0.0.1", + ctx: context.Background(), + server: &http.Server{Addr: "127.0.0.1:8000"}, + } + if len(call.Arguments) == 0 { panic(runtime.NewTypeError("serve requires at least one argument")) } - // Default configuration - port := 8000 - hostname := "127.0.0.1" - var handler sobek.Callable + var handler sobek.Value - // Parse arguments - arg0 := call.Argument(0) - if sobek.IsNumber(arg0) { - // serve(port, handler) - port = int(arg0.ToInteger()) - if len(call.Arguments) > 1 { + opt := call.Argument(0) + switch { + case isNumber(opt): + port := opt.ToInteger() + if port <= 0 { + panic(runtime.NewTypeError("port must be a positive number")) + } + serv.port = int(port) + serv.server.Addr = fmt.Sprintf(":%d", serv.port) + handler = call.Argument(1) + case isFunc(opt): + handler = opt + default: + opts := opt.ToObject(runtime) + if v := opts.Get("port"); v != nil { + serv.port = int(v.ToInteger()) + serv.server.Addr = fmt.Sprintf(":%d", serv.port) + } + if v := opts.Get("hostname"); v != nil { + serv.hostname = v.String() + serv.server.Addr = fmt.Sprintf("%s:%d", serv.hostname, serv.port) + } + if v := opts.Get("maxHeaderSize"); v != nil { + serv.server.MaxHeaderBytes = int(v.ToInteger()) + } + if v := opts.Get("keepAliveTimeout"); v != nil { + serv.server.IdleTimeout = time.Duration(v.ToInteger()) * time.Millisecond + } + if v := opts.Get("requestTimeout"); v != nil { + serv.server.ReadTimeout = time.Duration(v.ToInteger()) * time.Millisecond + } + if v := opts.Get("onError"); v != nil { var ok bool - handler, ok = sobek.AssertFunction(call.Argument(1)) + serv.onError, ok = sobek.AssertFunction(v) if !ok { - panic(runtime.NewTypeError("handler must be a function")) + panic(runtime.NewTypeError("onError must be a function")) } } - } else { - // serve(handler) or serve(options, handler) + if v := opts.Get("onListen"); v != nil { + var ok bool + serv.onListen, ok = sobek.AssertFunction(v) + if !ok { + panic(runtime.NewTypeError("onListen must be a function")) + } + } + if v := opts.Get("handler"); v != nil { + handler = v + } + if v := call.Argument(1); !sobek.IsUndefined(v) { + handler = v + } + } + + if handler != nil { var ok bool - handler, ok = sobek.AssertFunction(arg0) + serv.handler, ok = sobek.AssertFunction(handler) if !ok { panic(runtime.NewTypeError("handler must be a function")) } } - - if handler == nil { - panic(runtime.NewTypeError("handler is required")) + if serv.onError == nil { + serv.onError = func(this sobek.Value, args ...sobek.Value) (sobek.Value, error) { + code := http.StatusInternalServerError + msg := http.StatusText(code) + if len(args) > 0 { + err := args[0].ToObject(runtime) + url := err.Get("url").String() + method := err.Get("method").String() + message := err.Get("message").String() + msg = fmt.Sprintf("Internal Server Error %s %s %s", method, url, message) + } + logger.Error(msg) + return newResponse(runtime, &http.Response{ + StatusCode: code, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(http.StatusText(code))), + }), nil + } } - - // Create server context - ctx, cancel := context.WithCancel(context.Background()) - - server := &httpServer{ - runtime: runtime, - hostname: hostname, - port: port, - handler: handler, - ctx: ctx, - cancel: cancel, + if serv.handler == nil { + serv.handler = func(this sobek.Value, args ...sobek.Value) (sobek.Value, error) { + code := http.StatusNotFound + body := strings.NewReader(http.StatusText(http.StatusNotFound)) + return newResponse(runtime, &http.Response{ + StatusCode: code, + Header: make(http.Header), + Body: io.NopCloser(body), + }), nil + } } - // Create HTTP server - addr := fmt.Sprintf("%s:%d", hostname, port) - server.server = &http.Server{ - Addr: addr, - Handler: http.HandlerFunc(server.handleRequest), - } + serv.server.Handler = serv + serv.ref = vm.EnqueueJob(runtime) + ln := serv.listen() - // Start server in goroutine go func() { - server.mu.Lock() - server.running = true - server.mu.Unlock() - - logger.Debug("Starting HTTP server", "addr", addr) - - if err := server.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("HTTP server error", "error", err) + vm.EnqueueJob(runtime)(func() error { + if serv.onListen != nil { + _, _ = serv.onListen(sobek.Undefined(), serv.addr()) + } else { + logger.Info(fmt.Sprintf("listening on %s", serv.url())) + } + return nil + }) + err := serv.server.Serve(ln) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + vm.EnqueueJob(runtime)(func() error { return err }) } - - server.mu.Lock() - server.running = false - server.mu.Unlock() }() // Create server object to return serverObj := runtime.NewObject() // Add properties - serverObj.Set("url", fmt.Sprintf("http://%s:%d", hostname, port)) - serverObj.Set("port", port) - serverObj.Set("hostname", hostname) + serverObj.Set("url", serv.url()) + serverObj.Set("port", serv.port) + serverObj.Set("hostname", serv.hostname) // Add methods serverObj.Set("close", func(call sobek.FunctionCall) sobek.Value { - server.shutdown() + if err := serv.close(); err != nil { + panic(runtime.NewGoError(err)) + } return sobek.Undefined() }) serverObj.Set("shutdown", func(call sobek.FunctionCall) sobek.Value { - server.shutdown() + if err := serv.shutdown(); err != nil { + panic(runtime.NewGoError(err)) + } return sobek.Undefined() }) - // Store server reference for cleanup - runtime.Set("__http_server__", server) - return serverObj } -// handleRequest handles incoming HTTP requests -func (s *httpServer) handleRequest(w http.ResponseWriter, r *http.Request) { - // Create request object for JavaScript - reqObj := s.runtime.NewObject() +type httpServer struct { + rt *sobek.Runtime + server *http.Server + hostname string + port int + + handler, onError, onListen sobek.Callable + + ctx context.Context + closed atomic.Bool + + ref func(func() error) +} + +func (s *httpServer) url() string { + if s.port == 80 { + return "http://" + s.hostname + } + return fmt.Sprintf("http://%s:%d", s.hostname, s.port) +} + +func (s *httpServer) addr() sobek.Value { + return s.rt.ToValue(map[string]any{ + "hostname": s.hostname, + "port": s.port, + }) +} + +func (s *httpServer) listen() net.Listener { + ln, err := net.Listen("tcp", s.server.Addr) + if err != nil { + panic(s.rt.NewGoError(err)) + } + return ln +} + +func (s *httpServer) close() error { + s.closed.Store(true) + err := s.server.Close() + if s.ref != nil { + s.ref(func() error { s.ref = nil; return nil }) + } + return err +} + +func (s *httpServer) shutdown() error { + s.closed.Store(true) + err := s.server.Shutdown(s.ctx) + if s.ref != nil { + s.ref(func() error { s.ref = nil; return nil }) + } + if errors.Is(err, context.Canceled) { + return nil + } + return err +} + +// ServeHTTP implements http.Handler +func (s *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var wg sync.WaitGroup + wg.Add(1) + vm.EnqueueJob(s.rt)(func() error { + result, err := s.handler(sobek.Undefined(), newRequest(s.rt, r)) + if err != nil { + s.writeError(w, r, wg.Done, err) + return nil + } + + // Handle promise result + if isPromise(result) { + s.handlePromise(w, r, wg.Done, result) + return nil + } + + if res, ok := toResponse(result); ok { + s.writeResponse(w, r, wg.Done, res) + } else { + s.writeError(w, r, wg.Done, errNotResponse) + } + return nil + }) + wg.Wait() +} + +func (s *httpServer) writeResponse(w http.ResponseWriter, r *http.Request, done func(), res *http.Response) { + defer done() + + header := w.Header() + for k, v := range res.Header { + header[http.CanonicalHeaderKey(k)] = v + } + w.WriteHeader(res.StatusCode) + + if _, err := io.Copy(w, res.Body); err != nil { + logger.Error("Failed to write response", "error", err, "method", r.Method, "url", r.URL.String()) + } +} + +func (s *httpServer) writeError(w http.ResponseWriter, r *http.Request, done func(), rawErr error) { + var ( + jsErr *sobek.Object + result sobek.Value + err error + ) + + ex, ok := rawErr.(*sobek.Exception) + if ok { + jsErr, ok = ex.Value().(*sobek.Object) + } + if !ok { + jsErr, err = s.rt.New(s.rt.Get("Error"), s.rt.ToValue(rawErr.Error())) + if err != nil { + goto EX + } + } + _ = jsErr.Set("method", r.Method) + _ = jsErr.Set("headers", r.Header) + _ = jsErr.Set("url", r.URL.String()) + + result, err = s.onError(sobek.Undefined(), jsErr) + if err != nil { + goto EX + } + + if !isPromise(result) { + if res, ok := toResponse(result); ok { + s.writeResponse(w, r, done, res) + return + } + err = errNotResponse + } else { + switch p := result.Export().(*sobek.Promise); p.State() { + case sobek.PromiseStateRejected: + if ex, ok := p.Result().Export().(error); ok { + err = ex + } else { + err = errors.New(p.Result().String()) + } + case sobek.PromiseStateFulfilled: + if res, ok := toResponse(result); ok { + s.writeResponse(w, r, done, res) + return + } + err = errNotResponse + default: + if err = s.handlePendingPromise(w, r, done, result); err == nil { + return + } + } + } + +EX: + logger.Error("Exception in onError while handling exception", "message", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + w.Write(internalServerError) + done() +} + +// handlePromise handles promise result +func (s *httpServer) handlePromise(w http.ResponseWriter, r *http.Request, done func(), result sobek.Value) { + var err error + switch p := result.Export().(*sobek.Promise); p.State() { + case sobek.PromiseStateRejected: + if ex, ok := p.Result().Export().(error); ok { + err = ex + } else { + err = errors.New(p.Result().String()) + } + case sobek.PromiseStateFulfilled: + if res, ok := toResponse(p.Result()); ok { + s.writeResponse(w, r, done, res) + } else { + err = errNotResponse + } + default: + err = s.handlePendingPromise(w, r, done, result) + } + if err != nil { + s.writeError(w, r, done, err) + } +} + +// handlePendingPromise handles a pending promise with resolve and reject callbacks +func (s *httpServer) handlePendingPromise(w http.ResponseWriter, r *http.Request, done func(), promise sobek.Value) error { + object := promise.(*sobek.Object) + then, ok := sobek.AssertFunction(object.Get("then")) + if !ok { + return errNotResponse + } + + resolve := s.rt.ToValue(func(call sobek.FunctionCall) sobek.Value { + if res, ok := toResponse(call.Argument(0)); ok { + s.writeResponse(w, r, done, res) + } else { + s.writeError(w, r, done, errNotResponse) + } + return sobek.Undefined() + }) + + reject := s.rt.ToValue(func(call sobek.FunctionCall) sobek.Value { + v := call.Argument(0) + if ex, ok := v.Export().(error); ok { + s.writeError(w, r, done, ex) + } else { + s.writeError(w, r, done, errors.New(v.String())) + } + return sobek.Undefined() + }) + + _, err := then(object, resolve, reject) + return err +} + +// Helper functions + +func isNumber(v sobek.Value) bool { + return sobek.IsNumber(v) +} + +func isFunc(v sobek.Value) bool { + _, ok := sobek.AssertFunction(v) + return ok +} + +func isPromise(value sobek.Value) bool { + if obj := value.ToObject(nil); obj != nil { + if thenMethod := obj.Get("then"); thenMethod != nil && !sobek.IsUndefined(thenMethod) { + _, ok := sobek.AssertFunction(thenMethod) + return ok + } + } + return false +} + +// newRequest creates a JavaScript request object from http.Request +func newRequest(runtime *sobek.Runtime, r *http.Request) sobek.Value { + reqObj := runtime.NewObject() reqObj.Set("method", r.Method) reqObj.Set("url", r.URL.Path) reqObj.Set("path", r.URL.Path) // Headers - headersObj := s.runtime.NewObject() + headersObj := runtime.NewObject() for key, values := range r.Header { if len(values) > 0 { headersObj.Set(key, values[0]) @@ -166,69 +446,94 @@ func (s *httpServer) handleRequest(w http.ResponseWriter, r *http.Request) { } reqObj.Set("headers", headersObj) - // Call the JavaScript handler - result, err := s.handler(sobek.Undefined(), s.runtime.ToValue(reqObj)) + return reqObj +} + +// newResponse creates a Response object from http.Response +func newResponse(runtime *sobek.Runtime, resp *http.Response) sobek.Value { + responseObj := runtime.NewObject() + responseObj.Set("status", resp.StatusCode) + responseObj.Set("statusText", resp.Status) + responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) + + // Headers object + headersObj := runtime.NewObject() + for key, values := range resp.Header { + if len(values) > 0 { + headersObj.Set(key, values[0]) + } + } + responseObj.Set("headers", headersObj) + + // Read response body + bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - logger.Error("Handler error", "error", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return + panic(runtime.NewGoError(err)) } + resp.Body.Close() - // Process the response - if result != nil && !sobek.IsUndefined(result) { - responseObj := result.ToObject(s.runtime) + // text() method + responseObj.Set("text", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(string(bodyBytes)) + }) + + // Store the actual http.Response for internal use + responseObj.Set("__httpResponse", resp) - // Get status code + return responseObj +} + +// toResponse converts a sobek.Value to *http.Response +func toResponse(value sobek.Value) (*http.Response, bool) { + if obj := value.ToObject(nil); obj != nil { + // Check if it's our internal response object + if httpResp := obj.Get("__httpResponse"); httpResp != nil && !sobek.IsUndefined(httpResp) { + if resp, ok := httpResp.Export().(*http.Response); ok { + return resp, true + } + } + + // Handle standard Response objects status := 200 - if statusVal := responseObj.Get("status"); statusVal != nil && !sobek.IsUndefined(statusVal) { + if statusVal := obj.Get("status"); statusVal != nil && !sobek.IsUndefined(statusVal) { status = int(statusVal.ToInteger()) } - // Get headers - if headersVal := responseObj.Get("headers"); headersVal != nil && !sobek.IsUndefined(headersVal) { - headersObj := headersVal.ToObject(s.runtime) + headers := make(http.Header) + if headersVal := obj.Get("headers"); headersVal != nil && !sobek.IsUndefined(headersVal) { + headersObj := headersVal.ToObject(nil) for _, key := range headersObj.Keys() { value := headersObj.Get(key).String() - w.Header().Set(key, value) + headers.Set(key, value) } } - // Get body + // Get body content body := "" - if bodyVal := responseObj.Get("body"); bodyVal != nil && !sobek.IsUndefined(bodyVal) { + if bodyVal := obj.Get("body"); bodyVal != nil && !sobek.IsUndefined(bodyVal) { body = bodyVal.String() + } else if textMethod := obj.Get("text"); textMethod != nil && !sobek.IsUndefined(textMethod) { + if textFunc, ok := sobek.AssertFunction(textMethod); ok { + textResult, err := textFunc(obj) + if err == nil && textResult != nil && !sobek.IsUndefined(textResult) { + body = textResult.String() + } + } } - // Write response - w.WriteHeader(status) - w.Write([]byte(body)) - } else { - // Default response - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + return &http.Response{ + StatusCode: status, + Header: headers, + Body: io.NopCloser(strings.NewReader(body)), + }, true } + return nil, false } -// shutdown gracefully shuts down the server -func (s *httpServer) shutdown() { - s.mu.Lock() - defer s.mu.Unlock() - - if s.running && s.server != nil { - logger.Debug("Shutting down HTTP server") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := s.server.Shutdown(ctx); err != nil { - logger.Error("Server shutdown error", "error", err) - } - s.running = false - } - - if s.cancel != nil { - s.cancel() - } -} +var ( + internalServerError = []byte(http.StatusText(http.StatusInternalServerError)) + errNotResponse = errors.New("return value from handler must be a response or a promise resolving to a response") +) // Cleanup performs any necessary cleanup func (h *HTTPModule) Cleanup() error { @@ -240,4 +545,4 @@ func (h *HTTPModule) Cleanup() error { func (h *HTTPModule) IsEnabled(enabledModules map[string]bool) bool { enabled, exists := enabledModules["http"] return exists && enabled -} +} \ No newline at end of file diff --git a/server/server.go b/server/server.go index 6805372..e8835df 100644 --- a/server/server.go +++ b/server/server.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" "time" "github.com/grafana/sobek" @@ -33,8 +34,10 @@ type ModuleConfig struct { } type JSHandler struct { - vmManager *vm.VMManager - config ModuleConfig + vmManager *vm.VMManager + config ModuleConfig + runningVMs []*vm.VM + vmMutex sync.Mutex } func NewJSHandler() *JSHandler { @@ -82,7 +85,9 @@ func (h *JSHandler) handleExecuteJS( logger.Debug("Executing JavaScript code", "length", len(code)) // Check if this looks like HTTP server code - isServerCode := strings.Contains(code, "serve(") || strings.Contains(code, "require('http/server')") + isServerCode := strings.Contains(code, "serve(") && + (strings.Contains(code, "require('http/server')") || + strings.Contains(code, "require(\"http/server\")")) if isServerCode { logger.Debug("Detected server code, running in background") @@ -99,19 +104,26 @@ func (h *JSHandler) handleServerCode(ctx context.Context, code string) (*mcp.Cal // Capture console output var output strings.Builder - // Channel to signal if a server was actually started - serverStarted := make(chan bool, 1) + // Channel to capture execution results + resultChan := make(chan string, 1) + errorChan := make(chan error, 1) - // Run the server code in a goroutine + // Run the server code in a goroutine that stays alive go func() { // Create VM with custom logger for console output - vm, err := h.vmManager.CreateVM(ctx) + // Use background context so VM doesn't get cancelled when request finishes + vmCtx := context.Background() + vm, err := h.vmManager.CreateVM(vmCtx) if err != nil { logger.Debug("Failed to create VM", "error", err) - serverStarted <- false + errorChan <- err return } - defer vm.Close() + + // Track this VM for cleanup + h.vmMutex.Lock() + h.runningVMs = append(h.runningVMs, vm) + h.vmMutex.Unlock() // Setup console module to capture output consoleModule := console.NewConsoleModule(&output) @@ -121,41 +133,60 @@ func (h *JSHandler) handleServerCode(ctx context.Context, code string) (*mcp.Cal _, err = vm.RunString(code) if err != nil { logger.Error("Server execution error", "error", err) - serverStarted <- false + errorChan <- err + // Remove from tracking and close VM on error + h.vmMutex.Lock() + for i, trackedVM := range h.runningVMs { + if trackedVM == vm { + h.runningVMs = append(h.runningVMs[:i], h.runningVMs[i+1:]...) + break + } + } + h.vmMutex.Unlock() + vm.Close() return } - // If no server was started, signal false and let goroutine exit - select { - case serverStarted <- false: - default: - // Channel already has a value, meaning a server was started - } + // Send initial output back + resultChan <- output.String() - // Check if we should keep the goroutine alive - select { - case started := <-serverStarted: - if started { - // Keep the goroutine alive indefinitely for HTTP servers - select {} - } - // Otherwise, let the goroutine exit naturally - default: - // No signal received, let goroutine exit - } + // Keep the goroutine and VM alive indefinitely for HTTP servers + // The VM will be cleaned up when the MCP server shuts down + select {} }() - // Give the server time to start - time.Sleep(500 * time.Millisecond) - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Server code executed in background. Check console output:\n%s", output.String()), + // Wait for initial execution to complete or timeout + select { + case <-time.After(2 * time.Second): + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Server code execution timeout. If this is an HTTP server, it may still be starting in the background.", + }, + }, + IsError: true, + }, nil + case err := <-errorChan: + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Server execution error: %v", err), + }, }, - }, - }, nil + IsError: true, + }, nil + case result := <-resultChan: + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Server code executed in background:\n%s", result), + }, + }, + }, nil + } } func (h *JSHandler) handleRegularCode(ctx context.Context, code string) (*mcp.CallToolResult, error) { @@ -245,6 +276,18 @@ func (h *JSHandler) getAvailableModules() []string { return h.vmManager.GetEnabledModules() } +// Cleanup shuts down all running VMs +func (h *JSHandler) Cleanup() { + h.vmMutex.Lock() + defer h.vmMutex.Unlock() + + logger.Debug("Cleaning up running VMs", "count", len(h.runningVMs)) + for _, vm := range h.runningVMs { + vm.Close() + } + h.runningVMs = nil +} + func NewJSServer() (*server.MCPServer, error) { return NewJSServerWithConfig(ModuleConfig{ EnabledModules: []string{"http", "fetch", "timers", "buffer", "kv", "crypto"}, From 59370fc634627a7f804f847b42b61911a6811436 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 13 Jun 2025 18:40:17 +0300 Subject: [PATCH 8/8] handle body --- server/modules/http/http.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/server/modules/http/http.go b/server/modules/http/http.go index 8a8edb1..3573ec4 100644 --- a/server/modules/http/http.go +++ b/server/modules/http/http.go @@ -446,6 +446,37 @@ func newRequest(runtime *sobek.Runtime, r *http.Request) sobek.Value { } reqObj.Set("headers", headersObj) + // Read request body + bodyStr := "" + if r.Body != nil { + bodyBytes, err := io.ReadAll(r.Body) + if err == nil { + bodyStr = string(bodyBytes) + } + // Close the original body and replace with a new reader for downstream use + r.Body.Close() + r.Body = io.NopCloser(strings.NewReader(bodyStr)) + } + + reqObj.Set("body", bodyStr) + + // Add text() method for compatibility + reqObj.Set("text", func(call sobek.FunctionCall) sobek.Value { + return runtime.ToValue(bodyStr) + }) + + // Add json() method for convenience + reqObj.Set("json", func(call sobek.FunctionCall) sobek.Value { + if bodyStr == "" { + return sobek.Null() + } + jsonVal, err := runtime.RunString("JSON.parse(" + runtime.ToValue(bodyStr).String() + ")") + if err != nil { + panic(runtime.NewGoError(err)) + } + return jsonVal + }) + return reqObj }