diff --git a/README.md b/README.md index 8a51d3a..e6f787e 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # 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) -- **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 @@ -47,21 +47,16 @@ 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) -- `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` +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. @@ -73,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) } @@ -100,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) } @@ -212,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 @@ -230,18 +225,18 @@ 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'); }); // 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); @@ -260,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 77fa950..0b037dd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,19 +2,20 @@ package cmd import ( "fmt" - "log" "os" "slices" "strings" - "github.com/mark3labs/codebench-mcp/jsserver" - "github.com/mark3labs/mcp-go/server" + "github.com/mark3labs/codebench-mcp/internal/logger" + "github.com/mark3labs/codebench-mcp/server" + mcpserver "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" ) 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", + "cache", + // TODO: Add these as they're implemented + // "dom", + // "ext", + // "html", + // "signal", + // "stream", } // rootCmd represents the base command when called without any subcommands @@ -39,11 +42,16 @@ 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) + + 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,22 +78,26 @@ 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", "encoding", "url", "cache"} } + 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 { - 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) + if err := mcpserver.ServeStdio(jss); err != nil { + 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/description_test.go b/jsserver/description_test.go deleted file mode 100644 index b55c622..0000000 --- a/jsserver/description_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package jsserver - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBuildToolDescription(t *testing.T) { - tests := []struct { - name string - enabledModules []string - expectedContent []string - notExpected []string - }{ - { - name: "All modules enabled", - enabledModules: []string{"http", "fetch", "timers", "buffer", "crypto"}, - expectedContent: []string{ - "ski runtime", - "Node.js-like APIs", - "Available modules:", - "• 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)", - "Example usage (modern JavaScript with require()):", - }, - }, - { - name: "Only http and fetch", - enabledModules: []string{"http", "fetch"}, - expectedContent: []string{ - "ski runtime", - "Available modules:", - "• http: HTTP server creation and management", - "• fetch: Modern fetch API with Request, Response, Headers, FormData", - "Example usage (modern JavaScript with require()):", - }, - notExpected: []string{ - "• timers:", - "• buffer:", - "• crypto:", - }, - }, - { - name: "No modules enabled", - enabledModules: []string{}, - expectedContent: []string{ - "ski runtime", - "No modules are currently enabled", - "Only basic JavaScript execution is available", - }, - notExpected: []string{ - "Available modules:", - "Usage:", - }, - }, - { - name: "Only http enabled", - enabledModules: []string{"http"}, - expectedContent: []string{ - "Available modules:", - "• http: HTTP server creation and management", - }, - notExpected: []string{ - "• fetch:", - "• timers:", - "• buffer:", - "• crypto:", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - description := buildToolDescription(tt.enabledModules) - - // Check expected content - for _, expected := range tt.expectedContent { - assert.Contains(t, description, expected, "Description should contain: %s", expected) - } - - // Check content that should not be present - for _, notExpected := range tt.notExpected { - assert.NotContains(t, description, notExpected, "Description should not contain: %s", notExpected) - } - }) - } -} - -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") - - // Config1 should mention http and fetch - assert.Contains(t, desc1, "• http:") - assert.Contains(t, desc1, "• fetch:") - assert.NotContains(t, desc1, "• timers:") - - // Config2 should mention timers - assert.Contains(t, desc2, "• timers:") - assert.NotContains(t, desc2, "• http:") - assert.NotContains(t, desc2, "• fetch:") -} diff --git a/jsserver/server.go b/jsserver/server.go deleted file mode 100644 index 799fbe7..0000000 --- a/jsserver/server.go +++ /dev/null @@ -1,494 +0,0 @@ -package jsserver - -import ( - "bytes" - "context" - "fmt" - "log/slog" - "strings" - "time" - - "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" -) - -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 -} - -type JSHandler struct { - config ModuleConfig -} - -func NewJSHandler() *JSHandler { - return NewJSHandlerWithConfig(ModuleConfig{ - EnabledModules: []string{"http", "fetch", "timers", "buffer", "crypto"}, - }) -} - -func NewJSHandlerWithConfig(config ModuleConfig) *JSHandler { - return &JSHandler{ - config: config, - } -} - -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 - } - - // Otherwise check enabled modules list - if len(h.config.EnabledModules) == 0 { - return true // If no config, enable all - } - - for _, enabled := range h.config.EnabledModules { - if enabled == module { - return true - } - } - return false -} - -func (h *JSHandler) handleExecuteJS( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - code, err := request.RequireString("code") - if err != nil { - return nil, err - } - - // Check if this looks like HTTP server code - isServerCode := strings.Contains(code, "serve(") || strings.Contains(code, "require('ski/http/server')") - - if isServerCode { - // For server code, run in a goroutine and return immediately - return h.handleServerCode(ctx, code) - } else { - // For regular code, run synchronously - return h.handleRegularCode(ctx, code) - } -} - -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) - - // 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, - } - 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.setupHTTPModuleWithCallback(vm, serverStarted) - } - - // Execute the JavaScript code - _, err := vm.RunString(context.Background(), 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 - } - - // 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 - } - - // 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 - } - }() - - // 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()), - }, - }, - }, 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, - } - - 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) - } - - // 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) - - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("JavaScript execution error: %v\n\nOutput:\n%s", err, 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()), - }, - }, - }, 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)) - } - - // 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 - })) -} - -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 -} - -func NewJSServer() (*server.MCPServer, error) { - return NewJSServerWithConfig(ModuleConfig{ - EnabledModules: []string{"http", "fetch", "timers", "buffer", "crypto"}, - }) -} - -func NewJSServerWithConfig(config ModuleConfig) (*server.MCPServer, error) { - h := NewJSHandlerWithConfig(config) - - s := server.NewMCPServer( - "javascript-executor", - Version, - ) - - // Build detailed description with module information - description := buildToolDescription(h.getAvailableModules()) - - // Register the executeJS tool - s.AddTool(mcp.NewTool( - "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.Required(), - ), - ), h.handleExecuteJS) - - return s, nil -} - -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("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") - - if len(enabledModules) == 0 { - description.WriteString("No modules are currently enabled. Only basic JavaScript execution is available.") - return description.String() - } - - description.WriteString("Available modules:\n") - - // Define module descriptions with ski's actual features and require paths - 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)", - } - - // Add enabled modules with descriptions - for _, module := range enabledModules { - if desc, exists := moduleDescriptions[module]; exists { - description.WriteString(fmt.Sprintf("• %s: %s\n", module, desc)) - } - } - - // Add usage examples - description.WriteString("\nExample usage (modern JavaScript with require()):\n") - description.WriteString("```javascript\n") - description.WriteString("// Basic JavaScript execution\n") - description.WriteString("const result = 2 + 3;\n") - description.WriteString("console.log('Result:', result);\n\n") - - // Create a set for faster lookup - enabledSet := make(map[string]bool) - for _, module := range enabledModules { - enabledSet[module] = true - } - - // Add examples only for enabled modules - if enabledSet["fetch"] { - description.WriteString("// Fetch API (available globally when enabled)\n") - description.WriteString("const response = await fetch('https://api.example.com/data');\n") - description.WriteString("const data = await response.json();\n") - description.WriteString("console.log(data);\n\n") - } - - 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 server = serve(8000, async (req) => {\n") - description.WriteString(" return new Response(`Hello ${req.method} ${req.url}!`);\n") - description.WriteString("});\n") - 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") - description.WriteString("const hash = crypto.md5('hello').hex();\n") - description.WriteString("console.log('MD5 hash:', hash);\n\n") - } - - if enabledSet["timers"] { - description.WriteString("// Timers (available globally)\n") - description.WriteString("setTimeout(() => {\n") - description.WriteString(" console.log('Hello after 1 second');\n") - description.WriteString("}, 1000);\n\n") - } - - if enabledSet["buffer"] { - description.WriteString("// Buffer operations (available globally)\n") - description.WriteString("const buffer = Buffer.from('hello', 'utf8');\n") - description.WriteString("console.log(buffer.toString('base64'));\n\n") - } - - description.WriteString("```\n") - 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("• Async/await and Promises are fully supported\n") - - return description.String() -} diff --git a/server/description_test.go b/server/description_test.go new file mode 100644 index 0000000..67a910b --- /dev/null +++ b/server/description_test.go @@ -0,0 +1,191 @@ +package server + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildToolDescription(t *testing.T) { + tests := []struct { + name string + enabledModules []string + expectedContent []string + notExpected []string + }{ + { + name: "All modules enabled", + enabledModules: []string{"http", "fetch", "timers", "buffer", "crypto"}, + expectedContent: []string{ + "modern runtime", + "Node.js-like APIs", + "Available modules:", + "• 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)", + "Example usage (modern JavaScript with require()):", + }, + }, + { + name: "Only http and fetch", + enabledModules: []string{"http", "fetch"}, + expectedContent: []string{ + "modern runtime", + "Available modules:", + "• http: HTTP server creation and management", + "• fetch: Modern fetch API with Request, Response, Headers, FormData", + "Example usage (modern JavaScript with require()):", + }, + notExpected: []string{ + "• timers:", + "• buffer:", + "• crypto:", + }, + }, + { + name: "No modules enabled", + enabledModules: []string{}, + expectedContent: []string{ + "modern runtime", + "No modules are currently enabled", + "Only basic JavaScript execution is available", + }, + notExpected: []string{ + "Available modules:", + "Usage:", + }, + }, + { + name: "Only http enabled", + enabledModules: []string{"http"}, + expectedContent: []string{ + "Available modules:", + "• http: HTTP server creation and management", + }, + notExpected: []string{ + "• fetch:", + "• timers:", + "• buffer:", + "• crypto:", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + description := buildToolDescription(tt.enabledModules) + + // Check expected content + for _, expected := range tt.expectedContent { + assert.Contains(t, description, expected, "Description should contain: %s", expected) + } + + // Check content that should not be present + for _, notExpected := range tt.notExpected { + assert.NotContains(t, description, notExpected, "Description should not contain: %s", notExpected) + } + }) + } +} + +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) +} + +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) + } +} + +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/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/server/modules/buffer/buffer.go b/server/modules/buffer/buffer.go new file mode 100644 index 0000000..1819bb2 --- /dev/null +++ b/server/modules/buffer/buffer.go @@ -0,0 +1,208 @@ +package buffer + +import ( + "encoding/base64" + "encoding/hex" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/server/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() + 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)) + } + } + } + } + } + + // 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/server/modules/cache/cache.go b/server/modules/cache/cache.go new file mode 100644 index 0000000..7dd085b --- /dev/null +++ b/server/modules/cache/cache.go @@ -0,0 +1,229 @@ +package cache + +import ( + "context" + "sync" + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/server/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/server/modules/console/console.go b/server/modules/console/console.go new file mode 100644 index 0000000..3bbc2a2 --- /dev/null +++ b/server/modules/console/console.go @@ -0,0 +1,98 @@ +package console + +import ( + "fmt" + "strings" + + "github.com/grafana/sobek" +) + +// ConsoleModule provides console.log, console.error, etc. +type ConsoleModule struct { + output *strings.Builder +} + +// NewConsoleModule creates a new console module +func NewConsoleModule(output *strings.Builder) *ConsoleModule { + if output == nil { + output = &strings.Builder{} + } + return &ConsoleModule{ + output: output, + } +} + +// 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, " ") +} + +// 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) error { + console := runtime.NewObject() + + // console.log + console.Set("log", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.writeMessage(message) + return sobek.Undefined() + }) + + // console.error + console.Set("error", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.writeMessage(message) + return sobek.Undefined() + }) + + // console.warn + console.Set("warn", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.writeMessage(message) + return sobek.Undefined() + }) + + // console.info + console.Set("info", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.writeMessage(message) + return sobek.Undefined() + }) + + // console.debug + console.Set("debug", func(call sobek.FunctionCall) sobek.Value { + message := c.formatArgs(call.Arguments) + c.writeMessage(message) + return sobek.Undefined() + }) + + // Set console as global + runtime.Set("console", console) + return nil +} \ No newline at end of file diff --git a/server/modules/crypto/crypto.go b/server/modules/crypto/crypto.go new file mode 100644 index 0000000..c5b541b --- /dev/null +++ b/server/modules/crypto/crypto.go @@ -0,0 +1,225 @@ +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/server/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 { + // 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() + + // 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/server/modules/encoding/encoding.go b/server/modules/encoding/encoding.go new file mode 100644 index 0000000..9ec639a --- /dev/null +++ b/server/modules/encoding/encoding.go @@ -0,0 +1,103 @@ +package encoding + +import ( + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/server/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/server/modules/fetch/fetch.go b/server/modules/fetch/fetch.go new file mode 100644 index 0000000..a18fd1d --- /dev/null +++ b/server/modules/fetch/fetch.go @@ -0,0 +1,245 @@ +package fetch + +import ( + "io" + "net/http" + "net/http/cookiejar" + "strings" + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/server/vm" +) + +// FetchModule provides fetch API functionality +type FetchModule struct { + client *http.Client +} + +// 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, + }, + } +} + +// 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 { + // No setup needed - fetch will be available as a global + return nil +} + +// GetGlobalName returns the global name for this module +func (f *FetchModule) GetGlobalName() string { + return "fetch" +} + +// 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 + 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 + }) +} + +// 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 +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 +} \ No newline at end of file diff --git a/server/modules/http/http.go b/server/modules/http/http.go new file mode 100644 index 0000000..3573ec4 --- /dev/null +++ b/server/modules/http/http.go @@ -0,0 +1,579 @@ +package http + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" + "github.com/mark3labs/codebench-mcp/server/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" +} + +// 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() + return nil +} + +// 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) + }) +} + +// 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")) + } + + var handler sobek.Value + + 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 + serv.onError, ok = sobek.AssertFunction(v) + if !ok { + panic(runtime.NewTypeError("onError must be a function")) + } + } + 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 + serv.handler, ok = sobek.AssertFunction(handler) + if !ok { + panic(runtime.NewTypeError("handler must be a function")) + } + } + 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 + } + } + 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 + } + } + + serv.server.Handler = serv + serv.ref = vm.EnqueueJob(runtime) + ln := serv.listen() + + go func() { + 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 }) + } + }() + + // Create server object to return + serverObj := runtime.NewObject() + + // Add properties + 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 { + if err := serv.close(); err != nil { + panic(runtime.NewGoError(err)) + } + return sobek.Undefined() + }) + + serverObj.Set("shutdown", func(call sobek.FunctionCall) sobek.Value { + if err := serv.shutdown(); err != nil { + panic(runtime.NewGoError(err)) + } + return sobek.Undefined() + }) + + return serverObj +} + +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 := runtime.NewObject() + for key, values := range r.Header { + if len(values) > 0 { + headersObj.Set(key, values[0]) + } + } + 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 +} + +// 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 { + panic(runtime.NewGoError(err)) + } + resp.Body.Close() + + // 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) + + 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 := obj.Get("status"); statusVal != nil && !sobek.IsUndefined(statusVal) { + status = int(statusVal.ToInteger()) + } + + 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() + headers.Set(key, value) + } + } + + // Get body content + body := "" + 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() + } + } + } + + return &http.Response{ + StatusCode: status, + Header: headers, + Body: io.NopCloser(strings.NewReader(body)), + }, true + } + return nil, false +} + +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 { + // 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 +} \ No newline at end of file diff --git a/server/modules/kv/kv.go b/server/modules/kv/kv.go new file mode 100644 index 0000000..4a4f3b9 --- /dev/null +++ b/server/modules/kv/kv.go @@ -0,0 +1,122 @@ +package kv + +import ( + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/server/vm" +) + +// KVModule provides key-value storage per VM instance +type KVModule struct { + 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]any), + } +} + +// 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 { + // 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 + 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]any) + 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)) + }) + + return kvObj +} + +// 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/server/modules/timers/timers.go b/server/modules/timers/timers.go new file mode 100644 index 0000000..85140f7 --- /dev/null +++ b/server/modules/timers/timers.go @@ -0,0 +1,261 @@ +package timers + +import ( + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" + "github.com/mark3labs/codebench-mcp/server/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 { + logger.Debug("Setting up timers module") + + // setTimeout - standard implementation + 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")) + } + + i := call.Argument(1).ToInteger() + if i < 1 || i > 2147483647 { + 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 - standard implementation + 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 - standard implementation + 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")) + } + + i := call.Argument(1).ToInteger() + if i < 1 || i > 2147483647 { + i = 1 + } + delay := time.Duration(i) * time.Millisecond + logger.Debug("setInterval delay", "ms", i) + + 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) + 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 + } + } + }() + + return runtime.ToValue(t.id) + }) + + // clearInterval - standard implementation + 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 +} + +// 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 (standard implementation) +type timer struct { + id int64 + timer <-chan time.Time + done chan struct{} + cleanup func() +} + +func (t *timer) stop() { + logger.Debug("Stopping timer", "id", t.id) + select { + 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) + } +} + +// timers manages all timers for a runtime (standard implementation) +type timers struct { + id int64 + timer map[int64]*timer +} + +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{}), + } + if repeat { + t1 := time.NewTicker(delay) + n.timer = t1.C + n.cleanup = func() { + logger.Debug("Cleaning up ticker", "id", id) + delete(t.timer, id) + t1.Stop() + } + } else { + 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) + } +} + +var symTimers = sobek.NewSymbol(`Symbol.__timers__`) + +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 { + logger.Debug("Nothing function called") + return nil +} diff --git a/server/modules/url/url.go b/server/modules/url/url.go new file mode 100644 index 0000000..3093f9d --- /dev/null +++ b/server/modules/url/url.go @@ -0,0 +1,224 @@ +package url + +import ( + "net/url" + "strings" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/server/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/server/server.go b/server/server.go new file mode 100644 index 0000000..e8835df --- /dev/null +++ b/server/server.go @@ -0,0 +1,414 @@ +package server + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/grafana/sobek" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + // Import our new VM system + "github.com/mark3labs/codebench-mcp/internal/logger" + "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" + +type ModuleConfig struct { + EnabledModules []string + DisabledModules []string +} + +type JSHandler struct { + vmManager *vm.VMManager + config ModuleConfig + runningVMs []*vm.VM + vmMutex sync.Mutex +} + +func NewJSHandler() *JSHandler { + return NewJSHandlerWithConfig(ModuleConfig{ + EnabledModules: []string{"http", "fetch", "timers", "buffer", "kv", "crypto", "encoding", "url", "cache"}, + }) +} + +func NewJSHandlerWithConfig(config ModuleConfig) *JSHandler { + // Create VM manager with enabled modules + enabledModules := config.EnabledModules + if len(enabledModules) == 0 && len(config.DisabledModules) == 0 { + // Enable all modules by default if none specified + enabledModules = []string{"http", "fetch", "timers", "buffer", "kv", "crypto", "encoding", "url", "cache"} + } + + vmManager := vm.NewVMManager(enabledModules) + + // Register all available modules (except console which is handled per-execution) + 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()) + vmManager.RegisterModule(cache.NewCacheModule()) + + return &JSHandler{ + vmManager: vmManager, + config: config, + } +} + +func (h *JSHandler) handleExecuteJS( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + code, err := request.RequireString("code") + if err != nil { + 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('http/server')") || + strings.Contains(code, "require(\"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) + } +} + +func (h *JSHandler) handleServerCode(ctx context.Context, code string) (*mcp.CallToolResult, error) { + // Capture console output + var output strings.Builder + + // Channel to capture execution results + resultChan := make(chan string, 1) + errorChan := make(chan error, 1) + + // Run the server code in a goroutine that stays alive + go func() { + // Create VM with custom logger for console output + // 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) + errorChan <- err + return + } + + // 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) + consoleModule.Setup(vm.Runtime()) + + // Execute the JavaScript code + _, err = vm.RunString(code) + if err != nil { + logger.Error("Server execution error", "error", err) + 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 + } + + // Send initial output back + resultChan <- output.String() + + // Keep the goroutine and VM alive indefinitely for HTTP servers + // The VM will be cleaned up when the MCP server shuts down + select {} + }() + + // 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), + }, + }, + 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) { + // Capture console output + 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{ + Type: "text", + Text: fmt.Sprintf("Failed to create VM: %v", err), + }, + }, + IsError: true, + }, nil + } + defer vm.Close() + + // 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) + defer cancel() + + // Execute in a goroutine to respect timeout + resultChan := make(chan sobek.Value, 1) + errorChan := make(chan error, 1) + + go func() { + result, err := vm.RunString(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 timeout\n\nOutput:\n%s", output.String()), + }, + }, + IsError: true, + }, nil + 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()), + }, + }, + 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) + } + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("%s%s", output.String(), resultStr), + }, + }, + }, nil + } +} + +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"}, + }) +} + +func NewJSServerWithConfig(config ModuleConfig) (*server.MCPServer, error) { + h := NewJSHandlerWithConfig(config) + + s := server.NewMCPServer( + "codebench-mcp", + Version, + ) + + // Build detailed description with module information + description := buildToolDescription(h.getAvailableModules()) + + // Register the executeJS tool + s.AddTool(mcp.NewTool( + "executeJS", + mcp.WithDescription(description), + mcp.WithString("code", + 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) + + return s, nil +} + +func buildToolDescription(enabledModules []string) string { + var description strings.Builder + + 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") + + if len(enabledModules) == 0 { + description.WriteString("No modules are currently enabled. Only basic JavaScript execution is available.") + return description.String() + } + + description.WriteString("Available modules:\n") + + // Define module descriptions + moduleDescriptions := map[string]string{ + "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('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)", + "url": "URL parsing and URLSearchParams manipulation (available globally)", + } + + // Add enabled modules with descriptions + for _, module := range enabledModules { + if desc, exists := moduleDescriptions[module]; exists { + description.WriteString(fmt.Sprintf("• %s: %s\n", module, desc)) + } + } + + // Add usage examples + description.WriteString("\nExample usage (modern JavaScript with require()):\n") + description.WriteString("```javascript\n") + description.WriteString("// Basic JavaScript execution\n") + description.WriteString("const result = 2 + 3;\n") + description.WriteString("console.log('Result:', result);\n\n") + + // Create a set for faster lookup + enabledSet := make(map[string]bool) + for _, module := range enabledModules { + enabledSet[module] = true + } + + // Add examples only for enabled modules + if enabledSet["fetch"] { + description.WriteString("// Fetch API (available globally when enabled)\n") + description.WriteString("const response = await fetch('https://api.example.com/data');\n") + description.WriteString("const data = await response.json();\n") + description.WriteString("console.log(data);\n\n") + } + + if enabledSet["http"] { + description.WriteString("// HTTP server (require import - NOT import statement)\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") + description.WriteString("console.log('Server running at:', server.url);\n\n") + } + + if enabledSet["crypto"] { + description.WriteString("// Crypto operations (require import)\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") + } + + if enabledSet["timers"] { + description.WriteString("// Timers (available globally)\n") + description.WriteString("setTimeout(() => {\n") + description.WriteString(" console.log('Hello after 1 second');\n") + description.WriteString("}, 1000);\n\n") + } + + if enabledSet["buffer"] { + description.WriteString("// Buffer operations (available globally)\n") + description.WriteString("const buffer = Buffer.from('hello', 'utf8');\n") + description.WriteString("console.log(buffer.toString('base64'));\n\n") + } + + description.WriteString("```\n") + 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") + 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/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/server/vm/eventloop.go b/server/vm/eventloop.go new file mode 100644 index 0000000..1cc6978 --- /dev/null +++ b/server/vm/eventloop.go @@ -0,0 +1,193 @@ +package vm + +import ( + "sync" + + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" +) + +// 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 + pending uint // Count of pending async operations (timers, etc.) + 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.pending > 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 +} + +// 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__") + +// 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...) +} + +// 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) + 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/server/vm/loader.go b/server/vm/loader.go new file mode 100644 index 0000000..1272f52 --- /dev/null +++ b/server/vm/loader.go @@ -0,0 +1,116 @@ +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 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 +} + +// 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, 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")) + } + + 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) + + // 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) + } + + // 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, enabledModules map[string]bool) { + l.modules.Range(func(key, value any) bool { + module := value.(Module) + if globalModule, ok := module.(GlobalModule); ok { + // 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 + }) +} \ No newline at end of file diff --git a/server/vm/manager.go b/server/vm/manager.go new file mode 100644 index 0000000..2148ff3 --- /dev/null +++ b/server/vm/manager.go @@ -0,0 +1,151 @@ +package vm + +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 +func NewVMManager(enabledModules []string) *VMManager { + enabledMap := make(map[string]bool) + for _, module := range enabledModules { + enabledMap[module] = true + } + + 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() + + // 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}) + logger.Debug("VM symbol stored in runtime") + + // Setup global require function + m.loader.EnableRequire(rt, m.enabledModules) + 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, m.enabledModules) + logger.Debug("Global objects setup completed") + + logger.Debug("VM creation completed") + 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) + } + logger.Debug("Enabled modules", "modules", enabled) + 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 with event loop support +// 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) + return err + }) + return +} + +// 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() + + // 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/server/vm/module.go b/server/vm/module.go new file mode 100644 index 0000000..ebe7ca4 --- /dev/null +++ b/server/vm/module.go @@ -0,0 +1,60 @@ +package vm + +import ( + "github.com/grafana/sobek" + "github.com/mark3labs/codebench-mcp/internal/logger" +) + +// 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 { + 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 +} + +// 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'" + } + } +}