feat: add MCP endpoint exposing calendar tools over Streamable HTTP

Adds a POST /mcp endpoint (github.com/modelcontextprotocol/go-sdk) with
three tools mirroring the existing /calendar logic: get_calendar_today,
get_calendar_for_date and get_holidays. Bumps go.mod to Go 1.25 (required
by the SDK) and vendors the new dependencies. Also fixes an unanchored
.gitignore rule ("domogeek") that was silently excluding all untracked
files under cmd/domogeek/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hu8wwahgecuCGfesTKCfEd
This commit is contained in:
2026-09-05 10:53:31 +02:00
co-authored by Claude Sonnet 5
parent e30c25b198
commit e5468ef654
553 changed files with 88442 additions and 12995 deletions
+2 -14
View File
@@ -73,20 +73,7 @@ type CalendarDay struct {
type CalendarHandler struct{}
func (c *CalendarHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
now := time.Now()
calDavHolidays, err := cal.IsHolidaysFromCaldav(now)
if err != nil {
zap.S().Warnf("unable to read holiday status from caldav: %v", err)
calDavHolidays = false
}
cd := CalendarDay{
Day: now,
WorkingDay: cal.IsWorkingDay(now),
Ferie: cal.IsHoliday(now),
Holiday: calDavHolidays,
Weekday: cal.IsWeekDay(now),
}
cd := calendarDayFor(time.Now())
content, err := json.Marshal(cd)
if err != nil {
@@ -185,6 +172,7 @@ func main() {
}),
)
http.Handle("/status", healthz.Handler())
http.Handle("/mcp", newMCPHandler())
signChan := make(chan os.Signal, 1)
go func() {
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.uber.org/zap"
)
type mcpDateParams struct {
Date string `json:"date" jsonschema:"Date to check, in YYYY-MM-DD format"`
}
type mcpHolidaysParams struct {
Year int `json:"year,omitempty" jsonschema:"Year to list French public holidays for (defaults to the current year)"`
}
type mcpHolidaysResult struct {
Year int `json:"year"`
Holidays []time.Time `json:"holidays"`
}
func calendarDayFor(day time.Time) CalendarDay {
calDavHolidays, err := cal.IsHolidaysFromCaldav(day)
if err != nil {
zap.S().Warnf("unable to read holiday status from caldav: %v", err)
calDavHolidays = false
}
return CalendarDay{
Day: day,
WorkingDay: cal.IsWorkingDay(day),
Ferie: cal.IsHoliday(day),
Holiday: calDavHolidays,
Weekday: cal.IsWeekDay(day),
}
}
func handleCalendarToday(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, CalendarDay, error) {
return nil, calendarDayFor(time.Now().In(location)), nil
}
func handleCalendarForDate(_ context.Context, _ *mcp.CallToolRequest, params mcpDateParams) (*mcp.CallToolResult, CalendarDay, error) {
day, err := time.ParseInLocation("2006-01-02", params.Date, location)
if err != nil {
return nil, CalendarDay{}, fmt.Errorf("invalid date %q, expected format YYYY-MM-DD: %w", params.Date, err)
}
return nil, calendarDayFor(day), nil
}
func handleGetHolidays(_ context.Context, _ *mcp.CallToolRequest, params mcpHolidaysParams) (*mcp.CallToolResult, mcpHolidaysResult, error) {
year := params.Year
if year == 0 {
year = time.Now().In(location).Year()
}
return nil, mcpHolidaysResult{Year: year, Holidays: *cal.GetHolidays(year)}, nil
}
func newMCPHandler() http.Handler {
server := mcp.NewServer(&mcp.Implementation{
Name: "domogeek",
Version: "1.0.0",
}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "get_calendar_today",
Description: "Get calendar information for today: working day, French public holiday (ferie), personal holiday (from CalDAV) and weekday",
}, handleCalendarToday)
mcp.AddTool(server, &mcp.Tool{
Name: "get_calendar_for_date",
Description: "Get calendar information for a given date: working day, French public holiday (ferie), personal holiday (from CalDAV) and weekday",
}, handleCalendarForDate)
mcp.AddTool(server, &mcp.Tool{
Name: "get_holidays",
Description: "List French public holidays for a given year (defaults to the current year)",
}, handleGetHolidays)
return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"net/http/httptest"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestMCPEndToEnd(t *testing.T) {
withTestCalendar(t)
srv := httptest.NewServer(newMCPHandler())
defer srv.Close()
ctx := context.Background()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: srv.URL}, nil)
if err != nil {
t.Fatalf("connect failed: %v", err)
}
defer session.Close()
tools, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatalf("list tools failed: %v", err)
}
if len(tools.Tools) != 3 {
t.Fatalf("got %d tools, want 3", len(tools.Tools))
}
for _, tool := range tools.Tools {
t.Logf("tool: %s - %s", tool.Name, tool.Description)
}
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "get_calendar_for_date",
Arguments: map[string]any{"date": "2020-01-01"},
})
if err != nil {
t.Fatalf("call tool failed: %v", err)
}
if result.IsError {
t.Fatalf("tool call returned an error result: %v", result.Content)
}
t.Logf("structured content: %+v", result.StructuredContent)
holidays, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "get_holidays",
Arguments: map[string]any{"year": 2020},
})
if err != nil {
t.Fatalf("call get_holidays failed: %v", err)
}
t.Logf("holidays structured content: %+v", holidays.StructuredContent)
badDate, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "get_calendar_for_date",
Arguments: map[string]any{"date": "nope"},
})
if err != nil {
t.Fatalf("call with bad date should be a tool error, not a protocol error: %v", err)
}
if !badDate.IsError {
t.Fatalf("expected IsError=true for an invalid date")
}
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"context"
"domogeek/pkg/calendar"
"testing"
"time"
)
func withTestCalendar(t *testing.T) {
t.Helper()
previous := cal
cal = calendar.New(location)
t.Cleanup(func() { cal = previous })
}
func TestCalendarDayFor(t *testing.T) {
withTestCalendar(t)
tests := []struct {
name string
day time.Time
wantWorkingDay bool
wantFerie bool
wantWeekday bool
}{
{
name: "public holiday",
day: time.Date(2020, time.January, 1, 0, 0, 0, 0, location),
wantWorkingDay: false,
wantFerie: true,
wantWeekday: true,
},
{
name: "regular working day",
day: time.Date(2020, time.January, 2, 0, 0, 0, 0, location),
wantWorkingDay: true,
wantFerie: false,
wantWeekday: true,
},
{
name: "week-end",
day: time.Date(2020, time.January, 4, 0, 0, 0, 0, location),
wantWorkingDay: false,
wantFerie: false,
wantWeekday: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cd := calendarDayFor(tt.day)
if cd.WorkingDay != tt.wantWorkingDay {
t.Errorf("WorkingDay = %v, want %v", cd.WorkingDay, tt.wantWorkingDay)
}
if cd.Ferie != tt.wantFerie {
t.Errorf("Ferie = %v, want %v", cd.Ferie, tt.wantFerie)
}
if cd.Weekday != tt.wantWeekday {
t.Errorf("Weekday = %v, want %v", cd.Weekday, tt.wantWeekday)
}
if !cd.Day.Equal(tt.day) {
t.Errorf("Day = %v, want %v", cd.Day, tt.day)
}
})
}
}
func TestHandleCalendarToday(t *testing.T) {
withTestCalendar(t)
_, cd, err := handleCalendarToday(context.Background(), nil, struct{}{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
now := time.Now().In(location)
wantDay := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, location)
gotDay := time.Date(cd.Day.Year(), cd.Day.Month(), cd.Day.Day(), cd.Day.Hour(), cd.Day.Minute(), 0, 0, location)
if !gotDay.Equal(wantDay) {
t.Errorf("Day = %v, want close to %v", cd.Day, now)
}
}
func TestHandleCalendarForDate(t *testing.T) {
withTestCalendar(t)
t.Run("valid date", func(t *testing.T) {
_, cd, err := handleCalendarForDate(context.Background(), nil, mcpDateParams{Date: "2020-01-01"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !cd.Ferie {
t.Errorf("expected 2020-01-01 to be a ferie day")
}
})
t.Run("invalid date", func(t *testing.T) {
_, _, err := handleCalendarForDate(context.Background(), nil, mcpDateParams{Date: "not-a-date"})
if err == nil {
t.Fatal("expected an error for an invalid date, got nil")
}
})
}
func TestHandleGetHolidays(t *testing.T) {
withTestCalendar(t)
t.Run("explicit year", func(t *testing.T) {
_, result, err := handleGetHolidays(context.Background(), nil, mcpHolidaysParams{Year: 2020})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Year != 2020 {
t.Errorf("Year = %d, want 2020", result.Year)
}
if len(result.Holidays) != 10 {
t.Errorf("got %d holidays, want 10", len(result.Holidays))
}
})
t.Run("defaults to current year", func(t *testing.T) {
_, result, err := handleGetHolidays(context.Background(), nil, mcpHolidaysParams{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Year != time.Now().In(location).Year() {
t.Errorf("Year = %d, want current year %d", result.Year, time.Now().In(location).Year())
}
})
}