handler.go

 1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
 2//
 3// SPDX-License-Identifier: AGPL-3.0-or-later
 4
 5// Package timestamp provides an MCP tool for parsing natural language dates.
 6package timestamp
 7
 8import (
 9	"context"
10	"fmt"
11	"time"
12
13	"git.secluded.site/lune/internal/dateutil"
14	"github.com/modelcontextprotocol/go-sdk/mcp"
15)
16
17// ToolName is the name of this tool.
18const ToolName = "get_timestamp"
19
20// ToolDescription describes the tool for LLMs.
21const ToolDescription = `Parse natural language dates into RFC3339 timestamps.
22
23Use before passing dates to other tools. Supports PHP strtotime syntax:
24relative (+3 days, next Monday), named (March 5), ISO (2024-01-15).
25Empty input returns today.`
26
27// ToolAnnotations returns hints about tool behavior.
28func ToolAnnotations() *mcp.ToolAnnotations {
29	return &mcp.ToolAnnotations{
30		ReadOnlyHint:  true,
31		OpenWorldHint: ptr(true),
32		Title:         "Parse date",
33	}
34}
35
36func ptr[T any](v T) *T { return &v }
37
38// Input is the input schema for the timestamp tool.
39type Input struct {
40	Date string `json:"date" jsonschema:"Date/time expression to parse (empty = today)"`
41}
42
43// Output is the output schema for the timestamp tool.
44type Output struct {
45	Timestamp string `json:"timestamp"`
46	Date      string `json:"date"`
47}
48
49// Handler handles timestamp tool requests.
50type Handler struct {
51	timezone *time.Location
52}
53
54// NewHandler creates a new timestamp handler with the given timezone.
55func NewHandler(tz string) *Handler {
56	loc, err := time.LoadLocation(tz)
57	if err != nil {
58		loc = time.UTC
59	}
60
61	return &Handler{timezone: loc}
62}
63
64// Handle parses a natural language date and returns an RFC3339 timestamp.
65func (h *Handler) Handle(
66	_ context.Context,
67	_ *mcp.CallToolRequest,
68	input Input,
69) (*mcp.CallToolResult, Output, error) {
70	parsed, err := dateutil.ParseInTZ(input.Date, h.timezone)
71	if err != nil {
72		return &mcp.CallToolResult{
73			IsError: true,
74			Content: []mcp.Content{
75				&mcp.TextContent{Text: err.Error()},
76			},
77		}, Output{}, nil
78	}
79
80	t := parsed.In(h.timezone)
81	output := Output{
82		Timestamp: t.Format(time.RFC3339),
83		Date:      t.Format("2006-01-02"),
84	}
85
86	inputDisplay := input.Date
87	if inputDisplay == "" {
88		inputDisplay = "(empty)"
89	}
90
91	return &mcp.CallToolResult{
92		Content: []mcp.Content{&mcp.TextContent{
93			Text: fmt.Sprintf("Parsed %q → %s", inputDisplay, output.Timestamp),
94		}},
95	}, output, nil
96}