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
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.git
.tools
bin
dist
.scratch
evidence
*.env
**/*.key
**/id_*
17 changes: 16 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ changelog:
disable: true

builds:
- id: wpssh-mcp
main: ./cmd/wpssh-mcp
binary: wpssh-mcp
ldflags: >-
-s -w
-X main.version={{ .Tag }}
env:
- CGO_ENABLED=0
targets:
- linux_amd64
- linux_arm64
- darwin_amd64
- darwin_arm64
- id: wpgo
main: ./cmd/wpgo
binary: wpgo
Expand All @@ -24,6 +37,7 @@ builds:

archives:
- builds:
- wpssh-mcp
- wpgo
format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
Expand All @@ -38,7 +52,8 @@ brews:
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
folder: Formula
homepage: "https://github.com/builtbyrobben/wpssh"
description: "WordPress management over SSH"
description: "HTTPS MCP server for WordPress management over SSH"
license: "MIT"
test: |
system "#{bin}/wpssh-mcp", "--version"
system "#{bin}/wpgo", "--version"
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## Project Structure

- `cmd/wpgo/`: CLI entrypoint
- `cmd/wpssh-mcp/`: HTTPS MCP server entrypoint
- `cmd/wpgo/`: legacy CLI entrypoint
- `internal/`: implementation packages
- `cmd/`: Kong CLI commands
- `ssh/`: SSH client
Expand All @@ -16,10 +17,12 @@
- `adapter/`: Adapters
- `batch/`: Batch operations
- `registry/`: Registry
- `mcpserver/`: MCP tools and HTTP authentication

## Build, Test, and Development Commands

- `make` / `make build`: build `bin/wpgo`
- `make` / `make build`: build `bin/wpssh-mcp`
- `make build-cli`: build legacy `bin/wpgo`
- `make tools`: install pinned dev tools into `.tools/`
- `make fmt` / `make lint` / `make test` / `make ci`: format, lint, test, full local gate
- `make test-integration`: run integration tests
Expand Down
14 changes: 14 additions & 0 deletions Dockerfile.mcp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM golang:1.25.1-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=0.1.0
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=${VERSION}" -o /wpssh-mcp ./cmd/wpssh-mcp

FROM alpine:3.22
RUN apk add --no-cache ca-certificates && mkdir -p /home/wpssh/.config
COPY --from=build /wpssh-mcp /usr/local/bin/wpssh-mcp
ENV HOME=/home/wpssh
USER 1000:1000
ENTRYPOINT ["/usr/local/bin/wpssh-mcp"]
34 changes: 22 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ SHELL := /bin/bash

.DEFAULT_GOAL := build

.PHONY: build wpgo help fmt fmt-check lint test test-integration test-all ci tools install clean
.PHONY: build build-cli wpgo help fmt fmt-check lint test test-integration test-all ci tools install install-cli clean

BIN_DIR := $(CURDIR)/bin
BIN := $(BIN_DIR)/wpgo
CMD := ./cmd/wpgo
BIN := $(BIN_DIR)/wpssh-mcp
CMD := ./cmd/wpssh-mcp
CLI_BIN := $(BIN_DIR)/wpgo
CLI_CMD := ./cmd/wpgo

VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo "")
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
LDFLAGS := -X main.version=$(VERSION)
CLI_LDFLAGS := $(LDFLAGS) -X main.commit=$(COMMIT) -X main.date=$(DATE)

TOOLS_DIR := $(CURDIR)/.tools
GOFUMPT := $(TOOLS_DIR)/gofumpt
Expand All @@ -27,17 +30,21 @@ build:
@mkdir -p $(BIN_DIR)
@go build -ldflags "$(LDFLAGS)" -o $(BIN) $(CMD)

wpgo: build
build-cli:
@mkdir -p $(BIN_DIR)
@go build -ldflags "$(CLI_LDFLAGS)" -o $(CLI_BIN) $(CLI_CMD)

wpgo: build-cli
@if [ -n "$(RUN_ARGS)" ]; then \
$(BIN) $(RUN_ARGS); \
$(CLI_BIN) $(RUN_ARGS); \
elif [ -z "$(ARGS)" ]; then \
$(BIN) --help; \
$(CLI_BIN) --help; \
else \
$(BIN) $(ARGS); \
$(CLI_BIN) $(ARGS); \
fi

help: build
@$(BIN) --help
help: build-cli
@$(CLI_BIN) --help

tools:
@mkdir -p $(TOOLS_DIR)
Expand All @@ -60,7 +67,7 @@ lint: tools
@$(GOLANGCI_LINT) run

