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
29 changes: 19 additions & 10 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,17 @@ type ProxyConfig struct {
}

type ToolsConfig struct {
StoreEntry ToolDesc `yaml:"store_entry"`
GetEntry ToolDesc `yaml:"get_entry"`
GetEntryByWord ToolDesc `yaml:"get_entry_by_word"`
SearchEntries ToolDesc `yaml:"search_entries"`
RestoreEntry ToolDesc `yaml:"restore_entry"`
ListEntries ToolDesc `yaml:"list_entries"`
DeleteEntry ToolDesc `yaml:"delete_entry"`
SearchWords ToolDesc `yaml:"search_words"`
UploadFile ToolDesc `yaml:"upload_file"`
DownloadFile ToolDesc `yaml:"download_file"`
StoreEntry ToolDesc `yaml:"store_entry"`
GetEntry ToolDesc `yaml:"get_entry"`
GetEntryByWord ToolDesc `yaml:"get_entry_by_word"`
SearchEntries ToolDesc `yaml:"search_entries"`
RestoreEntry ToolDesc `yaml:"restore_entry"`
UpdateExpiration ToolDesc `yaml:"update_expiration"`
ListEntries ToolDesc `yaml:"list_entries"`
DeleteEntry ToolDesc `yaml:"delete_entry"`
SearchWords ToolDesc `yaml:"search_words"`
UploadFile ToolDesc `yaml:"upload_file"`
DownloadFile ToolDesc `yaml:"download_file"`
}

