Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,30 @@ jobs:
with:
go-version-file: go.mod
- name: Test (race detector)
env:
# libSQL/Turso integration tests run only when these are set, and
# skip cleanly otherwise. Forks / external PRs don't get repository
# secrets, so the pipeline still passes for them.
WATCHWORD_TEST_TURSO_URL: ${{ secrets.WATCHWORD_TEST_TURSO_URL }}
WATCHWORD_TEST_TURSO_AUTH_TOKEN: ${{ secrets.WATCHWORD_TEST_TURSO_AUTH_TOKEN }}
run: go test -race -timeout 5m ./...

turso:
# Optional integration job — runs Turso tests in isolation. Uses
# `continue-on-error` so a flaky/unavailable test DB never blocks merges.
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Turso integration tests
env:
WATCHWORD_TEST_TURSO_URL: ${{ secrets.WATCHWORD_TEST_TURSO_URL }}
WATCHWORD_TEST_TURSO_AUTH_TOKEN: ${{ secrets.WATCHWORD_TEST_TURSO_AUTH_TOKEN }}
run: go test -v -timeout 5m -run TestLibSQL ./internal/repository/...

concurrency:
runs-on: ubuntu-latest
steps:
Expand Down
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

An [MCP](https://modelcontextprotocol.io/) server that lets AI assistants store and retrieve prompts, snippets, and arbitrary text keyed by short, human-readable code words. Think of it as a shared clipboard between you and your LLM — say "save this as *falcon*", then later "show me *falcon*".

Built in Go. Supports SQLite and PostgreSQL. Runs over stdio, SSE, or Streamable HTTP.
Built in Go. Supports SQLite, PostgreSQL, and libSQL/Turso. Runs over stdio, SSE, or Streamable HTTP.

<a href="https://turso.tech/"><img src="https://api.turso.tech/static/images/logo-turso-solo.svg" alt="Powered by Turso" height="32" /></a> &nbsp; **Now with libSQL/Turso support** — point Watchword at a hosted Turso database for SQLite-compatible storage at the edge. See [libSQL / Turso](#libsql--turso) below.

## Why

Expand All @@ -13,7 +15,7 @@ LLM conversations are ephemeral. Watchword gives your assistant persistent, name
- **10 MCP tools** for storing, retrieving, searching, listing, restoring, deleting entries, and file upload/download
- **S3 file storage** — upload/download files up to 1GB via presigned URLs (works with AWS S3 and Cloudflare R2)
- **Collision resolution** — if a keyword is taken, the server auto-appends a suffix (`rabbit` -> `rabbit2`)
- **SQLite or PostgreSQL** backends
- **SQLite, PostgreSQL, or libSQL/Turso** backends
- **Automatic expiration** — entries expire after a configurable TTL (or never, with `ttl_hours: 0`)
- **Bearer token and JWT/JWKS authentication** — with optional named tokens for service accounts
- **Per-entry `created_by` tracking** — populated from a JWT identity claim or a named static token, surfaced on read/list/search responses
Expand Down Expand Up @@ -143,9 +145,11 @@ All settings live in `config.yaml`. Every value can be overridden with environme

| Setting | Env var | Default | Description |
|---------|---------|---------|-------------|
| `database.driver` | `WORDSTORE_DATABASE_DRIVER` | `sqlite` | `sqlite` or `postgres` |
| `database.driver` | `WORDSTORE_DATABASE_DRIVER` | `sqlite` | `sqlite`, `postgres`, or `libsql` |
| `database.sqlite.path` | `WORDSTORE_DATABASE_SQLITE_PATH` | `./data/word-store.db` | SQLite file path |
| `database.postgres.dsn` | `WORDSTORE_DATABASE_POSTGRES_DSN` | | PostgreSQL connection string |
| `database.libsql.url` | `WORDSTORE_DATABASE_LIBSQL_URL` | | libSQL/Turso DB URL (e.g. `libsql://my-db-org.turso.io`) |
| `database.libsql.auth_token` | `WORDSTORE_DATABASE_LIBSQL_AUTH_TOKEN` | | Turso auth token (keep out of `config.yaml`; pass via env) |

#### SQLite concurrency

Expand Down Expand Up @@ -186,6 +190,44 @@ The directory in `path` is created on startup, and migrations run on first boot.

**3. Migrating data (optional)** — switching driver starts from an empty database. If you need to carry entries across, dump the `entries` table from PostgreSQL (`COPY entries TO STDOUT (FORMAT csv, HEADER)`) and load it into SQLite with `.import`; the schemas are equivalent, but PostgreSQL `timestamptz` columns must be converted to RFC3339 strings for SQLite during the dump.

#### libSQL / Turso

<a href="https://turso.tech/"><img src="https://api.turso.tech/static/images/logo-turso-solo.svg" alt="Turso" height="40" /></a>

[Turso](https://turso.tech/) is a managed libSQL service — SQLite-compatible, but accessed remotely over HTTP/WebSocket. Watchword talks to it through the pure-Go [`libsql-client-go`](https://github.com/tursodatabase/libsql-client-go) driver, so no CGO is required.

The same `migrations/sqlite/*.sql` migrations run unchanged; the schema and SQL are identical to a local SQLite file. The only behavioral differences vs. the local SQLite backend:

- **No client-side pragmas.** `journal_mode`, `busy_timeout`, `synchronous`, `foreign_keys` are server-controlled on Turso — the URI-pragma format used by the local backend isn't applied.
- **Deferred transactions.** Turso doesn't expose `_txlock=immediate`; collision resolution still relies on the `(word, status)` unique constraint as the source of truth.

**Config (env-driven, recommended for secrets):**

```bash
export WORDSTORE_DATABASE_DRIVER=libsql
export WORDSTORE_DATABASE_LIBSQL_URL='libsql://your-db-org.turso.io'
export WORDSTORE_DATABASE_LIBSQL_AUTH_TOKEN='<turso-auth-token>'
./watchword
```

Or in `config.yaml`:

```yaml
database:
driver: "libsql"
libsql:
url: "libsql://your-db-org.turso.io"
# auth_token: prefer WORDSTORE_DATABASE_LIBSQL_AUTH_TOKEN env to keep tokens out of source control
```

Get a URL + auth token via the Turso CLI:

```bash
turso db create watchword
turso db show watchword --url # → libsql://watchword-<org>.turso.io
turso db tokens create watchword # → eyJhbGciOi...
```

### Authentication

| Setting | Env var | Default | Description |
Expand Down
3 changes: 3 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@ func initRepo(ctx context.Context, cfg *config.Config, logger *slog.Logger) (rep
case "postgres":
logger.Info("initializing PostgreSQL")
return repository.NewPostgresRepo(ctx, cfg.Database.Postgres.DSN)
case "libsql":
logger.Info("initializing libSQL/Turso", "url", cfg.Database.LibSQL.URL)
return repository.NewLibSQLRepo(cfg.Database.LibSQL.URL, cfg.Database.LibSQL.AuthToken)
default:
return nil, fmt.Errorf("unsupported database driver: %s", cfg.Database.Driver)
}
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/mark3labs/mcp-go v0.45.0
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.34.5
)

require (
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
Expand All @@ -35,6 +37,7 @@ require (
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
Expand All @@ -48,6 +51,7 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOh
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
Expand Down Expand Up @@ -44,6 +46,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
Expand Down Expand Up @@ -95,12 +99,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
Expand Down
22 changes: 20 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ type DatabaseConfig struct {
Driver string `yaml:"driver"`
SQLite SQLiteConfig `yaml:"sqlite"`
Postgres PostgresConfig `yaml:"postgres"`
LibSQL LibSQLConfig `yaml:"libsql"`
}

type SQLiteConfig struct {
Expand All @@ -166,6 +167,14 @@ type PostgresConfig struct {
DSN string `yaml:"dsn"`
}

// LibSQLConfig points at a remote libSQL/Turso database. URL is the canonical
// connection URL (e.g. "libsql://my-db-org.turso.io"). AuthToken is the Turso
// auth token; usually injected via env in production.
type LibSQLConfig struct {
URL string `yaml:"url"`
AuthToken string `yaml:"auth_token"`
}

type AuthConfig struct {
Enabled bool `yaml:"enabled"`
Tokens []string `yaml:"tokens"`
Expand Down Expand Up @@ -287,6 +296,12 @@ func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("WORDSTORE_DATABASE_POSTGRES_DSN"); v != "" {
cfg.Database.Postgres.DSN = v
}
if v := os.Getenv("WORDSTORE_DATABASE_LIBSQL_URL"); v != "" {
cfg.Database.LibSQL.URL = v
}
if v := os.Getenv("WORDSTORE_DATABASE_LIBSQL_AUTH_TOKEN"); v != "" {
cfg.Database.LibSQL.AuthToken = v
}
if v := os.Getenv("WORDSTORE_AUTH_ENABLED"); v != "" {
cfg.Auth.Enabled = v == "true" || v == "1"
}
Expand Down Expand Up @@ -510,16 +525,19 @@ func validate(cfg *Config) error {
return fmt.Errorf("invalid server.transport: %q (must be stdio, sse, streamable-http, or http)", cfg.Server.Transport)
}
switch cfg.Database.Driver {
case "sqlite", "postgres":
case "sqlite", "postgres", "libsql":
default:
return fmt.Errorf("invalid database.driver: %q (must be sqlite or postgres)", cfg.Database.Driver)
return fmt.Errorf("invalid database.driver: %q (must be sqlite, postgres, or libsql)", cfg.Database.Driver)
}
if cfg.Database.Driver == "sqlite" && cfg.Database.SQLite.Path == "" {
return fmt.Errorf("database.sqlite.path is required when driver is sqlite")
}
if cfg.Database.Driver == "postgres" && cfg.Database.Postgres.DSN == "" {
return fmt.Errorf("database.postgres.dsn is required when driver is postgres")
}
if cfg.Database.Driver == "libsql" && cfg.Database.LibSQL.URL == "" {
return fmt.Errorf("database.libsql.url is required when driver is libsql")
}
if cfg.Auth.JWT != nil && cfg.Auth.JWT.JWKSURL == "" {
return fmt.Errorf("auth.jwt.jwks_url is required when jwt is configured")
}
Expand Down
73 changes: 73 additions & 0 deletions internal/repository/libsql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package repository

import (
"database/sql"
"fmt"
"net/url"
"strings"
"time"

_ "github.com/tursodatabase/libsql-client-go/libsql"
)

// NewLibSQLRepo opens a connection to a remote libSQL/Turso database.
//
// The libsql client driver registers itself under the name "libsql". The DSN
// is the database URL (typically "libsql://<db-name>-<org>.turso.io"); the
// auth token is appended as an `authToken=` query parameter.
//
// Unlike the local SQLite path, no `journal_mode`, `busy_timeout`, or
// `_txlock` URI pragmas are applied — those are either server-controlled on
// Turso or unsupported by this driver. Transactions begin as deferred; the
// `(word, status)` unique constraint remains the source of truth for
// collision resolution.
//
// The schema and SQL are SQLite-compatible, so we reuse SQLiteRepo for all
// query/scanning logic and the embedded `migrations/sqlite/*.sql` migrations.
func NewLibSQLRepo(dbURL, authToken string) (*SQLiteRepo, error) {
dsn, err := buildLibSQLDSN(dbURL, authToken)
if err != nil {
return nil, err
}

db, err := sql.Open("libsql", dsn)
if err != nil {
return nil, fmt.Errorf("opening libsql: %w", err)
}

// libSQL HTTP/WS connections are cheap to multiplex but each holds an
// outbound socket; cap the pool conservatively. Writes are serialized
// server-side regardless of pool depth.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(8)
db.SetConnMaxIdleTime(5 * time.Minute)

return &SQLiteRepo{db: db}, nil
}

// buildLibSQLDSN normalizes the URL and merges the auth token into the query
// string. Accepts libsql://, wss://, ws://, https://, http://, or file: URLs
// (the last is mostly useful for local libsql-server testing).
func buildLibSQLDSN(dbURL, authToken string) (string, error) {
if dbURL == "" {
return "", fmt.Errorf("libsql url is empty")
}

if strings.HasPrefix(dbURL, "file:") || dbURL == ":memory:" {
return dbURL, nil
}

u, err := url.Parse(dbURL)
if err != nil {
return "", fmt.Errorf("parsing libsql url: %w", err)
}

if authToken != "" {
q := u.Query()
if q.Get("authToken") == "" {
q.Set("authToken", authToken)
}
u.RawQuery = q.Encode()
}
return u.String(), nil
}
Loading
Loading