test:
@go test -race -count=1 ./internal/...
@go test -race -count=1 ./internal/... ./cmd/...

test-integration:
@if [ -n "$(shell go list -tags=integration ./tests/integration/... 2>/dev/null)" ]; then \
Expand All @@ -73,7 +80,10 @@ test-all:
@go test -race -tags=integration ./...

install:
@go install -ldflags "$(LDFLAGS)" ./cmd/wpgo
@go install -ldflags "$(LDFLAGS)" ./cmd/wpssh-mcp

install-cli:
@go install -ldflags "$(CLI_LDFLAGS)" ./cmd/wpgo

clean:
rm -rf bin/ dist/ .tools/
Expand Down
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
# wpssh (wpgo)
# wpssh

A CLI tool for managing WordPress sites over SSH built with Go. Run WP-CLI commands, manage plugins, themes, databases, and more across multiple WordPress sites from a single interface.
An HTTPS MCP server for managing WordPress sites over SSH. It exposes a fixed set of WordPress tools through the Model Context Protocol's stateless Streamable HTTP transport. The existing `wpgo` CLI remains available while its broader command set is migrated.

## Installation
## MCP server

Build `bin/wpssh-mcp` with `make build`, or install it with `make install`. The server reads the service account's `~/.ssh/config`, `~/.ssh/known_hosts`, and optional `~/.config/wpgo/config.json` and `sites.json`. Add each target host's verified SSH host key to `known_hosts` before starting the server. The service account must have the SSH key and network access needed for those sites.

Set `WPSMCP_TOKEN` to a random secret of at least 32 characters outside the repository. The MCP endpoint is `/mcp` and requires `Authorization: Bearer <token>` on every request. Configure the MCP client with the HTTPS URL and token.

For containers, set `WPSMCP_TOKEN_FILE` to a mounted secret file. It takes precedence over `WPSMCP_TOKEN`.

By default the server listens on `127.0.0.1:8080`. Terminate HTTPS on the same VPS with a reverse proxy, forwarding `/mcp` to that listener and preserving the original `Host` header. For example, a Caddy site can use `reverse_proxy 127.0.0.1:8080`. Alternatively, set both `WPSMCP_TLS_CERT_FILE` and `WPSMCP_TLS_KEY_FILE`, plus a public `WPSMCP_LISTEN_ADDR`, to serve HTTPS directly. A non-loopback listener requires direct TLS or the explicit proxy setting below.

For a container connected only to a private reverse-proxy network, set `WPSMCP_BEHIND_PROXY=1` and `WPSMCP_LISTEN_ADDR=0.0.0.0:8080`. Do not publish the container port. The proxy must terminate HTTPS before forwarding requests.

The Linode Compose deployment is in `deploy/linode-compose.yaml`. It serves `https://mcp-google.builtbyrobben.com/wpssh/mcp` through the existing Traefik listener. Its `config` bind mount supplies WordPress paths from `sites.json`; keep that file and the token outside the source tree on the VPS. The Traefik route strips `/wpssh` before passing the request to the server's `/mcp` handler.

The default catalog is read-only: `list_sites`, `list_plugins`, `list_themes`, `list_users`, `list_posts`, `core_version`, and `get_option`. Set `WPSMCP_ALLOW_WRITES=1` to also expose `activate_plugin`, `deactivate_plugin`, `update_plugin`, and `update_option`. Each site-specific call requires a `site` alias. No tool accepts raw shell commands or PHP code. Tool results are fresh WP-CLI output; the MCP server does not use the CLI's SQLite cache.

| Variable | Purpose |
|----------|---------|
| `WPSMCP_TOKEN` | Bearer token of at least 32 characters when no token file is set |
| `WPSMCP_TOKEN_FILE` | Path to a bearer-token secret file; overrides `WPSMCP_TOKEN` |
| `WPSMCP_LISTEN_ADDR` | Listener address, default `127.0.0.1:8080` |
| `WPSMCP_TLS_CERT_FILE`, `WPSMCP_TLS_KEY_FILE` | Optional pair for direct HTTPS |
| `WPSMCP_ALLOW_WRITES` | Set to `1` to expose the four write tools |
| `WPSMCP_BEHIND_PROXY` | Permit plain HTTP on a non-loopback listener behind an HTTPS reverse proxy |

`wpssh-mcp --version` prints the build version without starting the server.

## Legacy CLI

The legacy `wpgo` command keeps its existing SSH host-key behavior. The MCP server requires pinned keys in `known_hosts`.