type ToolDesc struct {
Expand Down Expand Up @@ -103,6 +104,13 @@ var defaultToolsConfig = ToolsConfig{
"new_ttl_hours": "Optional. Set a new TTL in hours from now. 0 = never expires. Omit to use server default.",
},
},
UpdateExpiration: ToolDesc{
Description: "Update or reset the expiration of an entry referenced by UUID or keyword. Works on both active and expired entries: if expired, the entry is reactivated (collision-resolved against its original word, like restore_entry). Use this to extend a TTL, make an entry permanent, or revive an expired one. The underlying file (for file entries) is preserved — resetting expiration never deletes data. IMPORTANT: tell the user the RETURNED word, which may differ from the original if a collision was resolved.",
Properties: map[string]string{
"id": "The UUID or keyword of the entry to update.",
"ttl_hours": "Optional. New TTL in hours from now. 0 = never expires. Omit to use the server default.",
},
},
ListEntries: ToolDesc{
Description: "List stored entries with optional filtering by status and pagination. Returns compact summaries (no payload) — use get_entry or get_entry_by_word to retrieve full content.",
Properties: map[string]string{
Expand Down Expand Up @@ -511,6 +519,7 @@ func applyToolDefaults(cfg *Config) {
mergeToolDesc(&cfg.Tools.GetEntryByWord, d.GetEntryByWord)
mergeToolDesc(&cfg.Tools.SearchEntries, d.SearchEntries)
mergeToolDesc(&cfg.Tools.RestoreEntry, d.RestoreEntry)
mergeToolDesc(&cfg.Tools.UpdateExpiration, d.UpdateExpiration)
mergeToolDesc(&cfg.Tools.ListEntries, d.ListEntries)
mergeToolDesc(&cfg.Tools.DeleteEntry, d.DeleteEntry)
mergeToolDesc(&cfg.Tools.SearchWords, d.SearchWords)
Expand Down
42 changes: 42 additions & 0 deletions internal/mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,48 @@ func (h *Handlers) RestoreEntry(ctx context.Context, req mcp.CallToolRequest) (*
return marshalResult(resp)
}

func (h *Handlers) UpdateExpiration(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id := req.GetString("id", "")

var ttlHours *int
if args := req.GetArguments(); args != nil {
if _, ok := args["ttl_hours"]; ok {
t := req.GetInt("ttl_hours", 0)
ttlHours = &t
}
}

h.logger.Debug("update_expiration called", "id", id, "ttl_hours", ttlHours)

result, err := h.svc.UpdateExpiration(ctx, id, ttlHours)
if err != nil {
h.logger.Warn("update_expiration failed", "id", id, "error", err)
return mcp.NewToolResultError(err.Error()), nil
}

h.logger.Info("update_expiration success",
"id", result.Entry.ID, "word", result.Entry.Word,
"reactivated", result.Reactivated, "collision", result.CollisionResolved)

resp := map[string]interface{}{
"id": result.Entry.ID.String(),
"word": result.Entry.Word,
"status": result.Entry.Status,
"reactivated": result.Reactivated,
"collision_resolved": result.CollisionResolved,
}
if result.Entry.ExpiresAt != nil {
resp["expires_at"] = result.Entry.ExpiresAt
}
if result.Entry.CreatedBy != nil {
resp["created_by"] = *result.Entry.CreatedBy
}
if result.CollisionResolved {
resp["original_word"] = result.OriginalWord
}
return marshalResult(resp)
}

func (h *Handlers) ListEntries(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
status := req.GetString("status", "")
limit := req.GetInt("limit", 20)
Expand Down
89 changes: 88 additions & 1 deletion internal/mcp/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ import (
"log/slog"
"os"
"testing"
"time"

"github.com/mark3labs/mcp-go/mcp"

"github.com/watchword/watchword/internal/domain"
"github.com/watchword/watchword/internal/repository"
"github.com/watchword/watchword/internal/service"
)

func newTestHandlers(t *testing.T) *Handlers {
t.Helper()
h, _ := newTestHandlersWithRepo(t)
return h
}

func newTestHandlersWithRepo(t *testing.T) (*Handlers, repository.Repository) {
t.Helper()
repo, err := repository.NewSQLiteRepo(":memory:")
if err != nil {
Expand All @@ -25,7 +33,7 @@ func newTestHandlers(t *testing.T) *Handlers {
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
svc := service.NewEntryService(repo, 168, logger)
return &Handlers{svc: svc, logger: logger}
return &Handlers{svc: svc, logger: logger}, repo
}

func callTool(t *testing.T, h *Handlers, name string, args map[string]interface{}) map[string]interface{} {
Expand Down Expand Up @@ -53,6 +61,8 @@ func callTool(t *testing.T, h *Handlers, name string, args map[string]interface{
result, err = h.RestoreEntry(ctx, req)
case "delete_entry":
result, err = h.DeleteEntry(ctx, req)
case "update_expiration":
result, err = h.UpdateExpiration(ctx, req)
default:
t.Fatalf("unknown tool: %s", name)
}
Expand Down Expand Up @@ -293,6 +303,83 @@ func TestMCP_StoreEntry_ZeroTTL(t *testing.T) {
}
}

func TestMCP_UpdateExpiration_Active(t *testing.T) {
h := newTestHandlers(t)

stored := callTool(t, h, "store_entry", map[string]interface{}{
"word": "extend", "payload": "p",
})

out := callTool(t, h, "update_expiration", map[string]interface{}{
"id": stored["id"],
"ttl_hours": float64(48),
})
if out["status"] != "active" {
t.Errorf("expected status=active, got %v", out["status"])
}
if out["reactivated"] != false {
t.Errorf("expected reactivated=false, got %v", out["reactivated"])
}
if out["expires_at"] == nil {
t.Error("expected expires_at to be set")
}
}

func TestMCP_UpdateExpiration_ZeroTTLClearsExpiry(t *testing.T) {
h := newTestHandlers(t)

stored := callTool(t, h, "store_entry", map[string]interface{}{
"word": "perm", "payload": "p",
})

out := callTool(t, h, "update_expiration", map[string]interface{}{
"id": stored["id"],
"ttl_hours": float64(0),
})
if _, hasExp := out["expires_at"]; hasExp {
t.Errorf("expected no expires_at for ttl=0, got %v", out["expires_at"])
}
if out["status"] != "active" {
t.Errorf("expected status=active, got %v", out["status"])
}
}

func TestMCP_UpdateExpiration_ReactivatesExpired(t *testing.T) {
h, repo := newTestHandlersWithRepo(t)
ctx := context.Background()

past := time.Now().UTC().Add(-1 * time.Hour)
if _, err := repo.Store(ctx, &domain.Entry{Word: "fossil", Payload: "old", ExpiresAt: &past}); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := repo.MarkExpiredBatch(ctx, 100); err != nil {
t.Fatalf("MarkExpiredBatch: %v", err)
}

out := callTool(t, h, "update_expiration", map[string]interface{}{
"id": "fossil",
})
if out["status"] != "active" {
t.Errorf("expected status=active, got %v", out["status"])
}
if out["reactivated"] != true {
t.Errorf("expected reactivated=true, got %v", out["reactivated"])
}
if out["word"] != "fossil" {
t.Errorf("expected word=fossil, got %v", out["word"])
}
}

func TestMCP_UpdateExpiration_NotFound(t *testing.T) {
h := newTestHandlers(t)
out := callTool(t, h, "update_expiration", map[string]interface{}{
"id": "no-such-word",
})
if _, ok := out["_error"]; !ok {
t.Error("expected error for unknown reference")
}
}

func TestMCP_MultipleCollisions(t *testing.T) {
h := newTestHandlers(t)

Expand Down
13 changes: 13 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ var propTypes = map[string]map[string]string{
"restore_entry": {
"id": "string", "new_ttl_hours": "integer",
},
"update_expiration": {
"id": "string", "ttl_hours": "integer",
},
"list_entries": {
"status": "string", "limit": "integer", "offset": "integer",
"sort_by": "string", "sort_order": "string",
Expand Down Expand Up @@ -114,6 +117,16 @@ func NewServer(svc *service.EntryService, fileSvc *service.FileService, tools co
},
}, h.RestoreEntry)

s.AddTool(mcp.Tool{
Name: "update_expiration",
Description: tools.UpdateExpiration.Description,
InputSchema: mcp.ToolInputSchema{
Type: "object",
Properties: buildProps("update_expiration", tools.UpdateExpiration.Properties),
Required: []string{"id"},
},
}, h.UpdateExpiration)

s.AddTool(mcp.Tool{
Name: "list_entries",
Description: tools.ListEntries.Description,
Expand Down
96 changes: 96 additions & 0 deletions internal/service/entry_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ type RestoreResult struct {
OriginalWord string `json:"original_word,omitempty"`
}

type UpdateExpirationResult struct {
Entry *domain.Entry `json:"entry"`
Reactivated bool `json:"reactivated"`
CollisionResolved bool `json:"collision_resolved"`
OriginalWord string `json:"original_word,omitempty"`
}

type EntryService struct {
repo repository.Repository
defaultTTL int // hours
Expand Down Expand Up @@ -225,6 +232,95 @@ func (s *EntryService) RestoreEntry(ctx context.Context, idStr string, newTTLHou
return result, nil
}

// UpdateExpiration adjusts the expires_at of an entry referenced by UUID or
// word. When the entry is currently expired, it is reactivated (with collision
// resolution against the base word, mirroring restore_entry); when it is
// active, only expires_at is updated. ttlHours follows the same semantics as
// store_entry: nil → server default, 0 → never expires, positive → relative
// to now. The underlying S3 object (for file entries) is intentionally never
// touched — resetting an expiration must not destroy data.
func (s *EntryService) UpdateExpiration(ctx context.Context, idOrWord string, ttlHours *int) (*UpdateExpirationResult, error) {
if strings.TrimSpace(idOrWord) == "" {
return nil, domain.ErrInvalidWord
}

ttl := s.defaultTTL
if ttlHours != nil {
if *ttlHours < 0 || *ttlHours > domain.MaxTTLHours {
return nil, domain.ErrInvalidTTL
}
ttl = *ttlHours
}

var expiresAt *time.Time
if ttl > 0 {
t := time.Now().UTC().Add(time.Duration(ttl) * time.Hour)
expiresAt = &t
}

var result *UpdateExpirationResult
err := s.repo.WithTx(ctx, func(txRepo repository.Repository) error {
entry, err := lookupEntry(ctx, txRepo, idOrWord)
if err != nil {
return err
}

newStatus := entry.Status
newWord := entry.Word
var collision bool
var originalWord string
reactivated := false

if entry.Status == domain.StatusExpired {
resolved, c, rErr := resolveWord(ctx, txRepo, entry.Word)
if rErr != nil {
return rErr
}
newWord = resolved
collision = c
if c {
originalWord = entry.Word
}
newStatus = domain.StatusActive
reactivated = true
}

if err := txRepo.UpdateStatus(ctx, entry.ID, string(newStatus), newWord, expiresAt); err != nil {
return err
}

entry.Status = newStatus
entry.Word = newWord
entry.ExpiresAt = expiresAt
entry.UpdatedAt = time.Now().UTC()

result = &UpdateExpirationResult{
Entry: entry,
Reactivated: reactivated,
CollisionResolved: collision,
OriginalWord: originalWord,
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}

// lookupEntry resolves a UUID-or-word reference within the active transaction.
// Word lookups include expired entries so update_expiration can target them.
func lookupEntry(ctx context.Context, repo repository.Repository, idOrWord string) (*domain.Entry, error) {
if id, err := uuid.Parse(idOrWord); err == nil {
return repo.GetByID(ctx, id)
}
word := strings.TrimSpace(idOrWord)
if word == "" {
return nil, domain.ErrInvalidWord
}
return repo.GetByWord(ctx, word, true)
}

// ResolveEntry resolves a UUID or word to an entry without modifying it.
func (s *EntryService) ResolveEntry(ctx context.Context, idOrWord string) (*domain.Entry, error) {
id, err := uuid.Parse(idOrWord)
Expand Down
Loading
Loading