From 8f98d5bba137814409ac21fd5d834bc3b0e0e799 Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 21 Apr 2026 11:20:00 -0600 Subject: [PATCH] Implement SilverBullet MCP server --- AGENTS.md | 24 ++ cmd/sb-mcp/main.go | 27 +++ cmd/sb-mcp/serve.go | 31 +++ go.mod | 42 ++++ go.sum | 90 ++++++++ internal/config/config.go | 74 ++++++ internal/server/instructions.go | 8 + internal/server/server.go | 198 ++++++++++++++++ internal/server/silverbullet.md | 152 +++++++++++++ internal/server/tools.go | 17 ++ internal/silverbullet/client.go | 288 ++++++++++++++++++++++++ internal/silverbullet/client_test.go | 325 +++++++++++++++++++++++++++ 12 files changed, 1276 insertions(+) create mode 100644 AGENTS.md create mode 100644 cmd/sb-mcp/main.go create mode 100644 cmd/sb-mcp/serve.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/server/instructions.go create mode 100644 internal/server/server.go create mode 100644 internal/server/silverbullet.md create mode 100644 internal/server/tools.go create mode 100644 internal/silverbullet/client.go create mode 100644 internal/silverbullet/client_test.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..800e7bf0a54a1b91cebb9d4fb2c3edf9db2e4270 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# sb-mcp + +## Commands + +```sh +go build ./... # build all +go test ./... # test all +go test ./internal/silverbullet/ # test single package +``` + +Build with version: `go build -ldflags "-X git.secluded.site/sb-mcp/internal/server.Version=$VERSION" ./cmd/sb-mcp/` + +Run: `sb-mcp serve` (stdio) or `sb-mcp serve --http :3001` (streamable HTTP) + +## Landmines + +- **`print()` is silently swallowed** in the Runtime API. Always use `return` to send results back from `execute_lua`. No error, no warning — just empty output. +- **Space Lua is camelCase**, not snake_case. All built-in functions (`space.readPage`, `space.writePage`) use camelCase. Writing `space.read_page` will fail. +- **Bearer auth and password auth coexist** — `SB_TOKEN` sets `Authorization: Bearer ...`, while `SB_USER`/`SB_PASS` logs in via `POST /.auth` and sends a session `Cookie`. They use different headers, no override. For proxy auth (e.g. Exe.dev), use SB password authentication with Exe Bearer auth or SB Bearer auth with Exe HTTP Basic credentials embedded in `SB_URL` (`https://user:pass@host`). +- **Timeout clamping**: Lua timeouts <1 are clamped to 1, >21600 clamped to 21600. No error raised; the value silently changes. + +## Environment + +Required: `SB_URL` (must be http(s)). Optional: `SB_USER`/`SB_PASS` (basic auth), `SB_TOKEN` (bearer), `SB_TIMEOUT` (seconds, default 120). diff --git a/cmd/sb-mcp/main.go b/cmd/sb-mcp/main.go new file mode 100644 index 0000000000000000000000000000000000000000..2354573a00a9d97c4ccf999b7dea4c57984c7832 --- /dev/null +++ b/cmd/sb-mcp/main.go @@ -0,0 +1,27 @@ +// Package main is the entrypoint for the sb-mcp SilverBullet MCP server. +package main + +import ( + "context" + "os" + + "github.com/charmbracelet/fang" + "github.com/spf13/cobra" + + "git.secluded.site/sb-mcp/internal/server" +) + +func main() { + root := &cobra.Command{ + Use: "sb-mcp", + Short: "SilverBullet MCP Server", + Long: "A Model Context Protocol server for SilverBullet, providing Lua execution, screenshots, and console logs via the Runtime API.", + } + + root.AddCommand(serveCmd()) + + ctx := context.Background() + if err := fang.Execute(ctx, root, fang.WithVersion(server.Version), fang.WithNotifySignal(os.Interrupt, os.Kill)); err != nil { + os.Exit(1) + } +} diff --git a/cmd/sb-mcp/serve.go b/cmd/sb-mcp/serve.go new file mode 100644 index 0000000000000000000000000000000000000000..551c5b99ead35dff85c647abda47de0b2907ecca --- /dev/null +++ b/cmd/sb-mcp/serve.go @@ -0,0 +1,31 @@ +package main + +import ( + "github.com/spf13/cobra" + + "git.secluded.site/sb-mcp/internal/config" + "git.secluded.site/sb-mcp/internal/server" +) + +func serveCmd() *cobra.Command { + var httpAddr string + + cmd := &cobra.Command{ + Use: "serve", + Short: "Start the MCP server", + Long: "Start the SilverBullet MCP server. Uses stdio transport by default, or streamable HTTP with --http.", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + cfg.HTTPAddr = httpAddr + + return server.Run(cmd.Context(), cfg) + }, + } + + cmd.Flags().StringVar(&httpAddr, "http", "", "listen address for streamable HTTP transport (e.g. :3001); default is stdio") + + return cmd +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..fc1a299567e7e6624afb0f368b285065b1a3ebc0 --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module git.secluded.site/sb-mcp + +go 1.26.2 + +require ( + github.com/charmbracelet/fang v1.0.0 + github.com/modelcontextprotocol/go-sdk v1.5.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect + github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect + github.com/charmbracelet/x/ansi v0.11.0 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.4.1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..879613284413d53052641ba8ba44b5398e16d020 --- /dev/null +++ b/go.sum @@ -0,0 +1,90 @@ +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= +github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= +github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU= +github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= +github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..acff3b9f3632661b4504e86cb536343db891758b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,74 @@ +// Package config holds configuration for the SilverBullet MCP server, +// loaded from environment variables and command-line flags. +package config + +import ( + "fmt" + "net/url" + "os" + "strconv" +) + +// Config holds all configuration for the sb-mcp server. +type Config struct { + // SBURL is the base URL of the SilverBullet instance (required). + SBURL string + // SBUser and SBPass are Basic Auth credentials (optional). + SBUser string + SBPass string + // SBToken is a Bearer token for authentication (optional). + SBToken string + // HTTPAddr is the address for streamable HTTP transport. + // Empty string means stdio transport. + HTTPAddr string + // DefaultTimeout is the default Lua execution timeout in seconds. + DefaultTimeout int +} + +// Load reads configuration from environment variables. +// SB_URL (required), SB_USER, SB_PASS, SB_TOKEN (optional). +func Load() (*Config, error) { + sbURL := os.Getenv("SB_URL") + if sbURL == "" { + return nil, fmt.Errorf("SB_URL environment variable is required") + } + + u, err := url.Parse(sbURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return nil, fmt.Errorf("SB_URL must be a valid HTTP(S) URL, got: %s", sbURL) + } + + cfg := &Config{ + SBURL: sbURL, + SBUser: os.Getenv("SB_USER"), + SBPass: os.Getenv("SB_PASS"), + SBToken: os.Getenv("SB_TOKEN"), + DefaultTimeout: parseDefaultTimeout(), + } + + return cfg, nil +} + +// HasBasicAuth returns true if both SB_USER and SB_PASS are set. +func (c *Config) HasBasicAuth() bool { + return c.SBUser != "" && c.SBPass != "" +} + +// HasBearerToken returns true if SB_TOKEN is set. +func (c *Config) HasBearerToken() bool { + return c.SBToken != "" +} + +// parseDefaultTimeout reads the default Lua execution timeout from +// the SB_TIMEOUT environment variable (in seconds), defaulting to 120. +func parseDefaultTimeout() int { + s := os.Getenv("SB_TIMEOUT") + if s == "" { + return 120 + } + n, err := strconv.Atoi(s) + if err != nil || n < 1 { + return 120 + } + return n +} diff --git a/internal/server/instructions.go b/internal/server/instructions.go new file mode 100644 index 0000000000000000000000000000000000000000..91dec7874949b5d4bf61d7298a3b1a2ce17ec2a6 --- /dev/null +++ b/internal/server/instructions.go @@ -0,0 +1,8 @@ +package server + +import _ "embed" + +// Instructions contains the embedded SilverBullet guidance for MCP clients. +// +//go:embed silverbullet.md +var Instructions string diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000000000000000000000000000000000000..f91e09dfbeed3a2beab436f90c7c4646c8682ea1 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,198 @@ +// Package server wires up the MCP server with tools and transport selection. +package server + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.secluded.site/sb-mcp/internal/config" + "git.secluded.site/sb-mcp/internal/silverbullet" +) + +// Version is set at build time via ldflags. +var Version = "dev" + +// New creates a fully-wired MCP server with all tools registered. +func New(cfg *config.Config) *mcp.Server { + sbClient := silverbullet.New(cfg.SBURL, silverbullet.Auth{ + User: cfg.SBUser, + Pass: cfg.SBPass, + Token: cfg.SBToken, + }) + defaultTimeout := cfg.DefaultTimeout + if defaultTimeout == 0 { + defaultTimeout = 120 + } + + server := mcp.NewServer(&mcp.Implementation{ + Name: "sb-mcp", + Version: Version, + }, &mcp.ServerOptions{ + Instructions: Instructions, + }) + + // Register tools with the SB client closed over + mcp.AddTool(server, &mcp.Tool{ + Name: "execute_lua", + Description: "Execute a Space Lua script on the SilverBullet instance. Use 'return' to send results back. 'print()' output is not captured.", + Annotations: &mcp.ToolAnnotations{ + Title: "Execute Lua", + ReadOnlyHint: false, + DestructiveHint: ptrBool(true), + IdempotentHint: false, + OpenWorldHint: ptrBool(false), + }, + }, makeExecuteLuaHandler(sbClient, defaultTimeout)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "screenshot", + Description: "Capture the current SilverBullet viewport as a PNG image.", + Annotations: &mcp.ToolAnnotations{ + Title: "Screenshot", + ReadOnlyHint: true, + IdempotentHint: false, + OpenWorldHint: ptrBool(false), + }, + }, makeScreenshotHandler(sbClient)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "console_logs", + Description: "Retrieve recent console log entries from SilverBullet for debugging.", + Annotations: &mcp.ToolAnnotations{ + Title: "Console Logs", + ReadOnlyHint: true, + IdempotentHint: false, + OpenWorldHint: ptrBool(false), + }, + }, makeConsoleLogsHandler(sbClient)) + + return server +} + +// Run starts the MCP server with the appropriate transport. +// If cfg.HTTPAddr is set, uses streamable HTTP; otherwise uses stdio. +func Run(ctx context.Context, cfg *config.Config) error { + server := New(cfg) + + if cfg.HTTPAddr != "" { + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, nil) + log.Printf("sb-mcp: MCP server listening on %s", cfg.HTTPAddr) + return http.ListenAndServe(cfg.HTTPAddr, handler) + } + + // Stdio transport with logging to stderr + t := &mcp.LoggingTransport{ + Transport: &mcp.StdioTransport{}, + Writer: os.Stderr, + } + log.Printf("sb-mcp: starting stdio transport") + return server.Run(ctx, t) +} + +// ptrBool returns a pointer to the given bool value. +func ptrBool(b bool) *bool { + return &b +} + +// makeExecuteLuaHandler returns a tool handler that closes over the SB client. +func makeExecuteLuaHandler(client *silverbullet.Client, defaultTimeout int) func(context.Context, *mcp.CallToolRequest, ExecuteLuaParams) (*mcp.CallToolResult, any, error) { + return func(ctx context.Context, req *mcp.CallToolRequest, params ExecuteLuaParams) (*mcp.CallToolResult, any, error) { + timeout := params.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + + result, err := client.ExecuteLua(ctx, params.Script, timeout) + if err != nil { + return nil, nil, fmt.Errorf("executing lua: %w", err) + } + + if result.Error != "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Lua error: %s", result.Error)}, + }, + IsError: true, + }, nil, nil + } + + formatted := formatLuaResult(result.Result) + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: formatted}, + }, + }, nil, nil + } +} + +// makeScreenshotHandler returns a tool handler that closes over the SB client. +func makeScreenshotHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ScreenshotParams) (*mcp.CallToolResult, any, error) { + return func(ctx context.Context, req *mcp.CallToolRequest, params ScreenshotParams) (*mcp.CallToolResult, any, error) { + data, err := client.Screenshot(ctx) + if err != nil { + return nil, nil, fmt.Errorf("fetching screenshot: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.ImageContent{ + Data: data, + MIMEType: "image/png", + }, + }, + }, nil, nil + } +} + +// makeConsoleLogsHandler returns a tool handler that closes over the SB client. +func makeConsoleLogsHandler(client *silverbullet.Client) func(context.Context, *mcp.CallToolRequest, ConsoleLogsParams) (*mcp.CallToolResult, any, error) { + return func(ctx context.Context, req *mcp.CallToolRequest, params ConsoleLogsParams) (*mcp.CallToolResult, any, error) { + limit := params.Limit + if limit == 0 { + limit = 100 + } + + result, err := client.ConsoleLogs(ctx, limit, params.Since) + if err != nil { + return nil, nil, fmt.Errorf("fetching logs: %w", err) + } + + logsJSON, err := json.MarshalIndent(result.Logs, "", " ") + if err != nil { + return nil, nil, fmt.Errorf("formatting logs: %w", err) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(logsJSON)}, + }, + }, nil, nil + } +} + +// formatLuaResult formats a JSON result from Lua execution for display. +// JSON strings are unescaped to plain text (so markdown content reads cleanly). +// All other values (numbers, bools, null, objects, arrays) pass through as raw JSON. +func formatLuaResult(raw json.RawMessage) string { + if raw == nil { + return "null" + } + + var v any + if json.Unmarshal(raw, &v) == nil { + if s, ok := v.(string); ok { + return s + } + } + + return string(raw) +} diff --git a/internal/server/silverbullet.md b/internal/server/silverbullet.md new file mode 100644 index 0000000000000000000000000000000000000000..09f1a4b23bf8c8c887cdc4d83e35088d8aa6f4ae --- /dev/null +++ b/internal/server/silverbullet.md @@ -0,0 +1,152 @@ +# SilverBullet MCP Server Instructions + +You are connected to a SilverBullet instance via its Runtime API. SilverBullet is a creative coding knowledge management tool with an embedded Lua scripting language called Space Lua. + +## Available Tools + +- **execute_lua**: Run Space Lua scripts. Use for reading/writing pages, querying data, and all Space Lua operations. +- **screenshot**: Capture current viewport as a PNG image. +- **console_logs**: Retrieve recent console log entries. Useful for debugging. + +## Space Lua Essentials + +Space Lua is a custom Lua implementation embedded in SilverBullet. Key differences from standard Lua: + +- **camelCase** for all variables and functions (not snake_case) +- No coroutines (not supported) +- No `_ENV` (planned, not yet available) +- `print()` output is swallowed in Runtime API — use `return` to send results back +- `query[[...]]` is LIQ (Lua Integrated Query), a SQL/LINQ-inspired query syntax embedded in Lua + +## Page Operations + +```lua +-- Read a page +local content = space.readPage("MyPage") + +-- Write a page +space.writePage("MyPage", "# Hello\n\nContent here") + +-- Delete a page +space.deletePage("MyPage") + +-- Check if a page exists +if space.pageExists("MyPage") then + -- ... +end + +-- List all pages +local pages = space.listPages() + +-- Get page metadata +local meta = space.getPageMeta("MyPage") +-- meta.name, meta.lastModified +``` + +## Querying with LIQ + +LIQ (`query[[...]]`) is the primary way to search and filter data: + +```lua +-- Find pages tagged #task +local tasks = query[[ + from p = index.tag "task" + select p.name +]] + +-- Filter by frontmatter +local projects = query[[ + from p = index.tag "page" + where p.status == "active" + order by p.lastModified desc + select p.name + limit 10 +]] + +-- Count items in a group +local counts = query[[ + from p = index.tag "tag" + group by p.name + select { name = name, count = #group } +]] +``` + +## File/Document Operations + +```lua +-- List all files +local files = space.listFiles() + +-- Read a file (returns bytes as string) +local data = space.readFile("image.png") + +-- Write a file +space.writeFile("data.json", jsonString) + +-- Read a specific section +local section = space.readRef("MyPage#Introduction") +``` + +## Common Patterns + +### Working with Frontmatter + +SilverBullet pages can have YAML frontmatter. Query against indexed tags and frontmatter fields: + +```lua +local pages = query[[ + from p = index.tag "page" + where p.priority == "high" + select { name = p.name, priority = p.priority } +]] +``` + +### Working with Tasks + +```lua +local tasks = query[[ + from t = index.tag "task" + where t.state == "todo" + order by t.lastModified desc + select { name = t.name, page = t.page } +]] +``` + +### Links and References + +```lua +-- Find pages that link to a specific page +local links = query[[ + from l = index.tag "link" + where l.toPage == "TargetPage" + select l.fromPage +]] +``` + +### Combining Lua and LIQ + +```lua +-- Multi-step workflow +local pages = query[[ + from p = index.tag "page" + where p.name:startsWith("Project/") + select p.name +]] + +for _, p in ipairs(pages) do + local content = space.readPage(p.name) + -- process content + space.writePage(p.name, updatedContent) +end + +return pages +``` + +## Important Notes + +- Always `return` values from lua_script — `print()` output is not captured +- The `X-Timeout` header controls execution timeout (1-21600 seconds, default 120) +- Use `space.readRef("Page#Header")` to read specific sections +- Use explicit variable binding in LIQ: `from p = ...` (not bare `from ...`) +- Namespace your functions: `myPlugin = myPlugin or {}; function myPlugin.doThing() ... end` +- Use `-- priority: N` comments to control script load order (higher = earlier) \ No newline at end of file diff --git a/internal/server/tools.go b/internal/server/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..80520e59d44f65a0c915aaff69d8778d114d91b0 --- /dev/null +++ b/internal/server/tools.go @@ -0,0 +1,17 @@ +// Package server defines the MCP tool parameter types for the SilverBullet server. +package server + +// ExecuteLuaParams defines parameters for the execute_lua tool. +type ExecuteLuaParams struct { + Script string `json:"script" jsonschema:"The Space Lua script to execute. Use 'return' to send results back."` + Timeout int `json:"timeout,omitempty" jsonschema:"Maximum execution time in seconds (1-21600,default=120)"` +} + +// ScreenshotParams defines parameters for the screenshot tool. +type ScreenshotParams struct{} + +// ConsoleLogsParams defines parameters for the console_logs tool. +type ConsoleLogsParams struct { + Limit int `json:"limit,omitempty" jsonschema:"Maximum number of log entries to return (1-1000,default=100)"` + Since int64 `json:"since,omitempty" jsonschema:"Only return entries newer than this unix millisecond timestamp (optional)"` +} diff --git a/internal/silverbullet/client.go b/internal/silverbullet/client.go new file mode 100644 index 0000000000000000000000000000000000000000..3f16c3bdb561cfbe741319d4a6bea3e812514f3c --- /dev/null +++ b/internal/silverbullet/client.go @@ -0,0 +1,288 @@ +// Package silverbullet provides an HTTP client for the SilverBullet Runtime API. +package silverbullet + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Auth holds authentication credentials for the SilverBullet API. +type Auth struct { + // Bearer token for Authorization header. Set via SB_TOKEN. + Token string + // Username and password for cookie-based session auth. + // Logs in via POST /.auth and sends session cookie on subsequent requests. + User string + Pass string +} + +// Client is an HTTP client for the SilverBullet Runtime API. +type Client struct { + baseURL string + httpClient *http.Client + auth Auth + // cached session from password login + cachedCookieName string + cachedCookieVal string +} + +// New creates a new SilverBullet client. +// The baseURL is normalized to remove trailing slashes. +func New(baseURL string, auth Auth) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{ + Timeout: 6 * time.Hour, // long enough for lua_script with X-Timeout up to 21600s + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + auth: auth, + } +} + +// LuaResult holds the result from the Lua endpoints. +type LuaResult struct { + Result json.RawMessage `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// LogEntry represents a single console log entry. +type LogEntry struct { + Level string `json:"level"` + Text string `json:"text"` + Timestamp int64 `json:"timestamp"` +} + +// LogsResult holds the result from the logs endpoint. +type LogsResult struct { + Logs []LogEntry `json:"logs"` +} + +// ExecuteLua sends a Lua script to the /.runtime/lua_script endpoint. +func (c *Client) ExecuteLua(ctx context.Context, script string, timeout int) (*LuaResult, error) { + if timeout < 1 { + timeout = 1 + } + if timeout > 21600 { + timeout = 21600 + } + + endpoint, err := c.resolveURL("/.runtime/lua_script") + if err != nil { + return nil, fmt.Errorf("resolving URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(script)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("X-Timeout", strconv.Itoa(timeout)) + if err := c.setAuth(req); err != nil { + return nil, fmt.Errorf("setting auth: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing lua: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var luaErr LuaResult + if json.Unmarshal(body, &luaErr) == nil && luaErr.Error != "" { + return &luaErr, nil + } + return nil, fmt.Errorf("lua endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var result LuaResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parsing lua response: %w", err) + } + + return &result, nil +} + +// Screenshot fetches the current viewport screenshot as PNG bytes. +func (c *Client) Screenshot(ctx context.Context) ([]byte, error) { + endpoint, err := c.resolveURL("/.runtime/screenshot") + if err != nil { + return nil, fmt.Errorf("resolving URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + if err := c.setAuth(req); err != nil { + return nil, fmt.Errorf("setting auth: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching screenshot: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("screenshot endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading screenshot data: %w", err) + } + + return data, nil +} + +// ConsoleLogs fetches recent console log entries. +// limit caps the number of entries (max 1000). +// since filters to entries newer than the given unix millisecond timestamp (0 = no filter). +func (c *Client) ConsoleLogs(ctx context.Context, limit int, since int64) (*LogsResult, error) { + if limit < 1 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + + endpoint, err := c.resolveURL("/.runtime/logs") + if err != nil { + return nil, fmt.Errorf("resolving URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + if err := c.setAuth(req); err != nil { + return nil, fmt.Errorf("setting auth: %w", err) + } + + q := req.URL.Query() + q.Set("limit", strconv.Itoa(limit)) + if since > 0 { + q.Set("since", strconv.FormatInt(since, 10)) + } + req.URL.RawQuery = q.Encode() + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching logs: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading logs response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("logs endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var result LogsResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parsing logs response: %w", err) + } + + return &result, nil +} + +// resolveURL joins the base URL with a path, ensuring no double slashes. +func (c *Client) resolveURL(path string) (string, error) { + return url.JoinPath(c.baseURL, path) +} + +// setAuth sets authentication headers on the request. +// Bearer token goes on the Authorization header. +// Password auth logs in via POST /.auth and sends the session cookie. +// Both can coexist — they use different headers. +func (c *Client) setAuth(req *http.Request) error { + // Bearer token on Authorization header + if c.auth.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.auth.Token) + } + + // Password auth via session cookie + if c.auth.User != "" && c.auth.Pass != "" { + if err := c.ensureSessionCookie(req); err != nil { + return fmt.Errorf("session login: %w", err) + } + } + + return nil +} + +// ensureSessionCookie logs in via POST /.auth if needed and sets the session cookie. +func (c *Client) ensureSessionCookie(req *http.Request) error { + // Use cached session if available + if c.cachedCookieName != "" && c.cachedCookieVal != "" { + req.AddCookie(&http.Cookie{ + Name: c.cachedCookieName, + Value: c.cachedCookieVal, + }) + return nil + } + + // Log in to get session cookie + form := url.Values{} + form.Set("username", c.auth.User) + form.Set("password", c.auth.Pass) + + loginURL, err := c.resolveURL("/.auth") + if err != nil { + return fmt.Errorf("resolving login URL: %w", err) + } + + loginReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, loginURL, strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("creating login request: %w", err) + } + loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + loginResp, err := c.httpClient.Do(loginReq) + if err != nil { + return fmt.Errorf("login request failed: %w", err) + } + defer loginResp.Body.Close() + + if loginResp.StatusCode != http.StatusOK { + return fmt.Errorf("login failed with status %d", loginResp.StatusCode) + } + + // Extract session cookie from Set-Cookie header + for _, cookie := range loginResp.Cookies() { + if strings.HasPrefix(cookie.Name, "auth_") { + c.cachedCookieName = cookie.Name + c.cachedCookieVal = cookie.Value + req.AddCookie(&http.Cookie{ + Name: cookie.Name, + Value: cookie.Value, + }) + return nil + } + } + + return fmt.Errorf("login succeeded but no auth cookie returned") +} diff --git a/internal/silverbullet/client_test.go b/internal/silverbullet/client_test.go new file mode 100644 index 0000000000000000000000000000000000000000..76e3e8de5a5c226b2ad142d893d1a0a3741ac42c --- /dev/null +++ b/internal/silverbullet/client_test.go @@ -0,0 +1,325 @@ +package silverbullet + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func newTestServer(mux *http.ServeMux) *httptest.Server { + return httptest.NewServer(mux) +} + +func testClient(url string) *Client { + return New(url, Auth{}) +} + +func testClientWithBasicAuth(url string) *Client { + return New(url, Auth{User: "testuser", Pass: "testpass"}) +} + +func setupAuthServer(mux *http.ServeMux) { + mux.HandleFunc("/.auth", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + username := r.FormValue("username") + password := r.FormValue("password") + if username != "testuser" || password != "testpass" { + w.WriteHeader(http.StatusUnauthorized) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "auth_session", + Value: "mock-jwt-token", + }) + w.WriteHeader(http.StatusOK) + }) +} + +func testClientWithBearerToken(url string) *Client { + return New(url, Auth{Token: "testtoken123"}) +} + +func TestExecuteLua(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "text/plain" { + t.Errorf("expected Content-Type text/plain, got %s", r.Header.Get("Content-Type")) + } + + result := LuaResult{Result: json.RawMessage(`2`)} + json.NewEncoder(w).Encode(result) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClient(srv.URL) + result, err := client.ExecuteLua(context.Background(), "1 + 1", 30) + if err != nil { + t.Fatalf("ExecuteLua failed: %v", err) + } + + if result.Result == nil { + t.Fatal("expected result, got nil") + } +} + +func TestExecuteLuaError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{"error":"attempt to call nil value"}`) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClient(srv.URL) + result, err := client.ExecuteLua(context.Background(), "bad()", 30) + if err != nil { + t.Fatalf("ExecuteLua returned unexpected error: %v", err) + } + + if result.Error == "" { + t.Fatal("expected error in result, got empty string") + } +} + +func TestExecuteLuaTimeoutHeader(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) { + timeout := r.Header.Get("X-Timeout") + if timeout != "60" { + t.Errorf("expected X-Timeout 60, got %s", timeout) + } + result := LuaResult{Result: json.RawMessage(`"ok"`)} + json.NewEncoder(w).Encode(result) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClient(srv.URL) + _, err := client.ExecuteLua(context.Background(), "return 'ok'", 60) + if err != nil { + t.Fatalf("ExecuteLua failed: %v", err) + } +} + +func TestExecuteLuaTimeoutClamp(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) { + // Timeout should be clamped to 21600 even if caller passes 50000 + timeout := r.Header.Get("X-Timeout") + if timeout != "21600" { + t.Errorf("expected X-Timeout 21600, got %s", timeout) + } + result := LuaResult{Result: json.RawMessage(`"ok"`)} + json.NewEncoder(w).Encode(result) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClient(srv.URL) + _, err := client.ExecuteLua(context.Background(), "return 'ok'", 50000) + if err != nil { + t.Fatalf("ExecuteLua failed: %v", err) + } +} + +func TestScreenshot(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/.runtime/screenshot", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "image/png") + w.Write([]byte("fake-png-data")) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClient(srv.URL) + data, err := client.Screenshot(context.Background()) + if err != nil { + t.Fatalf("Screenshot failed: %v", err) + } + + if string(data) != "fake-png-data" { + t.Errorf("expected fake-png-data, got %s", string(data)) + } +} + +func TestConsoleLogs(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/.runtime/logs", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + limit := r.URL.Query().Get("limit") + if limit != "5" { + t.Errorf("expected limit=5, got %s", limit) + } + since := r.URL.Query().Get("since") + if since != "1000" { + t.Errorf("expected since=1000, got %s", since) + } + + logs := LogsResult{ + Logs: []LogEntry{ + {Level: "log", Text: "Booting", Timestamp: 1710000000000}, + {Level: "info", Text: "Ready", Timestamp: 1710000000050}, + }, + } + json.NewEncoder(w).Encode(logs) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClient(srv.URL) + result, err := client.ConsoleLogs(context.Background(), 5, 1000) + if err != nil { + t.Fatalf("ConsoleLogs failed: %v", err) + } + + if len(result.Logs) != 2 { + t.Fatalf("expected 2 log entries, got %d", len(result.Logs)) + } + + if result.Logs[0].Text != "Booting" { + t.Errorf("expected first log text 'Booting', got %s", result.Logs[0].Text) + } +} + +func TestConsoleLogsDefaultLimit(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/.runtime/logs", func(w http.ResponseWriter, r *http.Request) { + limit := r.URL.Query().Get("limit") + if limit != "100" { + t.Errorf("expected default limit=100, got %s", limit) + } + logs := LogsResult{Logs: []LogEntry{}} + json.NewEncoder(w).Encode(logs) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClient(srv.URL) + _, err := client.ConsoleLogs(context.Background(), 0, 0) + if err != nil { + t.Fatalf("ConsoleLogs failed: %v", err) + } +} + +func TestBasicAuth(t *testing.T) { + mux := http.NewServeMux() + setupAuthServer(mux) + mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) { + // Verify session cookie is present + cookie, err := r.Cookie("auth_session") + if err != nil { + t.Error("expected auth_session cookie, got none") + } + if cookie != nil && cookie.Value != "mock-jwt-token" { + t.Errorf("expected cookie value 'mock-jwt-token', got %s", cookie.Value) + } + + if r.Header.Get("X-Timeout") == "" { + t.Error("expected X-Timeout header to be set") + } + + result := LuaResult{Result: json.RawMessage(`"ok"`)} + json.NewEncoder(w).Encode(result) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClientWithBasicAuth(srv.URL) + _, err := client.ExecuteLua(context.Background(), "return 'ok'", 30) + if err != nil { + t.Fatalf("ExecuteLua with basic auth failed: %v", err) + } +} + +func TestBearerToken(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer testtoken123" { + t.Errorf("expected Bearer token, got %s", auth) + } + result := LuaResult{Result: json.RawMessage(`"ok"`)} + json.NewEncoder(w).Encode(result) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := testClientWithBearerToken(srv.URL) + _, err := client.ExecuteLua(context.Background(), "return 'ok'", 30) + if err != nil { + t.Fatalf("ExecuteLua with bearer token failed: %v", err) + } +} + +func TestBothAuth(t *testing.T) { + mux := http.NewServeMux() + setupAuthServer(mux) + mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) { + // Bearer token on Authorization header + auth := r.Header.Get("Authorization") + if auth != "Bearer testtoken123" { + t.Errorf("expected Bearer token on Authorization, got %s", auth) + } + // Session cookie from /.auth login + cookie, err := r.Cookie("auth_session") + if err != nil { + t.Error("expected auth_session cookie, got none") + } + if cookie != nil && cookie.Value != "mock-jwt-token" { + t.Errorf("expected cookie value 'mock-jwt-token', got %s", cookie.Value) + } + result := LuaResult{Result: json.RawMessage(`"ok"`)} + json.NewEncoder(w).Encode(result) + }) + + srv := newTestServer(mux) + defer srv.Close() + + client := New(srv.URL, Auth{User: "testuser", Pass: "testpass", Token: "testtoken123"}) + _, err := client.ExecuteLua(context.Background(), "return 'ok'", 30) + if err != nil { + t.Fatalf("ExecuteLua with both auth types failed: %v", err) + } +} + +func TestTrailingSlashURL(t *testing.T) { + // Verify that trailing slashes in the base URL are handled correctly + client := New("http://localhost:3000/", Auth{}) + if client.baseURL != "http://localhost:3000" { + t.Errorf("expected trailing slash stripped, got %s", client.baseURL) + } + + endpoint, err := client.resolveURL("/.runtime/lua_script") + if err != nil { + t.Fatalf("resolveURL failed: %v", err) + } + if endpoint != "http://localhost:3000/.runtime/lua_script" { + t.Errorf("expected no double slash, got %s", endpoint) + } +}