Detailed changes
@@ -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).
@@ -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)
+ }
+}
@@ -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
+}
@@ -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
+)
@@ -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=
@@ -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
+}
@@ -0,0 +1,8 @@
+package server
+
+import _ "embed"
+
+// Instructions contains the embedded SilverBullet guidance for MCP clients.
+//
+//go:embed silverbullet.md
+var Instructions string
@@ -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)
+}
@@ -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)
@@ -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)"`
+}
@@ -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")
+}
@@ -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)
+ }
+}