diff --git a/.goreleaser.yml b/.goreleaser.yml index 7d8db98..1259afa 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -17,8 +17,8 @@ builds: - -trimpath ldflags: - '-s -w' - - '-X main.version={{ .Version }}' - - '-X main.commit={{ .ShortCommit }}' + - '-X github.com/macrat/ayd/internal/meta.Version={{ .Version }}' + - '-X github.com/macrat/ayd/internal/meta.Commit={{ .ShortCommit }}' hooks: post: 'upx-ucl --lzma {{ .Path }}' - id: without-upx @@ -32,8 +32,8 @@ builds: - -trimpath ldflags: - '-s -w' - - '-X main.version={{ .Version }}' - - '-X main.commit={{ .ShortCommit }}' + - '-X github.com/macrat/ayd/internal/meta.Version={{ .Version }}' + - '-X github.com/macrat/ayd/internal/meta.Commit={{ .ShortCommit }}' archives: - formats: [tar.gz] diff --git a/Dockerfile b/Dockerfile index c0ad18e..16f4f84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ RUN for x in $PLUGINS; do \ COPY . /usr/src/ayd/ RUN cd /usr/src/ayd/cmd/ayd && \ - CGO_ENABLED=0 go build --trimpath -ldflags="-s -w -X 'main.version=$VERSION' -X 'main.commit=$COMMIT'" -buildvcs=false -o /output/ayd + CGO_ENABLED=0 go build --trimpath -ldflags="-s -w -X 'github.com/macrat/ayd/internal/meta.Version=$VERSION' -X 'github.com/macrat/ayd/internal/meta.Commit=$COMMIT'" -buildvcs=false -o /output/ayd RUN upx-ucl --lzma /output/* diff --git a/Makefile b/Makefile index 8f5f157..6f5c210 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ COMMIT = $(shell git rev-parse --short $(shell git describe)) ayd: ${SOURCES} - CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -trimpath -o ayd ./cmd/ayd + CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/macrat/ayd/internal/meta.Version=${VERSION} -X github.com/macrat/ayd/internal/meta.Commit=${COMMIT}" -trimpath -o ayd ./cmd/ayd .PHONY: test containertest cover fmt resources clean install diff --git a/README.md b/README.md index 6e357d7..f05e6bf 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ $ ayd ping:192.168.1.1 https://example.com - The [status page](#status-pages-and-endpoints) for using by browsers, consoles, or other programs. +- Built-in remote MCP server for AI tools like Claude or ChatGPT to analyze the status and logs. + - Send an alert if an incident occurs or is resolved. ### Good at @@ -611,6 +613,7 @@ Ayd has these pages/endpoints. | [/log.json](http://localhost:9000/log.json) | Raw log file in JSON format. | | [/targets.txt](http://localhost:9000/targets.txt) | The list of target URLs, separated by \\n. | | [/targets.json](http://localhost:9000/targets.json) | The list of target URLs in JSON format. | +| [/mcp](http://localhost:9000/mcp) | Remote [MCP](https://modelcontextprotocol.io/docs/getting-started/intro) server endpoint.| | [/metrics](http://localhost:9000/metrics) | Minimal status page for use by [Prometheus](https://prometheus.io/). | | [/healthz](http://localhost:9000/healthz) | Health status page for checking status of Ayd itself. | @@ -641,6 +644,13 @@ Query examples: - `status!=healthy target=ping:*`: The logs within recent 7 days that only about unhealthy ping targets. +#### MCP server + +Ayd supports [MCP (Model Context Protocol)](https://modelcontextprotocol.io/docs/getting-started/intro) for AI tools like Claude or ChatGPT to analyze the status and logs. + +To use MCP server, simply run Ayd as usual and add `http://localhost:9000/mcp` as a remote MCP server URL to your AI tool. + + ### Log file The log file of Ayd is stored in [JSON Lines](https://jsonlines.org/) format, encoded UTF-8. diff --git a/cmd/ayd/help.txt b/cmd/ayd/help.txt index 13be6bc..6da3852 100644 --- a/cmd/ayd/help.txt +++ b/cmd/ayd/help.txt @@ -18,6 +18,7 @@ Options: Ayd won't create log file if set "-" or empty. You can use time spec %Y, %y, %m, %d, %H, %M, in the file name. (default "ayd_%Y%m%d.log") + -n, --name=NAME Instance name. This will be shown in page titles and logs. -p, --port=PORT Listen port of status page. (default 9000) -u, --user=USER[:PASS] Username and password for HTTP basic auth. -c, --ssl-cert=FILE Path to certificate file for HTTPS. Please set also -k. diff --git a/cmd/ayd/main.go b/cmd/ayd/main.go index 8736fbe..58cfe69 100644 --- a/cmd/ayd/main.go +++ b/cmd/ayd/main.go @@ -9,34 +9,27 @@ import ( "text/template" "time" + "github.com/macrat/ayd/internal/meta" "github.com/macrat/ayd/internal/scheme" "github.com/macrat/ayd/internal/store" api "github.com/macrat/ayd/lib-ayd" "github.com/spf13/pflag" ) -var ( - version = "HEAD" - commit = "UNKNOWN" -) - -func init() { - scheme.HTTPUserAgent = fmt.Sprintf("ayd/%s health check", version) -} - type AydCommand struct { OutStream io.Writer ErrStream io.Writer - ListenPort int - StorePath string - OneshotMode bool - AlertURLs []string - UserInfo string - CertPath string - KeyPath string - ShowVersion bool - ShowHelp bool + ListenPort int + StorePath string + InstanceName string + OneshotMode bool + AlertURLs []string + UserInfo string + CertPath string + KeyPath string + ShowVersion bool + ShowHelp bool Tasks []Task StartedAt time.Time @@ -53,7 +46,7 @@ var helpText string func (cmd *AydCommand) PrintUsage(detail bool) { tmpl := template.Must(template.New("help.txt").Parse(helpText)) tmpl.Execute(cmd.ErrStream, map[string]interface{}{ - "Version": version, + "Version": meta.Version, "HTTPRedirectMax": scheme.HTTP_REDIRECT_MAX, "Short": !detail, }) @@ -64,6 +57,7 @@ func (cmd *AydCommand) ParseArgs(args []string) (exitCode int) { flags.IntVarP(&cmd.ListenPort, "port", "p", 9000, "HTTP listen port") flags.StringVarP(&cmd.StorePath, "log-file", "f", "ayd_%Y%m%d.log", "Path to log file") + flags.StringVarP(&cmd.InstanceName, "name", "n", "", "Instance name") flags.BoolVarP(&cmd.OneshotMode, "oneshot", "1", false, "Check status only once and exit") flags.StringArrayVarP(&cmd.AlertURLs, "alert", "a", nil, "The alert URLs") flags.StringVarP(&cmd.UserInfo, "user", "u", "", "Username and password for HTTP endpoint") @@ -119,7 +113,7 @@ func (cmd *AydCommand) ParseArgs(args []string) (exitCode int) { } func (cmd *AydCommand) PrintVersion() { - fmt.Fprintf(cmd.OutStream, "Ayd version %s (%s)\n", version, commit) + fmt.Fprintf(cmd.OutStream, "Ayd version %s (%s)\n", meta.Version, meta.Commit) } func (cmd *AydCommand) Run(args []string) (exitCode int) { @@ -137,7 +131,7 @@ func (cmd *AydCommand) Run(args []string) (exitCode int) { return 0 } - s, err := store.New(cmd.StorePath, cmd.OutStream) + s, err := store.New(cmd.InstanceName, cmd.StorePath, cmd.OutStream) if err != nil { fmt.Fprintf(cmd.ErrStream, "error: failed to open log file: %s\n", err) return 1 diff --git a/cmd/ayd/main_test.go b/cmd/ayd/main_test.go index 065bebd..a9b270e 100644 --- a/cmd/ayd/main_test.go +++ b/cmd/ayd/main_test.go @@ -117,6 +117,24 @@ func TestAydCommand_ParseArgs(t *testing.T) { Pattern: "invalid argument:\n ::invalid URL: Not valid as schedule or target URL.\n\nPlease see `ayd -h` for more information\\.\n", ExitCode: 2, }, + { + Args: []string{"ayd", "dummy:"}, + ExitCode: 0, + Extra: func(t *testing.T, cmd main.AydCommand) { + if cmd.InstanceName != "" { + t.Errorf("expected InstanceName is empty in default but got %q", cmd.InstanceName) + } + }, + }, + { + Args: []string{"ayd", "-n", "Test Instance", "dummy:"}, + ExitCode: 0, + Extra: func(t *testing.T, cmd main.AydCommand) { + if cmd.InstanceName != "Test Instance" { + t.Errorf("expected InstanceName is %q but got %q", "Test Instance", cmd.InstanceName) + } + }, + }, } for _, tt := range tests { diff --git a/cmd/ayd/schedule.go b/cmd/ayd/schedule.go index c430a43..74f77b4 100644 --- a/cmd/ayd/schedule.go +++ b/cmd/ayd/schedule.go @@ -40,6 +40,8 @@ type IntervalSchedule struct { func ParseIntervalSchedule(spec string) (IntervalSchedule, error) { if d, err := time.ParseDuration(spec); err != nil { return IntervalSchedule{}, err + } else if d <= 0 { + return IntervalSchedule{}, fmt.Errorf("interval duration: %q", spec) } else { return IntervalSchedule{d}, nil } diff --git a/cmd/ayd/schedule_test.go b/cmd/ayd/schedule_test.go index f8969c9..cb6d011 100644 --- a/cmd/ayd/schedule_test.go +++ b/cmd/ayd/schedule_test.go @@ -8,6 +8,33 @@ import ( "github.com/macrat/ayd/cmd/ayd" ) +func TestParseIntervalSchedule(t *testing.T) { + tests := []struct { + Name string + Input string + Output time.Duration + Error string + }{ + {"valid", "10s", 10 * time.Second, ""}, + {"invalid", "abc", 0, "time: invalid duration \"abc\""}, + {"zero", "0h", 0, "interval duration: \"0h\""}, + {"negative", "-5m", 0, "interval duration: \"-5m\""}, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + schedule, err := main.ParseIntervalSchedule(tt.Input) + if err != nil && err.Error() != tt.Error { + t.Fatalf("unexpected error: expected %#v but got %#v", tt.Error, err.Error()) + } + + if schedule.Interval != tt.Output { + t.Errorf("expected %#v but got %#v", tt.Output, schedule.Interval) + } + }) + } +} + func TestParseCronSchedule(t *testing.T) { tests := []struct { Name string diff --git a/cmd/ayd/server.go b/cmd/ayd/server.go index 795f036..a877118 100644 --- a/cmd/ayd/server.go +++ b/cmd/ayd/server.go @@ -12,6 +12,7 @@ import ( "time" "github.com/macrat/ayd/internal/endpoint" + "github.com/macrat/ayd/internal/meta" "github.com/macrat/ayd/internal/store" api "github.com/macrat/ayd/lib-ayd" "github.com/robfig/cron/v3" @@ -32,31 +33,39 @@ func (cmd *AydCommand) reportStartServer(s *store.Store, protocol, listen string cmd.StartedAt = time.Now() u := &api.URL{Scheme: "ayd", Opaque: "server"} + extra := map[string]interface{}{ + "url": fmt.Sprintf("%s://%s", protocol, listen), + "targets": tasks, + "version": fmt.Sprintf("%s (%s)", meta.Version, meta.Commit), + } + if s.Name() != "" { + extra["instance_name"] = s.Name() + } s.Report(u, api.Record{ Time: cmd.StartedAt, Status: api.StatusHealthy, Target: u, Message: "start Ayd server", - Extra: map[string]interface{}{ - "url": fmt.Sprintf("%s://%s", protocol, listen), - "targets": tasks, - "version": fmt.Sprintf("%s (%s)", version, commit), - }, + Extra: extra, }) } func (cmd *AydCommand) reportStopServer(s *store.Store, protocol, listen string) { u := &api.URL{Scheme: "ayd", Opaque: "server"} + extra := map[string]interface{}{ + "url": fmt.Sprintf("%s://%s", protocol, listen), + "version": fmt.Sprintf("%s (%s)", meta.Version, meta.Commit), + "since": cmd.StartedAt.Format(time.RFC3339), + } + if s.Name() != "" { + extra["instance_name"] = s.Name() + } s.Report(u, api.Record{ Time: time.Now(), Status: api.StatusHealthy, Target: u, Message: "stop Ayd server", - Extra: map[string]interface{}{ - "url": fmt.Sprintf("%s://%s", protocol, listen), - "version": fmt.Sprintf("%s (%s)", version, commit), - "since": cmd.StartedAt.Format(time.RFC3339), - }, + Extra: extra, }) } @@ -136,8 +145,11 @@ func (cmd *AydCommand) RunServer(ctx context.Context, s *store.Store) (exitCode wg.Done() }() - if err := srv.Shutdown(context.Background()); err != nil { - s.ReportInternalError("endpoint", err.Error()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + s.ReportInternalError("endpoint", fmt.Sprintf("failed to gracefully shutdown: %s", err.Error())) } wg.Done() }() diff --git a/cmd/ayd/server_test.go b/cmd/ayd/server_test.go index 0f5fe1a..7470d48 100644 --- a/cmd/ayd/server_test.go +++ b/cmd/ayd/server_test.go @@ -61,7 +61,7 @@ func TestRunServer_tls(t *testing.T) { log, stdout := io.Pipe() defer log.Close() defer stdout.Close() - s := testutil.NewStoreWithConsole(t, stdout) + s := testutil.NewStore(t, testutil.WithConsole(stdout)) defer s.Close() cert := testutil.NewCertificate(t) @@ -129,7 +129,7 @@ func TestRunServer_tls_error(t *testing.T) { cmd.CertPath = tt.Cert cmd.KeyPath = tt.Key - s := testutil.NewStoreWithConsole(t, output) + s := testutil.NewStore(t, testutil.WithConsole(output)) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() diff --git a/go.mod b/go.mod index 42927ee..a2937c5 100644 --- a/go.mod +++ b/go.mod @@ -10,27 +10,33 @@ require ( github.com/goccy/go-json v0.10.5 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 + github.com/itchyny/gojq v0.12.18 github.com/jlaffaye/ftp v0.2.0 github.com/macrat/go-parallel-pinger v1.1.6 github.com/mattn/go-isatty v0.0.20 + github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/pkg/sftp v1.13.10 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/pflag v1.0.10 github.com/xuri/excelize/v2 v2.10.0 goftp.io/server v0.4.1 - golang.org/x/crypto v0.46.0 - golang.org/x/sys v0.39.0 - golang.org/x/text v0.32.0 + golang.org/x/crypto v0.47.0 + golang.org/x/sys v0.40.0 + golang.org/x/text v0.33.0 ) require ( + github.com/google/jsonschema-go v0.4.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/kr/fs v0.1.0 // indirect - github.com/richardlehane/mscfb v1.0.4 // indirect - github.com/richardlehane/msoleps v1.0.4 // indirect - github.com/tiendc/go-deepcopy v1.7.1 // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect - golang.org/x/net v0.47.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 8c12d3d..4085b53 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -18,6 +22,10 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jlaffaye/ftp v0.0.0-20190624084859-c1312a7102bf/go.mod h1:lli8NYPQOFy3O++YmYbqVgOcQ1JPCwdOy+5zSjKJ9qY= github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI= @@ -32,15 +40,16 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/minio/minio-go/v6 v6.0.46/go.mod h1:qD0lajrGW49lKZLtXKtCB4X/qkMf0a5tBvN2PaZg7Gg= github.com/minio/sha256-simd v0.1.1/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= +github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= -github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= -github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= -github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -54,44 +63,50 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4= -github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4= github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= goftp.io/server v0.4.1 h1:x7KG4HIxSMdK/rpYhExMinRN/aO/T9icvaG/B5e/XfY= goftp.io/server v0.4.1/go.mod h1:hFZeR656ErRt3ojMKt7H10vQ5nuWV1e0YeUTeorlR6k= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/endpoint/benchmark_test.go b/internal/endpoint/benchmark_test.go index e328f9b..82f94a7 100644 --- a/internal/endpoint/benchmark_test.go +++ b/internal/endpoint/benchmark_test.go @@ -37,7 +37,7 @@ func Benchmark_endpoints(b *testing.B) { for _, tt := range benchmarks { b.Run(tt.Path, func(b *testing.B) { - s := testutil.NewStoreWithLog(b) + s := testutil.NewStore(b, testutil.WithLog()) defer s.Close() h := tt.Endpoint(s) diff --git a/internal/endpoint/endpoint.go b/internal/endpoint/endpoint.go index a6c4415..1b25e07 100644 --- a/internal/endpoint/endpoint.go +++ b/internal/endpoint/endpoint.go @@ -74,6 +74,8 @@ func New(s Store) http.Handler { m.Handle("/targets.txt", LinkHeader{TargetsTextEndpoint(s), targetsLink}) m.Handle("/targets.json", LinkHeader{TargetsJSONEndpoint(s), targetsLink}) + m.Handle("/mcp", MCPHandler(s)) + m.HandleFunc("/metrics", MetricsEndpoint(s)) m.HandleFunc("/healthz", HealthzEndpoint(s)) diff --git a/internal/endpoint/healthz_test.go b/internal/endpoint/healthz_test.go index c831a63..4b24a92 100644 --- a/internal/endpoint/healthz_test.go +++ b/internal/endpoint/healthz_test.go @@ -40,6 +40,10 @@ type DummyErrorsGetter struct { messages []string } +func (d DummyErrorsGetter) Name() string { + return "dummy" +} + func (d DummyErrorsGetter) Path() string { return "" } @@ -48,6 +52,14 @@ func (d DummyErrorsGetter) ProbeHistory() []api.ProbeHistory { return nil } +func (d DummyErrorsGetter) CurrentIncidents() []*api.Incident { + return []*api.Incident{} +} + +func (d DummyErrorsGetter) IncidentHistory() []*api.Incident { + return []*api.Incident{} +} + func (d DummyErrorsGetter) Targets() []string { return nil } diff --git a/internal/endpoint/incidents.go b/internal/endpoint/incidents.go index 2a84cb9..5a0e245 100644 --- a/internal/endpoint/incidents.go +++ b/internal/endpoint/incidents.go @@ -77,7 +77,7 @@ func IncidentsJSONEndpoint(s Store) http.HandlerFunc { enc := json.NewEncoder(newFlushWriter(w)) - handleError(s, "log.json", enc.EncodeContext(r.Context(), newIncidentsInfo(s))) + handleError(s, "incidents.json", enc.EncodeContext(r.Context(), newIncidentsInfo(s))) } } diff --git a/internal/endpoint/log.go b/internal/endpoint/log.go index 926e296..ed20286 100644 --- a/internal/endpoint/log.go +++ b/internal/endpoint/log.go @@ -367,6 +367,7 @@ func LogHTMLEndpoint(s Store) http.HandlerFunc { w.Header().Set("Content-Type", "text/html; charset=UTF-8") handleError(s, "log.html", tmpl.Execute(newFlushWriter(w), logData{ + InstanceName: s.Name(), Query: query, RawQuery: rawQuery.Encode(), Records: rs, @@ -382,6 +383,7 @@ func LogHTMLEndpoint(s Store) http.HandlerFunc { } type logData struct { + InstanceName string Since time.Time Until time.Time Query string diff --git a/internal/endpoint/log_fuzz_test.go b/internal/endpoint/log_fuzz_test.go index 6b53e17..4ff639e 100644 --- a/internal/endpoint/log_fuzz_test.go +++ b/internal/endpoint/log_fuzz_test.go @@ -13,7 +13,7 @@ import ( ) func FuzzLogJsonEndpoint(f *testing.F) { - s := testutil.NewStoreWithLog(f) + s := testutil.NewStore(f, testutil.WithLog()) defer s.Close() handler := endpoint.LogJsonEndpoint(s) diff --git a/internal/endpoint/log_test.go b/internal/endpoint/log_test.go index 8cee439..6520022 100644 --- a/internal/endpoint/log_test.go +++ b/internal/endpoint/log_test.go @@ -82,7 +82,7 @@ func TestLogScanner(t *testing.T) { { "LogGenerator", func(since, until time.Time) api.LogScanner { - s, err := store.New("", io.Discard) + s, err := store.New("", "", io.Discard) if err != nil { t.Fatalf("failed to create store: %s", err) } @@ -157,7 +157,7 @@ func TestLogScanner(t *testing.T) { } func TestPagingScanner(t *testing.T) { - s := testutil.NewStoreWithLog(t) + s := testutil.NewStore(t, testutil.WithLog()) messages := []string{ "hello world", @@ -218,7 +218,7 @@ func TestPagingScanner(t *testing.T) { } func TestContextScanner_scanAll(t *testing.T) { - s := testutil.NewStoreWithLog(t) + s := testutil.NewStore(t, testutil.WithLog()) r, err := s.OpenLog(time.Unix(0, 0), time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)) if err != nil { @@ -243,7 +243,7 @@ func TestContextScanner_scanAll(t *testing.T) { } func TestContextScanner_cancel(t *testing.T) { - s := testutil.NewStoreWithLog(t) + s := testutil.NewStore(t, testutil.WithLog()) r, err := s.OpenLog(time.Unix(0, 0), time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)) if err != nil { diff --git a/internal/endpoint/mcp.go b/internal/endpoint/mcp.go new file mode 100644 index 0000000..0fe8496 --- /dev/null +++ b/internal/endpoint/mcp.go @@ -0,0 +1,380 @@ +package endpoint + +import ( + "context" + "errors" + "fmt" + "maps" + "net/http" + "net/url" + "sort" + "time" + + "github.com/itchyny/gojq" + "github.com/macrat/ayd/internal/meta" + "github.com/macrat/ayd/internal/query" + api "github.com/macrat/ayd/lib-ayd" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func recordToMap(rec api.Record) map[string]any { + x := map[string]any{ + "time": rec.Time.Format(time.RFC3339), + "time_unix": rec.Time.Unix(), + "status": rec.Status.String(), + "latency": rec.Latency.String(), + "latency_ms": float64(rec.Latency.Nanoseconds()) / 1000000.0, + "target": rec.Target.String(), + "message": rec.Message, + } + maps.Copy(x, rec.Extra) + return x +} + +func incidentToMap(inc *api.Incident) map[string]any { + r := map[string]any{ + "target": inc.Target.String(), + "status": inc.Status.String(), + "message": inc.Message, + "starts_at": inc.StartsAt.Format(time.RFC3339), + "starts_at_unix": inc.StartsAt.Unix(), + } + + if inc.EndsAt.IsZero() { + r["ends_at"] = nil + r["ends_at_unix"] = nil + } else { + r["ends_at"] = inc.EndsAt.Format(time.RFC3339) + r["ends_at_unix"] = inc.EndsAt.Unix() + } + + return r +} + +type MCPOutput struct { + Result any `json:"result" jsonschema:"The result of the query."` +} + +func jqParseURL(x any, _ []any) any { + str, ok := x.(string) + if !ok { + return fmt.Errorf("parse_url/0: expected a string but got %T (%v)", x, x) + } + u, err := url.Parse(str) + if err != nil { + return fmt.Errorf("parse_url/0: failed to parse URL: %v", err) + } + + username := "" + if u.User != nil { + username = u.User.Username() + } + + queries := map[string][]any{} + for key, vals := range u.Query() { + queries[key] = make([]any, len(vals)) + for i, v := range vals { + queries[key][i] = v + } + } + + if u.Opaque != "" && u.Host == "" { + switch u.Scheme { + case "ping", "ping4", "ping6": + u.Host = u.Opaque + u.Opaque = "" + case "dns", "dns4", "dns6", "file", "exec", "mailto", "source": + u.Path = u.Opaque + u.Opaque = "" + } + } + + return map[string]any{ + "scheme": u.Scheme, + "username": username, + "hostname": u.Hostname(), + "port": u.Port(), + "path": u.Path, + "queries": queries, + "fragment": u.Fragment, + "opaque": u.Opaque, + } +} + +type JQQuery struct { + Code *gojq.Code +} + +func ParseJQ(query string) (JQQuery, error) { + if query == "" { + query = "." + } + + q, err := gojq.Parse(query) + if err != nil { + return JQQuery{}, err + } + + c, err := gojq.Compile( + q, + gojq.WithFunction("parse_url", 0, 0, jqParseURL), + ) + if err != nil { + return JQQuery{}, err + } + + return JQQuery{Code: c}, nil +} + +func (q JQQuery) Run(ctx context.Context, s Store, logScope string, input any) (MCPOutput, error) { + var outputs []any + + iter := q.Code.RunWithContext(ctx, input) + for { + v, ok := iter.Next() + if !ok { + break + } + if halt, ok := v.(*gojq.HaltError); ok { + if halt.ExitCode() == 0 { + break + } + v := map[string]any{ + "status": "halt_error", + "exit_code": halt.ExitCode(), + "value": halt.Value(), + } + outputs = append(outputs, v) + break + } else if err, ok := v.(error); ok { + return MCPOutput{}, err + } + outputs = append(outputs, v) + } + + if len(outputs) == 1 { + return MCPOutput{ + Result: outputs[0], + }, nil + } else { + return MCPOutput{ + Result: outputs, + }, nil + } +} + +type MCPStatusInput struct { + JQ string `json:"jq,omitempty" jsonschema:"A jq query string to filter and/or aggregate status. Query receives an array. Each object is like '{\"target\": \"{url}\", \"status\": \"...\", \"latest_log\": {\"time\": \"{RFC 3339}\", \"status\": \"...\", \"latency\": ..., \"message\": \"...\", ...}}'. You can use 'parse_url' filter to parse target URLs. For example, '.[] | {target: .target, status: .status, message: .latest_log.message}' to get the current status of all targets."` +} + +func FetchStatusByJq(ctx context.Context, s Store, input MCPStatusInput) (MCPOutput, error) { + query, err := ParseJQ(input.JQ) + if err != nil { + return MCPOutput{}, fmt.Errorf("failed to parse jq query: %w", err) + } + + history := s.ProbeHistory() + + targets := make([]any, 0, len(history)) + + for _, r := range history { + var latest map[string]any + if len(r.Records) > 0 { + latest = recordToMap(r.Records[len(r.Records)-1]) + delete(latest, "target") + } + + targets = append(targets, map[string]any{ + "target": r.Target.String(), + "status": r.Status.String(), + "latest_log": latest, + }) + } + sort.Slice(targets, func(i, j int) bool { + return targets[i].(map[string]any)["target"].(string) < targets[j].(map[string]any)["target"].(string) + }) + + return query.Run(ctx, s, "mcp/query_status", targets) +} + +type MCPIncidentsInput struct { + IncludeOngoing *bool `json:"include_ongoing,omitempty" jsonschema:"Whether to include ongoing incidents in the result. If omitted, ongoing incidents are included."` + IncludeResolved bool `json:"include_resolved,omitempty" jsonschema:"Whether to include resolved incidents in the result. If omitted, resolved incidents are not included."` + JQ string `json:"jq,omitempty" jsonschema:"A jq query string to filter and/or aggregate incidents. Query receives an array. Each object is like '{\"target\": \"{url}\", \"status\": \"...\", \"message\": \"...\", \"starts_at\": \"{RFC 3339}\", \"ends_at\": \"{RFC 3339 or null}\"}'. You can use 'parse_url' filter to parse target URLs. For example, 'map(.target | startswith(\"http\"))[] | {target: .target, status: .status, starts_at: .starts_at, resolved: (.ends_at != null)}' to get incidents of HTTP/HTTPS targets."` +} + +func FetchIncidentsByJq(ctx context.Context, s Store, input MCPIncidentsInput) (MCPOutput, error) { + query, err := ParseJQ(input.JQ) + if err != nil { + return MCPOutput{}, fmt.Errorf("failed to parse jq query: %w", err) + } + + current := s.CurrentIncidents() + history := s.IncidentHistory() + + count := 0 + if input.IncludeResolved { + count += len(history) + } + if input.IncludeOngoing == nil || *input.IncludeOngoing { + count += len(current) + } + + incidents := make([]any, 0, count) + + if input.IncludeResolved { + for _, v := range history { + incidents = append(incidents, incidentToMap(v)) + } + } + + if input.IncludeOngoing == nil || *input.IncludeOngoing { + for _, v := range current { + incidents = append(incidents, incidentToMap(v)) + } + } + + sort.Slice(incidents, func(i, j int) bool { + return incidents[i].(map[string]any)["starts_at_unix"].(int64) < incidents[j].(map[string]any)["starts_at_unix"].(int64) + }) + + return query.Run(ctx, s, "mcp/query_incidents", incidents) +} + +type MCPLogsInput struct { + Since string `json:"since" jsonschema:"The start time for fetching logs, in RFC3339 format."` + Until string `json:"until" jsonschema:"The end time for fetching logs, in RFC3339 format."` + Search string `json:"search,omitempty" jsonschema:"A search query to filter logs. For example, 'status!=HEALTHY', or 'status=FAILURE AND (latency<100ms OR target=http://example.com*)'. It is recommended to use this parameter to reduce the number of logs before applying jq query. If omitted, no filtering is applied."` + JQ string `json:"jq,omitempty" jsonschema:"A jq query string to filter logs. Query receives an array of status objects. Each objects has at least 'time', 'target', 'status', and 'latency'. You can use 'parse_url' filter to parse target URLs. For example, 'map(select(.status != \"HEALTHY\")) | group_by(.target)[] | {target: .[0].target, count: length, max_latency: (map(.latency_ms) | max)}' to get unhealthy logs grouped by target with maximum latency.'"` +} + +func FetchLogsByJq(ctx context.Context, s Store, input MCPLogsInput) (MCPOutput, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if input.Since == "" || input.Until == "" { + return MCPOutput{}, errors.New("since and until parameters are required") + } + + since, err := api.ParseTime(input.Since) + if err != nil { + return MCPOutput{}, fmt.Errorf("since time must be in RFC3339 format but got %q", input.Since) + } + until, err := api.ParseTime(input.Until) + if err != nil { + return MCPOutput{}, fmt.Errorf("until time must be in RFC3339 format but got %q", input.Until) + } + + var q query.Query + if input.Search != "" { + q = query.ParseQuery(input.Search) + st, en := q.TimeRange() + + if st != nil && st.After(since) { + since = *st + } + if en != nil && en.Before(until) { + until = *en + } + } + + logs, err := s.OpenLog(since, until) + if err != nil { + s.ReportInternalError("mcp/query_logs", fmt.Sprintf("failed to open logs: %v", err)) + return MCPOutput{}, errors.New("internal server error") + } + defer logs.Close() + + if q != nil { + logs = FilterScanner{ + Scanner: logs, + Query: q, + } + } + + jq, err := ParseJQ(input.JQ) + if err != nil { + return MCPOutput{}, fmt.Errorf("failed to parse jq query: %w", err) + } + + records := []any{} + for logs.Scan() { + rec := logs.Record() + records = append(records, recordToMap(rec)) + } + + return jq.Run(ctx, s, "mcp/query_logs", records) +} + +func MCPServer(s Store) *mcp.Server { + impl := &mcp.Implementation{ + Name: "ayd", + Version: meta.Version, + Title: "Ayd", + } + + opts := &mcp.ServerOptions{ + Instructions: "Ayd is a simple alive monitoring tool. The logs and status can be large, so it is recommended to extract necessary information using search and jq queries instead of fetching all data at once.", + } + + if s.Name() != "" { + impl.Title = fmt.Sprintf("Ayd (%s)", s.Name()) + opts.Instructions = fmt.Sprintf(`%s This Ayd instance's name is %q.`, opts.Instructions, s.Name()) + } + + server := mcp.NewServer(impl, opts) + + mcp.AddTool(server, &mcp.Tool{ + Name: "query_status", + Title: "Query status", + Description: "Fetch latest status of each targets from Ayd server.", + Annotations: &mcp.ToolAnnotations{ + IdempotentHint: true, + ReadOnlyHint: true, + }, + }, func(ctx context.Context, req *mcp.CallToolRequest, input MCPStatusInput) (*mcp.CallToolResult, MCPOutput, error) { + output, err := FetchStatusByJq(ctx, s, input) + return nil, output, err + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "query_incidents", + Title: "Query incidents", + Description: "Fetch current and past incidents from Ayd server. The result is limited by number. Please use query_logs tool to analyze long-term history.", + Annotations: &mcp.ToolAnnotations{ + IdempotentHint: true, + ReadOnlyHint: true, + }, + }, func(ctx context.Context, req *mcp.CallToolRequest, input MCPIncidentsInput) (*mcp.CallToolResult, MCPOutput, error) { + output, err := FetchIncidentsByJq(ctx, s, input) + return nil, output, err + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "query_logs", + Title: "Query logs", + Description: "Fetch health check logs from Ayd server. The result can be very large. Please use time range and aggregation in jq query to reduce the result size.", + Annotations: &mcp.ToolAnnotations{ + IdempotentHint: true, + ReadOnlyHint: true, + }, + }, func(ctx context.Context, req *mcp.CallToolRequest, input MCPLogsInput) (*mcp.CallToolResult, MCPOutput, error) { + output, err := FetchLogsByJq(ctx, s, input) + return nil, output, err + }) + + return server +} + +func MCPHandler(s Store) http.Handler { + server := MCPServer(s) + + handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + }) + + return handler +} diff --git a/internal/endpoint/mcp_test.go b/internal/endpoint/mcp_test.go new file mode 100644 index 0000000..86add11 --- /dev/null +++ b/internal/endpoint/mcp_test.go @@ -0,0 +1,1004 @@ +package endpoint_test + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/macrat/ayd/internal/endpoint" + "github.com/macrat/ayd/internal/scheme" + "github.com/macrat/ayd/internal/testutil" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestJQQuery(t *testing.T) { + input := map[string]any{ + "foo": 1, + "bar": 2, + "baz": map[string]any{ + "qux": 3, + }, + } + + tests := []struct { + Query string + Expect any + Error string + }{ + { + Query: `.foo + .bar`, + Expect: 3, + }, + { + Query: `.baz.qux * 2`, + Expect: 6, + }, + { + Query: `[.foo, .bar]`, + Expect: []any{1, 2}, + }, + { + Query: `.nonexistent`, + Expect: nil, + }, + { + Query: `0 / 0`, + Error: `cannot divide number (0) by: number (0)`, + }, + { + Query: `"http://foo:bar@example.com/path?query=value#fragment" | parse_url`, + Expect: map[string]any{ + "scheme": "http", + "username": "foo", + "hostname": "example.com", + "port": "", + "path": "/path", + "queries": map[string][]any{"query": {"value"}}, + "fragment": "fragment", + "opaque": "", + }, + }, + { + Query: `"ping:example.com" | parse_url`, + Expect: map[string]any{ + "scheme": "ping", + "username": "", + "hostname": "example.com", + "port": "", + "path": "", + "queries": map[string][]any{}, + "fragment": "", + "opaque": "", + }, + }, + { + Query: `"dns:example.com" | parse_url`, + Expect: map[string]any{ + "scheme": "dns", + "username": "", + "hostname": "", + "port": "", + "path": "example.com", + "queries": map[string][]any{}, + "fragment": "", + "opaque": "", + }, + }, + { + Query: `"://hoge" | parse_url`, + Error: `parse_url/0: failed to parse URL: parse "://hoge": missing protocol scheme`, + }, + { + Query: `123 | parse_url`, + Error: `parse_url/0: expected a string but got int (123)`, + }, + { + Query: `halt`, + Expect: []any(nil), + }, + { + Query: `halt_error`, + Expect: map[string]any{ + "status": "halt_error", + "exit_code": 5, + "value": input, + }, + }, + { + Query: `123 | halt_error(4)`, + Expect: map[string]any{ + "status": "halt_error", + "exit_code": 4, + "value": 123, + }, + }, + { + Query: `error("hello")`, + Error: `error: hello`, + }, + } + + for _, tt := range tests { + t.Run(tt.Query, func(t *testing.T) { + s := testutil.NewStore(t) + + jq, err := endpoint.ParseJQ(tt.Query) + if err != nil { + t.Fatalf("failed to parse JQ query: %v", err) + } + + output, err := jq.Run(context.Background(), s, "mcp/test", input) + errStr := "" + if err != nil { + errStr = err.Error() + } + + if errStr != tt.Error { + if tt.Error == "" { + t.Fatalf("unexpected error: %v", err) + } else { + t.Fatalf("expected error %q, got %v", tt.Error, err) + } + } else { + if diff := cmp.Diff(tt.Expect, output.Result); diff != "" { + t.Errorf("output mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +type MCPTest[I any] struct { + Name string + Args I + Expect endpoint.MCPOutput + Error string +} + +func RunMCPTest[I any](t *testing.T, tool string, tests []MCPTest[I]) { + srv := testutil.StartTestServer(t) + t.Cleanup(func() { + srv.Close() + }) + + for _, tt := range tests { + t.Run(fmt.Sprintf("%s_%s", tool, tt.Name), func(t *testing.T) { + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "none", + }, nil) + sess, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{ + Endpoint: srv.URL + "/mcp", + }, nil) + if err != nil { + t.Fatalf("failed to connect to MCP server: %v", err) + } + defer sess.Close() + + result, err := sess.CallTool(t.Context(), &mcp.CallToolParams{ + Name: tool, + Arguments: tt.Args, + }) + if err != nil { + t.Fatalf("failed to call tool %q: %v", tool, err) + } + + if len(result.Content) != 1 { + t.Fatalf("expected 1 content, got %#v", result.Content) + } + + if tt.Error == "" { + var resultData endpoint.MCPOutput + if text, ok := result.Content[0].(*mcp.TextContent); !ok { + t.Fatalf("expected TextContent, got %#v", result.Content[0]) + } else if err := json.Unmarshal([]byte(text.Text), &resultData); err != nil { + t.Fatalf("failed to unmarshal result data: %v", err) + } + + if diff := cmp.Diff(tt.Expect, resultData); diff != "" { + t.Errorf("output mismatch (-want +got):\n%s", diff) + } + + if result.IsError == true { + t.Errorf("expected IsError to be false, but got true") + } + } else { + if text, ok := result.Content[0].(*mcp.TextContent); !ok { + t.Fatalf("expected TextContent, got %#v", result.Content[0]) + } else if text.Text != tt.Error { + t.Errorf("expected error %q, got %q", tt.Error, text.Text) + } + + if result.IsError == false { + t.Errorf("expected IsError to be true, but got false") + } + } + }) + } +} + +func TestMCPHandler_QueryStatus(t *testing.T) { + tests := []MCPTest[endpoint.MCPStatusInput]{ + { + Name: "without_query", + Args: endpoint.MCPStatusInput{}, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "dummy:#no-record-yet", + "status": "UNKNOWN", + "latest_log": nil, + }, + map[string]any{ + "target": "http://a.example.com", + "status": "HEALTHY", + "latest_log": map[string]any{ + "latency": "345.678ms", + "latency_ms": 345.678, + "message": "hello world!!", + "status": "HEALTHY", + "time": "2021-01-02T15:04:07Z", + "time_unix": 1609599847.0, + }, + }, + map[string]any{ + "target": "http://b.example.com", + "status": "HEALTHY", + "latest_log": map[string]any{ + "extra": 1.234, + "latency": "54.321ms", + "latency_ms": 54.321, + "message": "this is healthy", + "status": "HEALTHY", + "time": "2021-01-02T15:04:06Z", + "time_unix": 1609599846.0, + }, + }, + map[string]any{ + "target": "http://c.example.com", + "status": "UNKNOWN", + "latest_log": map[string]any{ + "extra": []any{1.0, 2.0, 3.0}, + "hoge": "fuga", + "latency": "2.345ms", + "latency_ms": 2.345, + "message": "this is unknown", + "status": "UNKNOWN", + "time": "2021-01-02T15:04:09Z", + "time_unix": 1609599849.0, + }, + }, + }, + }, + }, + { + Name: "with_single_result_query", + Args: endpoint.MCPStatusInput{ + JQ: `.[] | select(.target == "http://a.example.com") | .latest_log.message`, + }, + Expect: endpoint.MCPOutput{ + Result: "hello world!!", + }, + }, + { + Name: "with_multiple_result_query", + Args: endpoint.MCPStatusInput{ + JQ: `.[] | {target: .target, latency: .latest_log.latency_ms}`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "dummy:#no-record-yet", + "latency": nil, + }, + map[string]any{ + "target": "http://a.example.com", + "latency": 345.678, + }, + map[string]any{ + "target": "http://b.example.com", + "latency": 54.321, + }, + map[string]any{ + "target": "http://c.example.com", + "latency": 2.345, + }, + }, + }, + }, + { + Name: "with_no_result_query", + Args: endpoint.MCPStatusInput{ + JQ: `.[] | select(.status == "nonexistent")`, + }, + Expect: endpoint.MCPOutput{ + Result: nil, + }, + }, + { + Name: "unclosed_bracket", + Args: endpoint.MCPStatusInput{ + JQ: `(.[0]`, + }, + Error: `failed to parse jq query: unexpected EOF`, + }, + { + Name: "iterate_null", + Args: endpoint.MCPStatusInput{ + JQ: `.[0].nonexistent[]`, + }, + Error: `cannot iterate over: null`, + }, + { + Name: "unknown_function", + Args: endpoint.MCPStatusInput{ + JQ: `.[0] | unknown_function`, + }, + Error: `failed to parse jq query: function not defined: unknown_function/0`, + }, + { + Name: "example_query", + Args: endpoint.MCPStatusInput{ + JQ: `.[] | {target: .target, status: .status, message: .latest_log.message}`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "dummy:#no-record-yet", + "status": "UNKNOWN", + "message": nil, + }, + map[string]any{ + "target": "http://a.example.com", + "status": "HEALTHY", + "message": "hello world!!", + }, + map[string]any{ + "target": "http://b.example.com", + "status": "HEALTHY", + "message": "this is healthy", + }, + map[string]any{ + "target": "http://c.example.com", + "status": "UNKNOWN", + "message": "this is unknown", + }, + }, + }, + }, + } + + RunMCPTest(t, "query_status", tests) +} + +func TestMCPHandler_QueryIncidents(t *testing.T) { + True := true + False := false + + tests := []MCPTest[endpoint.MCPIncidentsInput]{ + { + Name: "without_parameters", + Args: endpoint.MCPIncidentsInput{}, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "http://c.example.com", + "status": "UNKNOWN", + "message": "this is unknown", + "starts_at": "2021-01-02T15:04:09Z", + "starts_at_unix": 1609599849.0, + "ends_at": nil, + "ends_at_unix": nil, + }, + }, + }, + }, + { + Name: "only_ongoing", + Args: endpoint.MCPIncidentsInput{ + IncludeOngoing: &True, + IncludeResolved: false, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "http://c.example.com", + "status": "UNKNOWN", + "message": "this is unknown", + "starts_at": "2021-01-02T15:04:09Z", + "starts_at_unix": 1609599849.0, + "ends_at": nil, + "ends_at_unix": nil, + }, + }, + }, + }, + { + Name: "only_resolved_with_false", + Args: endpoint.MCPIncidentsInput{ + IncludeOngoing: &False, + IncludeResolved: true, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "http://b.example.com", + "status": "FAILURE", + "message": "this is failure", + "starts_at": "2021-01-02T15:04:05Z", + "starts_at_unix": 1609599845.0, + "ends_at": "2021-01-02T15:04:06Z", + "ends_at_unix": 1609599846.0, + }, + }, + }, + }, + { + Name: "without_query", + Args: endpoint.MCPIncidentsInput{ + IncludeOngoing: &True, + IncludeResolved: true, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "http://b.example.com", + "status": "FAILURE", + "message": "this is failure", + "starts_at": "2021-01-02T15:04:05Z", + "starts_at_unix": 1609599845.0, + "ends_at": "2021-01-02T15:04:06Z", + "ends_at_unix": 1609599846.0, + }, + map[string]any{ + "target": "http://c.example.com", + "status": "UNKNOWN", + "message": "this is unknown", + "starts_at": "2021-01-02T15:04:09Z", + "starts_at_unix": 1609599849.0, + "ends_at": nil, + "ends_at_unix": nil, + }, + }, + }, + }, + { + Name: "with_single_result_query", + Args: endpoint.MCPIncidentsInput{ + IncludeOngoing: &True, + IncludeResolved: true, + JQ: `.[] | select(.target == "http://b.example.com") | .message`, + }, + Expect: endpoint.MCPOutput{ + Result: "this is failure", + }, + }, + { + Name: "with_multiple_result_query", + Args: endpoint.MCPIncidentsInput{ + IncludeOngoing: &True, + IncludeResolved: true, + JQ: `.[] | {target: .target, resolved: (.ends_at != null)}`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "http://b.example.com", + "resolved": true, + }, + map[string]any{ + "target": "http://c.example.com", + "resolved": false, + }, + }, + }, + }, + { + Name: "with_no_result_query", + Args: endpoint.MCPIncidentsInput{ + JQ: `.[] | select(.status == "nonexistent")`, + }, + Expect: endpoint.MCPOutput{ + Result: nil, + }, + }, + { + Name: "unclosed_bracket", + Args: endpoint.MCPIncidentsInput{ + JQ: `(.[0]`, + }, + Error: `failed to parse jq query: unexpected EOF`, + }, + { + Name: "iterate_null", + Args: endpoint.MCPIncidentsInput{ + JQ: `.[0].nonexistent[]`, + }, + Error: `cannot iterate over: null`, + }, + { + Name: "unknown_function", + Args: endpoint.MCPIncidentsInput{ + JQ: `.[0] | unknown_function`, + }, + Error: `failed to parse jq query: function not defined: unknown_function/0`, + }, + { + Name: "example_query", + Args: endpoint.MCPIncidentsInput{ + IncludeOngoing: &True, + IncludeResolved: true, + JQ: `.[] | {target: .target, status: .status, message: .message, starts_at: .starts_at, resolved: (.ends_at != null)}`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "http://b.example.com", + "status": "FAILURE", + "message": "this is failure", + "starts_at": "2021-01-02T15:04:05Z", + "resolved": true, + }, + map[string]any{ + "target": "http://c.example.com", + "status": "UNKNOWN", + "message": "this is unknown", + "starts_at": "2021-01-02T15:04:09Z", + "resolved": false, + }, + }, + }, + }, + } + + RunMCPTest(t, "query_incidents", tests) +} + +func TestMCPHandler_QueryLogs(t *testing.T) { + tests := []MCPTest[endpoint.MCPLogsInput]{ + { + Name: "without_params", + Args: endpoint.MCPLogsInput{}, + Error: "since and until parameters are required", + }, + { + Name: "without_since", + Args: endpoint.MCPLogsInput{ + Until: "2021-01-02T15:04:10Z", + }, + Error: "since and until parameters are required", + }, + { + Name: "without_until", + Args: endpoint.MCPLogsInput{ + Since: "2021-01-02T15:04:00Z", + }, + Error: "since and until parameters are required", + }, + { + Name: "without_query", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "latency": "123.456ms", + "latency_ms": 123.456, + "message": "hello world", + "status": "HEALTHY", + "target": "http://a.example.com", + "time": "2021-01-02T15:04:05Z", + "time_unix": 1609599845.0, + }, + map[string]any{ + "latency": "12.345ms", + "latency_ms": 12.345, + "message": "this is failure", + "status": "FAILURE", + "target": "http://b.example.com", + "time": "2021-01-02T15:04:05Z", + "time_unix": 1609599845.0, + }, + map[string]any{ + "latency": "234.567ms", + "latency_ms": 234.567, + "message": "hello world!", + "status": "HEALTHY", + "target": "http://a.example.com", + "time": "2021-01-02T15:04:06Z", + "time_unix": 1609599846.0, + }, + map[string]any{ + "extra": 1.234, + "latency": "54.321ms", + "latency_ms": 54.321, + "message": "this is healthy", + "status": "HEALTHY", + "target": "http://b.example.com", + "time": "2021-01-02T15:04:06Z", + "time_unix": 1609599846.0, + }, + map[string]any{ + "latency": "345.678ms", + "latency_ms": 345.678, + "message": "hello world!!", + "status": "HEALTHY", + "target": "http://a.example.com", + "time": "2021-01-02T15:04:07Z", + "time_unix": 1609599847.0, + }, + map[string]any{ + "hello": "world", + "latency": "1.234ms", + "latency_ms": 1.234, + "message": "this is aborted", + "status": "ABORTED", + "target": "http://c.example.com", + "time": "2021-01-02T15:04:08Z", + "time_unix": 1609599848.0, + }, + map[string]any{ + "extra": []any{1.0, 2.0, 3.0}, + "hoge": "fuga", + "latency": "2.345ms", + "latency_ms": 2.345, + "message": "this is unknown", + "status": "UNKNOWN", + "target": "http://c.example.com", + "time": "2021-01-02T15:04:09Z", + "time_unix": 1609599849.0, + }, + }, + }, + }, + { + Name: "with_single_object_result_query", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + JQ: `.[0]`, + }, + Expect: endpoint.MCPOutput{ + Result: map[string]any{ + "latency": "123.456ms", + "latency_ms": 123.456, + "message": "hello world", + "status": "HEALTHY", + "target": "http://a.example.com", + "time": "2021-01-02T15:04:05Z", + "time_unix": 1609599845.0, + }, + }, + }, + { + Name: "with_single_value_result_query", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + JQ: `.[0].message`, + }, + Expect: endpoint.MCPOutput{ + Result: "hello world", + }, + }, + { + Name: "with_multiple_result_query", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + JQ: `group_by(.target) | map({target: .[0].target, count: length})`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "http://a.example.com", + "count": 3.0, + }, + map[string]any{ + "target": "http://b.example.com", + "count": 2.0, + }, + map[string]any{ + "target": "http://c.example.com", + "count": 2.0, + }, + }, + }, + }, + { + Name: "with_no_result_query", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + JQ: `.[] | select(.target == "dummy:nonexistent")`, + }, + Expect: endpoint.MCPOutput{ + Result: nil, + }, + }, + { + Name: "with_search", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + Search: `message=hello\ world*`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "latency": "123.456ms", + "latency_ms": 123.456, + "message": "hello world", + "status": "HEALTHY", + "target": "http://a.example.com", + "time": "2021-01-02T15:04:05Z", + "time_unix": 1609599845.0, + }, + map[string]any{ + "latency": "234.567ms", + "latency_ms": 234.567, + "message": "hello world!", + "status": "HEALTHY", + "target": "http://a.example.com", + "time": "2021-01-02T15:04:06Z", + "time_unix": 1609599846.0, + }, + map[string]any{ + "latency": "345.678ms", + "latency_ms": 345.678, + "message": "hello world!!", + "status": "HEALTHY", + "target": "http://a.example.com", + "time": "2021-01-02T15:04:07Z", + "time_unix": 1609599847.0, + }, + }, + }, + }, + { + Name: "with_search_and_time_query", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + Search: `message=hello\ world* time=2021-01-02T15:04:06Z`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "latency": "234.567ms", + "latency_ms": 234.567, + "message": "hello world!", + "status": "HEALTHY", + "target": "http://a.example.com", + "time": "2021-01-02T15:04:06Z", + "time_unix": 1609599846.0, + }, + }, + }, + }, + { + Name: "invalid_since", + Args: endpoint.MCPLogsInput{ + Since: "invalid-time-format", + Until: "2021-01-02T15:04:10Z", + }, + Error: `since time must be in RFC3339 format but got "invalid-time-format"`, + }, + { + Name: "invalid_until", + Args: endpoint.MCPLogsInput{ + Since: "2021-01-02T15:04:00Z", + Until: "invalid-time-format", + }, + Error: `until time must be in RFC3339 format but got "invalid-time-format"`, + }, + { + Name: "invalid_query", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + JQ: `.[`, + }, + Error: `failed to parse jq query: unexpected EOF`, + }, + { + Name: "unknown_function", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + JQ: `unknown_function(123)`, + }, + Error: `failed to parse jq query: function not defined: unknown_function/1`, + }, + { + Name: "example_query", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + JQ: `map(select(.status != "HEALTHY")) | group_by(.target)[] | {target: .[0].target, count: length, max_latency: (map(.latency_ms) | max)}`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + map[string]any{ + "target": "http://b.example.com", + "count": 1.0, + "max_latency": 12.345, + }, + map[string]any{ + "target": "http://c.example.com", + "count": 2.0, + "max_latency": 2.345, + }, + }, + }, + }, + } + + RunMCPTest(t, "query_logs", tests) +} + +func TestMCP_connection(t *testing.T) { + tests := []struct { + Name string + WithInstanceName bool + }{ + {"without_instance_name", false}, + {"with_instance_name", true}, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + var opts []testutil.StoreOption + if tt.WithInstanceName { + opts = append(opts, testutil.WithInstanceName("test-instance")) + } + + srv := testutil.StartTestServer(t, opts...) + t.Cleanup(func() { + srv.Close() + }) + + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "none", + }, nil) + sess, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{ + Endpoint: srv.URL + "/mcp", + }, nil) + if err != nil { + t.Fatalf("failed to connect to MCP server: %v", err) + } + defer sess.Close() + + initResult := sess.InitializeResult() + if initResult.ServerInfo.Name != "ayd" { + t.Errorf("unexpected server name: %q", initResult.ServerInfo.Name) + } + if tt.WithInstanceName { + if initResult.ServerInfo.Title != "Ayd (test-instance)" { + t.Errorf("unexpected server title: %q", initResult.ServerInfo.Title) + } + if !strings.Contains(initResult.Instructions, "test-instance") { + t.Errorf("instructions does not contain instance name: %q", initResult.Instructions) + } + } else { + if initResult.ServerInfo.Title != "Ayd" { + t.Errorf("unexpected server title: %q", initResult.ServerInfo.Title) + } + if strings.Contains(initResult.Instructions, "instance") { + t.Errorf("instructions contains instance name: %q", initResult.Instructions) + } + } + + if err := sess.Ping(t.Context(), nil); err != nil { + t.Fatalf("failed to ping MCP server: %v", err) + } + }) + } +} + +func NewTestMCPServer(tb testing.TB, s endpoint.Store) *mcp.ClientSession { + srvPort, cliPort := mcp.NewInMemoryTransports() + + srv := endpoint.MCPServer(s) + srv.Connect(tb.Context(), srvPort, nil) + + cli := mcp.NewClient(&mcp.Implementation{ + Name: "benchmark-client", + Version: "none", + }, nil) + sess, err := cli.Connect(tb.Context(), cliPort, nil) + if err != nil { + tb.Fatalf("failed to connect to MCP server: %v", err) + } + + return sess +} + +func BenchmarkMCPHandler_QueryStatus(b *testing.B) { + sess := NewTestMCPServer(b, testutil.NewStore(b)) + + for b.Loop() { + _, err := sess.CallTool(b.Context(), &mcp.CallToolParams{ + Name: "query_status", + Arguments: endpoint.MCPStatusInput{ + JQ: `.[] | {target: .target, status: .status, message: .latest_log.message}`, + }, + }) + if err != nil { + b.Fatalf("failed to call tool query_status: %v", err) + } + } +} + +func BenchmarkMCPHandler_QueryIncidents(b *testing.B) { + sess := NewTestMCPServer(b, testutil.NewStore(b)) + + for b.Loop() { + _, err := sess.CallTool(b.Context(), &mcp.CallToolParams{ + Name: "query_incidents", + Arguments: endpoint.MCPStatusInput{ + JQ: `.[] | {target: .target, status: .status, message: .message, starts_at: .starts_at, resolved: (.ends_at != null)}`, + }, + }) + if err != nil { + b.Fatalf("failed to call tool query_status: %v", err) + } + } +} + +func BenchmarkMCPHandler_QueryLogs_smallLogs(b *testing.B) { + sess := NewTestMCPServer(b, testutil.NewStore(b)) + + for b.Loop() { + _, err := sess.CallTool(b.Context(), &mcp.CallToolParams{ + Name: "query_logs", + Arguments: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + Search: `status=HEALTHY`, + JQ: `group_by(.target)[] | {target: .[0].target, count: length}`, + }, + }) + if err != nil { + b.Fatalf("failed to call tool query_logs: %v", err) + } + } +} + +func BenchmarkMCPHandler_QueryLogs_largeLogs(b *testing.B) { + s := testutil.NewStore(b) + + var probers []scheme.Prober + for i := range 10 { + probers = append(probers, testutil.NewProber(b, fmt.Sprintf("dummy://random?latency=0ms#%d", i))) + } + + for range 10_000 { + for _, p := range probers { + p.Probe(context.Background(), s) + } + } + + sess := NewTestMCPServer(b, s) + + for b.Loop() { + _, err := sess.CallTool(b.Context(), &mcp.CallToolParams{ + Name: "query_logs", + Arguments: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + Search: `status=HEALTHY`, + JQ: `group_by(.target)[] | {target: .[0].target, count: length}`, + }, + }) + if err != nil { + b.Fatalf("failed to call tool query_logs: %v", err) + } + } +} diff --git a/internal/endpoint/metrics.go b/internal/endpoint/metrics.go index 45e62ab..8fcae09 100644 --- a/internal/endpoint/metrics.go +++ b/internal/endpoint/metrics.go @@ -33,7 +33,7 @@ func MetricsEndpoint(s Store) http.HandlerFunc { m := metricInfo{ Timestamp: last.Time.UnixMilli(), Latency: last.Latency.Seconds(), - Target: strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(hs.Target.String(), "\\", "\\\\"), "\n", "\\\n"), "\"", "\\\""), + Target: strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(hs.Target.String(), `\`, `\\`), "\n", `\n`), `"`, `\"`), } switch last.Status { diff --git a/internal/endpoint/store.go b/internal/endpoint/store.go index f6ce15f..783c3a4 100644 --- a/internal/endpoint/store.go +++ b/internal/endpoint/store.go @@ -7,12 +7,21 @@ import ( ) type Store interface { + // Name returns the Ayd instance name. + Name() string + // Targets returns target URLs include inactive target. Targets() []string // ProbeHistory returns a slice of ProbeHistory. ProbeHistory() []api.ProbeHistory + // CurrentIncidents returns a slice of current incidents. + CurrentIncidents() []*api.Incident + + // IncidentHistory returns a slice of past incidents. + IncidentHistory() []*api.Incident + // MakeReport creates ayd.Report for exporting for endpoint. MakeReport(probeHistoryLength int) api.Report diff --git a/internal/endpoint/templates.go b/internal/endpoint/templates.go index ed41419..4c2dca3 100644 --- a/internal/endpoint/templates.go +++ b/internal/endpoint/templates.go @@ -176,6 +176,9 @@ var ( maxLatency = l } } + if maxLatency == 0 { + return "" + } offset := 20 - len(rs) @@ -248,7 +251,9 @@ var ( sort.Slice(xs, func(i, j int) bool { return xs[i].Key < xs[j].Key }) - xs[len(xs)-1].IsLast = true + if len(xs) > 0 { + xs[len(xs)-1].IsLast = true + } return xs }, } diff --git a/internal/endpoint/templates/base.html b/internal/endpoint/templates/base.html index d0ec312..5e6ccb8 100644 --- a/internal/endpoint/templates/base.html +++ b/internal/endpoint/templates/base.html @@ -30,7 +30,7 @@

- Ayd {{ block "title" . }}{{ end }} + {{ if .InstanceName }}{{ .InstanceName }} - {{ else }}Ayd {{ end }}{{ block "title" . }}{{ end }}