### Installation

### Homebrew (macOS/Linux)

Expand All @@ -20,7 +51,7 @@ Download the latest release from [GitHub Releases](https://github.com/builtbyrob
```bash
git clone https://github.com/builtbyrobben/wpssh.git
cd wpssh
make build
make build-cli
```

## Configuration
Expand Down
109 changes: 109 additions & 0 deletions cmd/wpssh-mcp/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/builtbyrobben/wpssh/internal/mcpserver"
)

var version = "dev"

var (
errTokenLength = errors.New("WPSMCP_TOKEN must contain at least 32 characters")
errTLSConfig = errors.New("WPSMCP_TLS_CERT_FILE and WPSMCP_TLS_KEY_FILE must be set together")
errPublicHTTP = errors.New("non-loopback listener requires TLS certificate and key")
)

func main() {
if len(os.Args) == 2 && os.Args[1] == "--version" {
fmt.Println(version)
return
}
if err := run(); err != nil {
slog.Error("wpssh MCP stopped", "error", err)
os.Exit(1)
}
}

func run() error {
token, err := loadToken()
if err != nil {
return err
}
if len(token) < 32 {
return errTokenLength
}
addr := os.Getenv("WPSMCP_LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:8080"
}
cert, key := os.Getenv("WPSMCP_TLS_CERT_FILE"), os.Getenv("WPSMCP_TLS_KEY_FILE")
if (cert == "") != (key == "") {
return errTLSConfig
}
if cert == "" && !loopbackAddress(addr) && os.Getenv("WPSMCP_BEHIND_PROXY") != "1" {
return errPublicHTTP
}
service, err := mcpserver.NewService()
if err != nil {
return fmt.Errorf("initialize WordPress service: %w", err)
}
defer service.Close()

mux := http.NewServeMux()
mux.Handle("/mcp", mcpserver.BearerAuth(token, mcpserver.Handler(service, os.Getenv("WPSMCP_ALLOW_WRITES") == "1", version)))
server := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
slog.Info("wpssh MCP listening", "address", addr)
if cert != "" {
err = server.ListenAndServeTLS(cert, key)
} else {
err = server.ListenAndServe()
}
if errors.Is(err, http.ErrServerClosed) {
<-shutdownDone
return nil
}
stop()
<-shutdownDone
return fmt.Errorf("serve MCP: %w", err)
}

func loadToken() (string, error) {
if path := os.Getenv("WPSMCP_TOKEN_FILE"); path != "" {
data, err := os.ReadFile(path) // #nosec G304 -- the operator supplies the secret file path.
if err != nil {
return "", fmt.Errorf("read WPSMCP_TOKEN_FILE: %w", err)
}
return string(bytes.TrimSpace(data)), nil
}
return strings.TrimSpace(os.Getenv("WPSMCP_TOKEN")), nil
}

func loopbackAddress(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
return net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback()
}
50 changes: 50 additions & 0 deletions cmd/wpssh-mcp/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"errors"
"os"
"path/filepath"
"testing"
)

func TestLoadTokenFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(path, []byte("file-secret\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("WPSMCP_TOKEN", "environment-secret")
t.Setenv("WPSMCP_TOKEN_FILE", path)
token, err := loadToken()
if err != nil || token != "file-secret" {
t.Fatalf("loadToken() = %q, %v", token, err)
}
}

func TestLoopbackAddress(t *testing.T) {
for _, tc := range []struct {
address string
want bool
}{
{"127.0.0.1:8080", true},
{"[::1]:8080", true},
{"0.0.0.0:8080", false},
{"192.0.2.1:8080", false},
{"localhost:8080", false},
{"invalid", false},
} {
if got := loopbackAddress(tc.address); got != tc.want {
t.Errorf("loopbackAddress(%q) = %v, want %v", tc.address, got, tc.want)
}
}
}

func TestRunRejectsPublicPlainHTTP(t *testing.T) {
t.Setenv("WPSMCP_TOKEN", "12345678901234567890123456789012")
t.Setenv("WPSMCP_LISTEN_ADDR", "0.0.0.0:8080")
t.Setenv("WPSMCP_TLS_CERT_FILE", "")
t.Setenv("WPSMCP_TLS_KEY_FILE", "")
t.Setenv("WPSMCP_BEHIND_PROXY", "")
if err := run(); !errors.Is(err, errPublicHTTP) {
t.Fatalf("run() = %v, want public HTTP rejection", err)
}
}
Loading
Loading