Implement SilverBullet MCP server

Amolith created

Change summary

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, 1,276 insertions(+)

Detailed changes

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).

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)
+	}
+}

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
+}

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
+)

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=

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
+}

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

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)
+}

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)

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)"`
+}

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")
+}

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)
+	}
+}