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:
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user