feat: expose current date and time via HTTP and MCP
Adds GET /datetime and an MCP get_current_datetime tool returning the current date/time in Europe/Paris, alongside the existing calendar endpoints. Both share the same CurrentDateTime payload shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hu8wwahgecuCGfesTKCfEd
This commit is contained in:
@@ -47,19 +47,21 @@ docker build -t domogeek .
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `cmd/domogeek/domogeek.go` — the binary entrypoint. Wires up flags, zap logging, the `calendar.Calendar`, Prometheus metrics/instrumented handlers, and a `health-go` health check, then serves four HTTP routes:
|
- `cmd/domogeek/domogeek.go` — the binary entrypoint. Wires up flags, zap logging, the `calendar.Calendar`, Prometheus metrics/instrumented handlers, and a `health-go` health check, then serves five HTTP routes:
|
||||||
- `GET /calendar` — returns a `CalendarDay` JSON payload (`working_day`, `ferie`, `holiday`, `weekday`) for "now" in `Europe/Paris`.
|
- `GET /calendar` — returns a `CalendarDay` JSON payload (`working_day`, `ferie`, `holiday`, `weekday`) for "now" in `Europe/Paris`.
|
||||||
|
- `GET /datetime` — returns a `CurrentDateTime` JSON payload (`date_time`) for "now" in `Europe/Paris` (`DateTimeHandler`).
|
||||||
- `POST /mcp` — MCP Streamable HTTP endpoint (see `cmd/domogeek/mcp.go` below).
|
- `POST /mcp` — MCP Streamable HTTP endpoint (see `cmd/domogeek/mcp.go` below).
|
||||||
- `GET /metrics` — Prometheus metrics (request counter/summary/histogram wrapping the calendar handler, defined in `init()`).
|
- `GET /metrics` — Prometheus metrics (request counter/summary/histogram wrapping the calendar handler, defined in `init()`).
|
||||||
- `GET /status` — health-go handler with two checks: a static `calendar` check and a `caldav` check that calls `cal.IsHolidaysFromCaldav`.
|
- `GET /status` — health-go handler with two checks: a static `calendar` check and a `caldav` check that calls `cal.IsHolidaysFromCaldav`.
|
||||||
- The process blocks on `SIGTERM` (not `SIGINT`) via a signal channel; `http.ListenAndServe` runs in a goroutine and `zap.S().Fatal`s the process on error.
|
- The process blocks on `SIGTERM` (not `SIGINT`) via a signal channel; `http.ListenAndServe` runs in a goroutine and `zap.S().Fatal`s the process on error.
|
||||||
|
|
||||||
- `cmd/domogeek/mcp.go` — MCP server built with `github.com/modelcontextprotocol/go-sdk/mcp`, mounted at `/mcp` via `mcp.NewStreamableHTTPHandler`. Exposes three tools, all reading the same package-level `cal`/`location` globals as the `/calendar` handler:
|
- `cmd/domogeek/mcp.go` — MCP server built with `github.com/modelcontextprotocol/go-sdk/mcp`, mounted at `/mcp` via `mcp.NewStreamableHTTPHandler`. Exposes four tools, all reading the same package-level `cal`/`location` globals as the `/calendar` and `/datetime` handlers:
|
||||||
|
- `get_current_datetime` — same payload as `GET /datetime`: the current date and time.
|
||||||
- `get_calendar_today` — same payload as `GET /calendar`, for now.
|
- `get_calendar_today` — same payload as `GET /calendar`, for now.
|
||||||
- `get_calendar_for_date` — same payload for an arbitrary `date` (`YYYY-MM-DD`); an invalid date is returned as a tool error (`CallToolResult.IsError`), not a protocol-level error, so an MCP client/LLM can see and self-correct.
|
- `get_calendar_for_date` — same payload for an arbitrary `date` (`YYYY-MM-DD`); an invalid date is returned as a tool error (`CallToolResult.IsError`), not a protocol-level error, so an MCP client/LLM can see and self-correct.
|
||||||
- `get_holidays` — lists French public holidays (`Calendar.GetHolidays`) for a given `year`, defaulting to the current year if omitted.
|
- `get_holidays` — lists French public holidays (`Calendar.GetHolidays`) for a given `year`, defaulting to the current year if omitted.
|
||||||
- `calendarDayFor` in this file is the single place that builds a `CalendarDay` from a `time.Time`; both the HTTP `/calendar` handler and the MCP tools call it, so there's one code path for the working-day/ferie/holiday/weekday logic.
|
- `calendarDayFor` in this file is the single place that builds a `CalendarDay` from a `time.Time`; both the HTTP `/calendar` handler and the MCP tools call it, so there's one code path for the working-day/ferie/holiday/weekday logic.
|
||||||
- `cmd/domogeek/mcp_test.go` unit-tests the handler functions directly; `cmd/domogeek/mcp_e2e_test.go` drives the real Streamable HTTP transport with an MCP client (`httptest.Server` + `mcp.NewClient(...).Connect`) to check the wiring end-to-end (tool listing, structured content, and the tool-error-vs-protocol-error distinction).
|
- `cmd/domogeek/mcp_test.go` unit-tests the handler functions directly; `cmd/domogeek/mcp_e2e_test.go` drives the real Streamable HTTP transport with an MCP client (`httptest.Server` + `mcp.NewClient(...).Connect`) to check the wiring end-to-end (tool listing, structured content, and the tool-error-vs-protocol-error distinction). `cmd/domogeek/datetime_test.go` covers the `/datetime` HTTP handler directly.
|
||||||
|
|
||||||
- `pkg/calendar/calendar.go` — all domain logic, built around the `Calendar` struct and functional options (`WithCaldav`, `WithCaldavPath`, `WithCaldavSummaryPattern`):
|
- `pkg/calendar/calendar.go` — all domain logic, built around the `Calendar` struct and functional options (`WithCaldav`, `WithCaldavPath`, `WithCaldavSummaryPattern`):
|
||||||
- `GetEasterDay` implements the Gauss/Meeus algorithm to compute Easter Sunday for a given year (in `cal.Location`); `GetHolidays` derives the fixed-date French public holidays plus two computed from it: Easter Monday (Easter+1) and Ascension (Easter+39).
|
- `GetEasterDay` implements the Gauss/Meeus algorithm to compute Easter Sunday for a given year (in `cal.Location`); `GetHolidays` derives the fixed-date French public holidays plus two computed from it: Easter Monday (Easter+1) and Ascension (Easter+39).
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ Tools for domotic
|
|||||||
|
|
||||||
Return calendar informations about today
|
Return calendar informations about today
|
||||||
|
|
||||||
|
## Date and time
|
||||||
|
|
||||||
|
`GET /datetime` returns the current date and time.
|
||||||
|
|
||||||
## MCP
|
## MCP
|
||||||
|
|
||||||
An MCP server is exposed over Streamable HTTP at `/mcp`, providing the same calendar
|
An MCP server is exposed over Streamable HTTP at `/mcp`, providing the same calendar
|
||||||
information as MCP tools (`get_calendar_today`, `get_calendar_for_date`, `get_holidays`)
|
and date/time information as MCP tools (`get_current_datetime`, `get_calendar_today`,
|
||||||
for use by LLM agents/MCP clients.
|
`get_calendar_for_date`, `get_holidays`) for use by LLM agents/MCP clients.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDateTimeHandler_ServeHTTP(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/datetime", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
(&DateTimeHandler{}).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got CurrentDateTime
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("unable to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().In(location)
|
||||||
|
if diff := now.Sub(got.DateTime); diff < -time.Second || diff > time.Second {
|
||||||
|
t.Errorf("DateTime = %v, want close to %v", got.DateTime, now)
|
||||||
|
}
|
||||||
|
_, wantOffset := now.Zone()
|
||||||
|
_, gotOffset := got.DateTime.Zone()
|
||||||
|
if gotOffset != wantOffset {
|
||||||
|
t.Errorf("DateTime UTC offset = %d, want %d", gotOffset, wantOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,26 @@ func (c *CalendarHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CurrentDateTime struct {
|
||||||
|
DateTime time.Time `json:"date_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DateTimeHandler struct{}
|
||||||
|
|
||||||
|
func (d *DateTimeHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
content, err := json.Marshal(CurrentDateTime{DateTime: time.Now().In(location)})
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
zap.S().Errorf("unable to marshall response %v, %v", content, err)
|
||||||
|
} else {
|
||||||
|
_, err = w.Write(content)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
zap.S().Errorf("unable to marshall response %v, :%v", content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var port int
|
var port int
|
||||||
var host string
|
var host string
|
||||||
@@ -173,6 +193,7 @@ func main() {
|
|||||||
)
|
)
|
||||||
http.Handle("/status", healthz.Handler())
|
http.Handle("/status", healthz.Handler())
|
||||||
http.Handle("/mcp", newMCPHandler())
|
http.Handle("/mcp", newMCPHandler())
|
||||||
|
http.Handle("/datetime", &DateTimeHandler{})
|
||||||
|
|
||||||
signChan := make(chan os.Signal, 1)
|
signChan := make(chan os.Signal, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ func calendarDayFor(day time.Time) CalendarDay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleCurrentDateTime(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, CurrentDateTime, error) {
|
||||||
|
return nil, CurrentDateTime{DateTime: time.Now().In(location)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleCalendarToday(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, CalendarDay, error) {
|
func handleCalendarToday(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, CalendarDay, error) {
|
||||||
return nil, calendarDayFor(time.Now().In(location)), nil
|
return nil, calendarDayFor(time.Now().In(location)), nil
|
||||||
}
|
}
|
||||||
@@ -64,6 +68,11 @@ func newMCPHandler() http.Handler {
|
|||||||
Version: "1.0.0",
|
Version: "1.0.0",
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
|
Name: "get_current_datetime",
|
||||||
|
Description: "Get the current date and time",
|
||||||
|
}, handleCurrentDateTime)
|
||||||
|
|
||||||
mcp.AddTool(server, &mcp.Tool{
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
Name: "get_calendar_today",
|
Name: "get_calendar_today",
|
||||||
Description: "Get calendar information for today: working day, French public holiday (ferie), personal holiday (from CalDAV) and weekday",
|
Description: "Get calendar information for today: working day, French public holiday (ferie), personal holiday (from CalDAV) and weekday",
|
||||||
|
|||||||
@@ -26,13 +26,24 @@ func TestMCPEndToEnd(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list tools failed: %v", err)
|
t.Fatalf("list tools failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(tools.Tools) != 3 {
|
if len(tools.Tools) != 4 {
|
||||||
t.Fatalf("got %d tools, want 3", len(tools.Tools))
|
t.Fatalf("got %d tools, want 4", len(tools.Tools))
|
||||||
}
|
}
|
||||||
for _, tool := range tools.Tools {
|
for _, tool := range tools.Tools {
|
||||||
t.Logf("tool: %s - %s", tool.Name, tool.Description)
|
t.Logf("tool: %s - %s", tool.Name, tool.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: "get_current_datetime",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("call get_current_datetime failed: %v", err)
|
||||||
|
}
|
||||||
|
if now.IsError {
|
||||||
|
t.Fatalf("get_current_datetime returned an error result: %v", now.Content)
|
||||||
|
}
|
||||||
|
t.Logf("current datetime structured content: %+v", now.StructuredContent)
|
||||||
|
|
||||||
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
result, err := session.CallTool(ctx, &mcp.CallToolParams{
|
||||||
Name: "get_calendar_for_date",
|
Name: "get_calendar_for_date",
|
||||||
Arguments: map[string]any{"date": "2020-01-01"},
|
Arguments: map[string]any{"date": "2020-01-01"},
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ func TestCalendarDayFor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleCurrentDateTime(t *testing.T) {
|
||||||
|
_, dt, err := handleCurrentDateTime(context.Background(), nil, struct{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().In(location)
|
||||||
|
if diff := now.Sub(dt.DateTime); diff < -time.Second || diff > time.Second {
|
||||||
|
t.Errorf("DateTime = %v, want close to %v", dt.DateTime, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleCalendarToday(t *testing.T) {
|
func TestHandleCalendarToday(t *testing.T) {
|
||||||
withTestCalendar(t)
|
withTestCalendar(t)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user