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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: [main]
branches: ['**']
pull_request:
branches: [main]

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=auto
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ docker compose up -d # Run with PostgreSQL + Streamable HTTP (via supergatewa
- **entry_type column** — `text` (default) or `file`; file entries store JSON metadata in payload field
- **Streamable HTTP via supergateway** — Docker image wraps the stdio binary with [supergateway](https://github.com/supercorp-ai/supergateway) to expose Streamable HTTP on `/mcp`. Uses `--stateful` mode to avoid the child-process OOM leak ([PR #111](https://github.com/supercorp-ai/supergateway/pull/111)). Built from the PR #111 branch for the stateless leak fix as well.

## Commit Guidelines

- Never add Claude as a co-author in commit messages (no `Co-Authored-By: Claude` lines).

## MCP Library

Uses `github.com/mark3labs/mcp-go` for MCP protocol support (stdio + SSE transports).
Expand Down
93 changes: 89 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ LLM conversations are ephemeral. Watchword gives your assistant persistent, name
- **Collision resolution** — if a keyword is taken, the server auto-appends a suffix (`rabbit` -> `rabbit2`)
- **SQLite or PostgreSQL** backends
- **Automatic expiration** — entries expire after a configurable TTL (or never, with `ttl_hours: 0`)
- **Bearer token and JWT/JWKS authentication**
- **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
- **Health endpoints** for Kubernetes liveness/readiness probes
- **Customizable tool descriptions** — tune the prompts your LLM sees via `config.yaml`
- **Transports**: stdio, SSE, Streamable HTTP, or combined HTTP mode
Expand Down Expand Up @@ -151,16 +152,19 @@ All settings live in `config.yaml`. Every value can be overridden with environme
| Setting | Env var | Default | Description |
|---------|---------|---------|-------------|
| `auth.enabled` | `WORDSTORE_AUTH_ENABLED` | `true` | Enable/disable authentication |
| `auth.tokens` | `WORDSTORE_AUTH_TOKENS` | | Comma-separated bearer tokens |
| `auth.tokens` | `WORDSTORE_AUTH_TOKENS` | | Comma-separated bearer tokens (anonymous — `created_by` left null) |
| `auth.named_tokens` | | | List of `{name, token}` pairs. Requests using a named token record `created_by = name`. See [Tracking who created an entry](#tracking-who-created-an-entry). |
| | `WORDSTORE_AUTH_TOKEN` | | Token for stdio transport validation |

#### JWT / JWKS

| Setting | Env var | Default | Description |
|---------|---------|---------|-------------|
| `auth.jwt.jwks_url` | `WORDSTORE_AUTH_JWT_JWKS_URL` | | JWKS endpoint for public key discovery (required when `jwt` block is present) |
| `auth.jwt.issuer` | `WORDSTORE_AUTH_JWT_ISSUER` | | Expected `iss` claim |
| `auth.jwt.issuer` | `WORDSTORE_AUTH_JWT_ISSUER` | | Expected `iss` claim (exact match — Auth0 emits a trailing slash, Keycloak does not) |
| `auth.jwt.audience` | `WORDSTORE_AUTH_JWT_AUDIENCE` | | Expected `aud` claim (recommended for OAuth — RFC 8707) |
| `auth.jwt.required_scopes` | `WORDSTORE_AUTH_JWT_REQUIRED_SCOPES` | | Comma-separated scopes that must all be present; checked against the `scope` claim (space-delimited string) and `scp` claim (array). If unset, signature + iss + aud is enough. |
| `auth.jwt.identity_claim` | `WORDSTORE_AUTH_JWT_IDENTITY_CLAIM` | `sub` | Claim used to populate `created_by` on stored entries. Common alternatives: `email`, `preferred_username`. |

#### Protected Resource Metadata (RFC 9728)

Expand Down Expand Up @@ -220,15 +224,96 @@ Register these redirect URIs in your authorization server for Claude:
- `https://claude.com/api/mcp/auth_callback`
- `http://localhost:6274/oauth/callback` (Claude Code)

#### Provider-specific notes

Watchword validates one IdP at a time. Pick Keycloak **or** Auth0 (or a Cloudflare Workers OAuth provider); ready-made configs live in [`examples/keycloak.yaml`](examples/keycloak.yaml) and [`examples/auth0.yaml`](examples/auth0.yaml).

**Keycloak**

- `jwks_url`: `{base}/realms/{realm}/protocol/openid-connect/certs`
- `issuer`: `{base}/realms/{realm}` (no trailing slash)
- `audience`: by default Keycloak puts `account` in `aud`. Treating that as a valid audience accepts any realm user — it does not bind tokens to this API. Create an *Audience* client scope that emits a custom value (e.g. `watchword`), assign it to your client, and set `audience` to that value.
- Scopes: define client scopes (e.g. `watchword:read`, `watchword:write`) and require them with `required_scopes`. Keycloak sends them in the `scope` claim.

**Auth0**

- `jwks_url`: `https://{tenant}.auth0.com/.well-known/jwks.json`
- `issuer`: `https://{tenant}.auth0.com/` (trailing slash — Auth0 emits it and `iss` is matched exactly)
- `audience`: the API Identifier from the Auth0 API (e.g. `https://watchword.example.com/api`)
- Scopes: define them on the Auth0 API and grant to the app/user; Auth0 sends them in the `scope` claim.

**Cloudflare**

Two distinct Cloudflare products show up here — they behave differently:

- **Cloudflare Workers OAuth Provider** (the pattern in [`docs/cloudflare-worker-oauth-proxy.md`](docs/cloudflare-worker-oauth-proxy.md)) — fits Watchword's bearer-token flow. The Worker issues its own JWTs; `aud` is whatever the Worker stamps. Set `audience` to that value, point `jwks_url`/`issuer` at the Worker's well-known endpoints, and treat it the same as Auth0/Keycloak.
- **Cloudflare Access** (zero-trust app gating) — does *not* fit cleanly. Access delivers its JWT in the `Cf-Access-Jwt-Assertion` header (and cookie), not `Authorization: Bearer`. `aud` is the per-application *Application AUD* tag (a hex string from the Access dashboard); JWKS is at `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs` and issuer is `https://<team>.cloudflareaccess.com`. Best used as a front door layered in front of normal MCP auth, not as the MCP auth itself.

#### How `aud` is shaped per provider

`aud` is enforced via `jwt.WithAudience` (exact match), which accepts either a string or an array — only one entry has to match. Per-provider gotchas:

| Provider | Where `aud` comes from | Shape | What to put in `audience` |
|----------|-----------------------|-------|----------------------------|
| Keycloak | Audience protocol mapper on a client scope | string or array (often includes `"account"`) | The custom value emitted by your Audience mapper (e.g. `"watchword"`). Don't set this to `"account"` — that accepts every realm user. |
| Auth0 | The `audience` query param sent to `/authorize` | array, typically `[<API Identifier>, "https://{tenant}.auth0.com/userinfo"]` | The API Identifier (matches the first array entry). If clients omit `audience` at `/authorize`, Auth0 returns an opaque token (not a JWT) — those won't validate. |
| Cloudflare Worker OAuth | Whatever the Worker code stamps | depends on the Worker | The exact value the Worker uses. |
| Cloudflare Access | The Application AUD tag | string (hex) | The Application AUD from the Access dashboard. Note Access uses a non-`Authorization` header, so swapping it in requires middleware changes. |

#### Tracking who created an entry

Watchword records the creator's identity on each new entry in a nullable `created_by` column. The column is surfaced on `get_entry`, `get_entry_by_word`, `list_entries`, `search_entries`, `search_words`, `store_entry`, `restore_entry`, and `upload_file` responses. It stays `null` for anonymous calls.

Where `created_by` comes from:

- **JWT requests:** the value of `auth.jwt.identity_claim` (default `sub`). Set it to `email` or `preferred_username` if you want a human-readable label.
- **Named static tokens:** the `name` of the matching `auth.named_tokens` entry.
- **Plain `auth.tokens`:** anonymous (`created_by` is null) — these tokens have no associated identity.
- **Auth disabled:** anonymous.

```yaml
auth:
enabled: true
named_tokens:
- name: ci-bot
token: "ci-secret-xyz"
- name: alice
token: "alice-secret-xyz"
jwt:
jwks_url: "https://{tenant}.auth0.com/.well-known/jwks.json"
issuer: "https://{tenant}.auth0.com/"
audience: "https://watchword.example.com/api"
identity_claim: "email"
```

Restoring an expired entry preserves the original `created_by`; it is not overwritten by the restorer.

#### Required scopes

`auth.jwt.required_scopes` enforces fine-grained access on top of issuer/audience. Every listed scope must appear in either the `scope` claim (space-delimited string, used by Auth0 and Keycloak) or the `scp` claim (array). Without this, any valid token from the configured issuer/audience is accepted.

```yaml
auth:
jwt:
jwks_url: "https://{tenant}.auth0.com/.well-known/jwks.json"
issuer: "https://{tenant}.auth0.com/"
audience: "https://watchword.example.com/api"
required_scopes:
- "watchword:read"
- "watchword:write"
```

### S3 file storage (optional)

When configured, Watchword registers `upload_file` and `download_file` tools. Files are stored in S3 (or any S3-compatible service like Cloudflare R2) and transferred via presigned URLs — the MCP server never touches file data.

| Setting | Env var | Default | Description |
|---------|---------|---------|-------------|
| `s3.enabled` | `WORDSTORE_S3_ENABLED` | *(unset)* | Set to `false` to force-disable S3 even if other `s3.*` / `WORDSTORE_S3_*` values are present. Useful for environments that may leak partial S3 env vars. |
| `s3.endpoint` | `WORDSTORE_S3_ENDPOINT` | *(empty = AWS)* | Custom endpoint URL (required for R2, MinIO) |
| `s3.region` | `WORDSTORE_S3_REGION` | | AWS region (e.g. `eu-central-1`) |
| `s3.bucket` | `WORDSTORE_S3_BUCKET` | | S3 bucket name |
| `s3.key_prefix` | `WORDSTORE_S3_KEY_PREFIX` | *(empty)* | Optional folder/prefix prepended to every new object key (e.g. `tenants/acme`). Existing entries keep their stored key. |
| `s3.presign_ttl_minutes` | `WORDSTORE_S3_PRESIGN_TTL_MINUTES` | `15` | How long presigned URLs remain valid |
| `s3.max_file_size_bytes` | `WORDSTORE_S3_MAX_FILE_SIZE_BYTES` | `1073741824` | Max file size (default 1GB) |
| | `WORDSTORE_S3_ACCESS_KEY_ID` | | S3 access key (env var only — never in config file) |
Expand Down Expand Up @@ -260,7 +345,7 @@ s3:

**S3 object cleanup**: Expired file entries do not auto-delete S3 objects. Use [S3 lifecycle rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html) for garbage collection.

If `s3` is not configured, only the original text-based tools are registered — no S3 dependency.
If `s3` is not configured, only the original text-based tools are registered — no S3 dependency. Partial S3 config (e.g. region set but no bucket, or missing credentials) does **not** fail startup — Watchword logs a warning and continues with the file tools disabled. To explicitly disable S3 in environments where partial `WORDSTORE_S3_*` env vars may leak in (e.g. shared k8s ConfigMaps), set `s3.enabled: false` (or `WORDSTORE_S3_ENABLED=false`) — the entire S3 block is then discarded after config load.

### Expiration

Expand Down
53 changes: 37 additions & 16 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func main() {
os.Exit(1)
}

authenticator := auth.NewAuthenticator(cfg.Auth.Enabled, cfg.Auth.Tokens)
authenticator := auth.NewAuthenticator(cfg.Auth.Enabled, cfg.Auth.Tokens, cfg.Auth.NamedTokens)

if cfg.Auth.JWT != nil {
jwtVal, err := auth.NewJWTValidator(ctx, cfg.Auth.JWT)
Expand All @@ -75,31 +75,52 @@ func main() {

if cfg.Server.Transport == "stdio" && cfg.Auth.Enabled {
token := os.Getenv("WORDSTORE_AUTH_TOKEN")
if err := authenticator.Validate(token); err != nil {
identity, err := authenticator.Validate(token)
if err != nil {
logger.Error("stdio auth failed: invalid WORDSTORE_AUTH_TOKEN")
os.Exit(1)
}
logger.Info("stdio auth validated successfully")
if identity != "" {
ctx = auth.WithIdentity(ctx, identity)
logger.Info("stdio auth validated successfully", "identity", identity)
} else {
logger.Info("stdio auth validated successfully")
}
}

svc := service.NewEntryService(repo, cfg.Expiration.TTLHours, logger)

var fileSvc *service.FileService
var s3c *s3client.Client
if cfg.S3 != nil {
var err error
s3c, err = s3client.NewClient(ctx, cfg.S3)
if err != nil {
logger.Error("failed to initialize S3 client", "error", err)
os.Exit(1)
}
fileSvc = service.NewFileService(repo, s3c, cfg.Expiration.TTLHours, cfg.S3.MaxFileSizeBytes, logger)
logger.Info("S3 file storage enabled", "bucket", cfg.S3.Bucket, "region", cfg.S3.Region)

if cfg.S3.Proxy != nil {
signer := proxy.NewURLSigner(cfg.S3.Proxy.BaseURL, cfg.S3.Proxy.HMACSecret, cfg.S3.Proxy.URLTTLMinutes)
fileSvc.SetProxySigner(signer)
logger.Info("proxy download enabled", "base_url", cfg.S3.Proxy.BaseURL, "ttl_minutes", cfg.S3.Proxy.URLTTLMinutes)
switch {
case cfg.S3.Bucket == "" || cfg.S3.Region == "":
logger.Warn("S3 config incomplete, file tools disabled",
"bucket_set", cfg.S3.Bucket != "", "region_set", cfg.S3.Region != "")
cfg.S3 = nil
default:
var err error
s3c, err = s3client.NewClient(ctx, cfg.S3)
if err != nil {
logger.Warn("S3 client init failed, file tools disabled", "error", err)
s3c = nil
cfg.S3 = nil
} else {
fileSvc = service.NewFileService(repo, s3c, cfg.Expiration.TTLHours, cfg.S3.MaxFileSizeBytes, cfg.S3.KeyPrefix, logger)
logger.Info("S3 file storage enabled", "bucket", cfg.S3.Bucket, "region", cfg.S3.Region, "key_prefix", cfg.S3.KeyPrefix)

if p := cfg.S3.Proxy; p != nil {
if p.HMACSecret == "" || p.BaseURL == "" {
logger.Warn("proxy config incomplete, proxy download disabled",
"hmac_secret_set", p.HMACSecret != "", "base_url_set", p.BaseURL != "")
cfg.S3.Proxy = nil
} else {
signer := proxy.NewURLSigner(p.BaseURL, p.HMACSecret, p.URLTTLMinutes)
fileSvc.SetProxySigner(signer)
logger.Info("proxy download enabled", "base_url", p.BaseURL, "ttl_minutes", p.URLTTLMinutes)
}
}
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ auth:
enabled: true
tokens:
- "your-secret-token-1"
# named_tokens: # Optional. Each entry's `name` is recorded as `created_by` for that token's requests.
# - name: "ci-bot"
# token: "ci-secret-xyz"
# resource_metadata: # RFC 9728 Protected Resource Metadata (MCP OAuth)
# resource: "https://watchword.example.com"
# authorization_servers:
Expand All @@ -24,6 +27,11 @@ auth:
# jwks_url: "https://auth.example.com/.well-known/jwks.json"
# issuer: "https://auth.example.com/"
# audience: "https://watchword.example.com"
# required_scopes: # Optional. All listed scopes must appear in `scope` or `scp` claim.
# - "watchword:read"
# - "watchword:write"
# identity_claim: "sub" # Optional. Claim used to populate `created_by`. Default: "sub". Common alternative: "email".
# See examples/keycloak.yaml and examples/auth0.yaml for ready-made provider configs.
# oauth_metadata: # Legacy AS metadata (2025-03-26 MCP spec compat)
# authorization_endpoint: "https://auth.example.com/authorize"
# token_endpoint: "https://auth.example.com/oauth/token"
Expand Down
50 changes: 50 additions & 0 deletions examples/auth0.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Watchword + Auth0
#
# Plug Watchword into an Auth0 tenant. Replace placeholders:
# {TENANT} Auth0 tenant subdomain, e.g. "acme" or "acme.eu"
# {API_ID} the API Identifier you set on your Auth0 API
# (e.g. "https://watchword.example.com/api")
# {WATCHWORD} public URL of this MCP server (e.g. https://watchword.example.com)
#
# Issuer trap: Auth0 emits `iss` with a trailing slash. Match it exactly.
#
# Scopes: declare scopes (e.g. `watchword:read`, `watchword:write`) on the
# Auth0 API and grant them to the application/user. Auth0 puts them in the
# `scope` claim (space-delimited string).

server:
transport: "streamable-http"
http_port: 8080

database:
driver: "postgres"
postgres:
dsn: "postgres://wordstore:wordstore@localhost:5432/wordstore?sslmode=disable"

auth:
enabled: true

resource_metadata:
resource: "{WATCHWORD}"
authorization_servers:
- "https://{TENANT}.auth0.com/"
scopes_supported:
- "watchword:read"
- "watchword:write"

jwt:
jwks_url: "https://{TENANT}.auth0.com/.well-known/jwks.json"
issuer: "https://{TENANT}.auth0.com/" # trailing slash matters
audience: "{API_ID}"
required_scopes:
- "watchword:read"
identity_claim: "sub" # Auth0 `sub` looks like "auth0|abc123". Use "email" for human-readable created_by (requires the openid email scope).

expiration:
enabled: true
interval_hours: 24
ttl_hours: 168

logging:
level: "info"
format: "json"
54 changes: 54 additions & 0 deletions examples/keycloak.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Watchword + Keycloak
#
# Plug Watchword into a Keycloak realm. Replace placeholders:
# {KC_BASE} e.g. https://keycloak.example.com
# {REALM} Keycloak realm name (e.g. "watchword")
# {WATCHWORD} public URL of this MCP server (e.g. https://watchword.example.com)
#
# Audience trap: by default Keycloak puts "account" in `aud`, which means any
# authenticated realm user passes the audience check. To bind tokens to this
# API, create an "Audience" client scope that emits a custom audience (e.g.
# "watchword") and assign it to the client. Then set `audience` below to that
# value.
#
# Scopes: create client scopes like `watchword:read` / `watchword:write` and
# require them via `required_scopes`. Both `scope` (string) and `scp` (array)
# claims are supported.

server:
transport: "streamable-http"
http_port: 8080

database:
driver: "postgres"
postgres:
dsn: "postgres://wordstore:wordstore@localhost:5432/wordstore?sslmode=disable"

auth:
enabled: true

# MCP clients discover the authorization server here (RFC 9728).
resource_metadata:
resource: "{WATCHWORD}"
authorization_servers:
- "{KC_BASE}/realms/{REALM}"
scopes_supported:
- "watchword:read"
- "watchword:write"

jwt:
jwks_url: "{KC_BASE}/realms/{REALM}/protocol/openid-connect/certs"
issuer: "{KC_BASE}/realms/{REALM}"
audience: "watchword" # the value emitted by your Audience client scope
required_scopes:
- "watchword:read"
identity_claim: "preferred_username" # `sub` is a UUID in Keycloak — `preferred_username` or `email` is friendlier for `created_by`

expiration:
enabled: true
interval_hours: 24
ttl_hours: 168

logging:
level: "info"
format: "json"
Loading
Loading