From b9a313a01ac09a43e43cea0019b79111566178fb Mon Sep 17 00:00:00 2001 From: SHIDA Yuma Date: Mon, 17 Nov 2025 18:07:10 +0900 Subject: [PATCH 01/35] feat(endpoint/mcp): support remote MCP server (#44) Close #25 --- .goreleaser.yml | 8 +- Dockerfile | 2 +- Makefile | 2 +- README.md | 10 + cmd/ayd/main.go | 12 +- cmd/ayd/server.go | 12 +- go.mod | 6 + go.sum | 12 + internal/endpoint/endpoint.go | 2 + internal/endpoint/mcp.go | 371 ++++++++++++++++++++ internal/endpoint/mcp_test.go | 626 ++++++++++++++++++++++++++++++++++ internal/meta/version.go | 11 + 12 files changed, 1056 insertions(+), 18 deletions(-) create mode 100644 internal/endpoint/mcp.go create mode 100644 internal/endpoint/mcp_test.go create mode 100644 internal/meta/version.go diff --git a/.goreleaser.yml b/.goreleaser.yml index 7d8db985..1259afab 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 c0ad18ec..16f4f842 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 8f5f1576..6f5c210c 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 6e357d77..f05e6bf6 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/main.go b/cmd/ayd/main.go index 8736fbe4..292c256d 100644 --- a/cmd/ayd/main.go +++ b/cmd/ayd/main.go @@ -9,19 +9,15 @@ 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) + scheme.HTTPUserAgent = fmt.Sprintf("ayd/%s health check", meta.Version) } type AydCommand struct { @@ -53,7 +49,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, }) @@ -119,7 +115,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) { diff --git a/cmd/ayd/server.go b/cmd/ayd/server.go index 795f0368..fc8e545d 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" @@ -40,7 +41,7 @@ func (cmd *AydCommand) reportStartServer(s *store.Store, protocol, listen string Extra: map[string]interface{}{ "url": fmt.Sprintf("%s://%s", protocol, listen), "targets": tasks, - "version": fmt.Sprintf("%s (%s)", version, commit), + "version": fmt.Sprintf("%s (%s)", meta.Version, meta.Commit), }, }) } @@ -54,7 +55,7 @@ func (cmd *AydCommand) reportStopServer(s *store.Store, protocol, listen string) Message: "stop Ayd server", Extra: map[string]interface{}{ "url": fmt.Sprintf("%s://%s", protocol, listen), - "version": fmt.Sprintf("%s (%s)", version, commit), + "version": fmt.Sprintf("%s (%s)", meta.Version, meta.Commit), "since": cmd.StartedAt.Format(time.RFC3339), }, }) @@ -136,8 +137,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/go.mod b/go.mod index 40a95f9e..1d902f08 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,11 @@ 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.17 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.1.0 github.com/pkg/sftp v1.13.10 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/pflag v1.0.10 @@ -24,13 +26,17 @@ require ( ) require ( + github.com/google/jsonschema-go v0.3.0 // 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.6 // 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/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect ) diff --git a/go.sum b/go.sum index dab0134c..d9ec1559 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ 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/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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/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 +20,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.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= 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,6 +38,8 @@ 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.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.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= @@ -62,6 +70,8 @@ github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstf 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= @@ -77,6 +87,8 @@ golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 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= diff --git a/internal/endpoint/endpoint.go b/internal/endpoint/endpoint.go index a6c44159..1b25e078 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/mcp.go b/internal/endpoint/mcp.go new file mode 100644 index 00000000..d9f7bb00 --- /dev/null +++ b/internal/endpoint/mcp.go @@ -0,0 +1,371 @@ +package endpoint + +import ( + "context" + "errors" + "fmt" + "maps" + "net/http" + "net/url" + "strings" + "time" + + "github.com/itchyny/gojq" + "github.com/macrat/ayd/internal/meta" + 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 MCPTargetsInput struct { + Keywords []string `json:"keywords,omitempty" jsonschema:"A list of keywords to filter targets. They work as an AND condition."` +} + +type MCPTargetsOutput struct { + Targets []string `json:"targets" jsonschema:"A list of target URLs that include the keywords."` +} + +func FetchTargets(s Store, input MCPTargetsInput) (output MCPTargetsOutput) { + targets := s.Targets() + + filtered := make([]string, 0, len(targets)) + for _, t := range targets { + matched := true + for _, kw := range input.Keywords { + if !strings.Contains(t, kw) { + matched = false + break + } + } + if matched { + filtered = append(filtered, t) + } + } + + output.Targets = filtered + return output +} + +type MCPOutput struct { + Result any `json:"result" jsonschema:"The result of the query."` + Error string `json:"error,omitempty" jsonschema:"Error message if the query failed."` +} + +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": + 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, v any) MCPOutput { + var outputs []any + + iter := q.Code.RunWithContext(ctx, v) + for { + v, ok := iter.Next() + if !ok { + break + } + if err, ok := v.(error); ok { + return MCPOutput{ + Error: err.Error(), + } + } + outputs = append(outputs, v) + } + + if len(outputs) == 1 { + return MCPOutput{ + Result: outputs[0], + } + } else { + return MCPOutput{ + Result: outputs, + } + } +} + +type MCPStatusInput struct { + Query string `json:"query,omitempty" jsonschema:"A query string to filter status, in jq syntax. Query receives an object like '{\"probe_history\": {\"{target_url}\": {\"status\": \"{status}\", \"updated\": \"{datetime}\", \"records\": [...]}}, \"current_incidents\": [{...}, ...], \"incident_history\": [{...}, ...]}'. You can use 'parse_url' filter to parse target URLs."` +} + +func FetchStatusByJq(ctx context.Context, s Store, input MCPStatusInput) (output MCPOutput) { + defer func() { + if r := recover(); r != nil { + s.ReportInternalError("mcp/query_status", fmt.Sprintf("panic occurred: %v", r)) + output = MCPOutput{ + Error: "internal server error", + } + } + }() + + query, err := ParseJQ(input.Query) + if err != nil { + return MCPOutput{ + Error: fmt.Sprintf("failed to parse query: %v", err), + } + } + + report := s.MakeReport(40) + + obj := map[string]any{} + + obj["probe_history"] = map[string]any{} + for k, v := range report.ProbeHistory { + var updated *string + if !v.Updated.IsZero() { + u := v.Updated.Format(time.RFC3339) + updated = &u + } + + h := map[string]any{ + "status": v.Status.String(), + "updated": updated, + "records": make([]any, len(v.Records)), + } + for i, r := range v.Records { + h["records"].([]any)[i] = recordToMap(r) + } + obj["probe_history"].(map[string]any)[k] = h + } + + obj["current_incidents"] = make([]any, len(report.CurrentIncidents)) + for i, v := range report.CurrentIncidents { + obj["current_incidents"].([]any)[i] = incidentToMap(v) + } + + obj["incident_history"] = make([]any, len(report.IncidentHistory)) + for i, v := range report.IncidentHistory { + obj["incident_history"].([]any)[i] = incidentToMap(v) + } + + return query.Run(ctx, obj) +} + +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."` + Query string `json:"query,omitempty" jsonschema:"A query string to filter logs, in jq syntax. Query receives an array of status objects. Please try '.[0]' to understand the structure if needed. You can use 'parse_url' filter to parse target URLs."` +} + +func FetchLogsByJq(ctx context.Context, s Store, input MCPLogsInput) (output MCPOutput) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + defer func() { + if r := recover(); r != nil { + s.ReportInternalError("mcp/query_logs", fmt.Sprintf("panic occurred: %v", r)) + output = MCPOutput{ + Error: "internal server error", + } + } + }() + + if input.Since == "" || input.Until == "" { + return MCPOutput{ + Error: "since and until parameters are required", + } + } + + since, err := api.ParseTime(input.Since) + if err != nil { + if errors.Is(err, api.ErrInvalidTime) { + return MCPOutput{ + Error: fmt.Sprintf("since time must be in RFC3339 format but got %q", input.Since), + } + } else { + return MCPOutput{ + Error: fmt.Sprintf("invalid since time: %v", err), + } + } + } + until, err := api.ParseTime(input.Until) + if err != nil { + if errors.Is(err, api.ErrInvalidTime) { + return MCPOutput{ + Error: fmt.Sprintf("until time must be in RFC3339 format but got %q", input.Until), + } + } else { + return MCPOutput{ + Error: fmt.Sprintf("invalid until time: %v", err), + } + } + } + + logs, err := s.OpenLog(since, until) + if err != nil { + s.ReportInternalError("mcp/query_logs", fmt.Sprintf("failed to open logs: %v", err)) + return MCPOutput{ + Error: "internal server error", + } + } + defer logs.Close() + + query, err := ParseJQ(input.Query) + if err != nil { + return MCPOutput{ + Error: fmt.Sprintf("failed to parse query: %v", err), + } + } + + records := []any{} + for logs.Scan() { + rec := logs.Record() + records = append(records, recordToMap(rec)) + } + + return query.Run(ctx, records) +} + +func MCPHandler(s Store) http.HandlerFunc { + server := mcp.NewServer(&mcp.Implementation{ + Name: "Ayd", + Version: meta.Version, + }, nil) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_targets", + Title: "List targets", + Description: "List currently monitored target URLs.", + Annotations: &mcp.ToolAnnotations{ + IdempotentHint: true, + ReadOnlyHint: true, + }, + }, func(ctx context.Context, req *mcp.CallToolRequest, input MCPTargetsInput) (*mcp.CallToolResult, MCPTargetsOutput, error) { + output := FetchTargets(s, input) + return nil, output, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "query_status", + Title: "Query status", + Description: "Fetch current status summary using jq query from Ayd server.", + Annotations: &mcp.ToolAnnotations{ + IdempotentHint: true, + ReadOnlyHint: true, + }, + }, func(ctx context.Context, req *mcp.CallToolRequest, input MCPStatusInput) (*mcp.CallToolResult, MCPOutput, error) { + output := FetchStatusByJq(ctx, s, input) + return nil, output, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "query_logs", + Title: "Query logs", + Description: "Fetch health check logs using jq query from Ayd server.", + Annotations: &mcp.ToolAnnotations{ + IdempotentHint: true, + ReadOnlyHint: true, + }, + }, func(ctx context.Context, req *mcp.CallToolRequest, input MCPLogsInput) (*mcp.CallToolResult, MCPOutput, error) { + output := FetchLogsByJq(ctx, s, input) + return nil, output, nil + }) + + handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{ + Stateless: true, + JSONResponse: true, + }) + + return handler.ServeHTTP +} diff --git a/internal/endpoint/mcp_test.go b/internal/endpoint/mcp_test.go new file mode 100644 index 00000000..fa30b0be --- /dev/null +++ b/internal/endpoint/mcp_test.go @@ -0,0 +1,626 @@ +package endpoint_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/macrat/ayd/internal/endpoint" + "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: `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": []any{"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)`, + }, + } + + for _, tt := range tests { + t.Run(tt.Query, func(t *testing.T) { + jq, err := endpoint.ParseJQ(tt.Query) + if err != nil { + t.Fatalf("failed to parse JQ query: %v", err) + } + + output := jq.Run(context.Background(), input) + + if output.Error != tt.Error { + if tt.Error == "" { + t.Fatalf("unexpected error: %v", output.Error) + } else { + t.Fatalf("expected error %q, got %q", tt.Error, output.Error) + } + } else { + if diff := cmp.Diff(tt.Expect, output.Result); diff != "" { + t.Errorf("output mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +type MCPTest[I, O any] struct { + Name string + Args I + Expect O +} + +func RunMCPTest[I, O any](t *testing.T, tool string, tests []MCPTest[I, O]) { + 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) + } + + var resultData O + 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) + } + }) + } +} + +func TestMCPHandler_ListTargets(t *testing.T) { + tests := []MCPTest[endpoint.MCPTargetsInput, endpoint.MCPTargetsOutput]{ + { + Name: "no_filter", + Args: endpoint.MCPTargetsInput{}, + Expect: endpoint.MCPTargetsOutput{ + Targets: []string{ + "dummy:#no-record-yet", + "http://a.example.com", + "http://b.example.com", + "http://c.example.com", + }, + }, + }, + { + Name: "single_keyword_1", + Args: endpoint.MCPTargetsInput{ + Keywords: []string{"a."}, + }, + Expect: endpoint.MCPTargetsOutput{ + Targets: []string{ + "http://a.example.com", + }, + }, + }, + { + Name: "single_keyword_2", + Args: endpoint.MCPTargetsInput{ + Keywords: []string{"example"}, + }, + Expect: endpoint.MCPTargetsOutput{ + Targets: []string{ + "http://a.example.com", + "http://b.example.com", + "http://c.example.com", + }, + }, + }, + { + Name: "multiple_keywords", + Args: endpoint.MCPTargetsInput{ + Keywords: []string{"b", "c"}, + }, + Expect: endpoint.MCPTargetsOutput{ + Targets: []string{ + "http://b.example.com", + }, + }, + }, + { + Name: "no_match", + Args: endpoint.MCPTargetsInput{ + Keywords: []string{"nonexistent"}, + }, + Expect: endpoint.MCPTargetsOutput{ + Targets: []string{}, + }, + }, + } + + RunMCPTest(t, "list_targets", tests) +} + +func TestMCPHandler_QueryStatus(t *testing.T) { + tests := []MCPTest[endpoint.MCPStatusInput, endpoint.MCPOutput]{ + { + Name: "without_query", + Args: endpoint.MCPStatusInput{}, + Expect: endpoint.MCPOutput{ + Result: map[string]any{ + "current_incidents": []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, + }, + }, + "incident_history": []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, + }, + }, + "probe_history": map[string]any{ + "dummy:#no-record-yet": map[string]any{ + "records": []any{}, + "status": "UNKNOWN", + "updated": nil, + }, + "http://a.example.com": map[string]any{ + "status": "HEALTHY", + "updated": "2021-01-02T15:04:07Z", + "records": []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, + }, + }, + }, + "http://b.example.com": map[string]any{ + "records": []any{ + 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{ + "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, + }, + }, + "status": "HEALTHY", + "updated": "2021-01-02T15:04:06Z", + }, + "http://c.example.com": map[string]any{ + "records": []any{ + 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, + }, + }, + "status": "UNKNOWN", + "updated": "2021-01-02T15:04:09Z", + }, + }, + }, + }, + }, + { + Name: "with_single_result_query", + Args: endpoint.MCPStatusInput{ + Query: `.probe_history["http://a.example.com"].records[0].message`, + }, + Expect: endpoint.MCPOutput{ + Result: "hello world", + }, + }, + { + Name: "with_multiple_result_query", + Args: endpoint.MCPStatusInput{ + Query: `.probe_history["http://a.example.com"].records[] | select(.status == "HEALTHY") | .message`, + }, + Expect: endpoint.MCPOutput{ + Result: []any{ + "hello world", + "hello world!", + "hello world!!", + }, + }, + }, + { + Name: "with_no_result_query", + Args: endpoint.MCPStatusInput{ + Query: `.probe_history[] | select(.status == "nonexistent")`, + }, + Expect: endpoint.MCPOutput{ + Result: nil, + }, + }, + { + Name: "invalid_query", + Args: endpoint.MCPStatusInput{ + Query: `(.incident_history`, + }, + Expect: endpoint.MCPOutput{ + Error: `failed to parse query: unexpected EOF`, + }, + }, + { + Name: "unknown_function", + Args: endpoint.MCPStatusInput{ + Query: `.probe_history | unknown_function`, + }, + Expect: endpoint.MCPOutput{ + Error: `failed to parse query: function not defined: unknown_function/0`, + }, + }, + } + + RunMCPTest(t, "query_status", tests) +} + +func TestMCPHandler_QueryLogs(t *testing.T) { + tests := []MCPTest[endpoint.MCPLogsInput, endpoint.MCPOutput]{ + { + Name: "without_params", + Args: endpoint.MCPLogsInput{}, + Expect: endpoint.MCPOutput{ + Error: "since and until parameters are required", + }, + }, + { + Name: "without_since", + Args: endpoint.MCPLogsInput{ + Until: "2021-01-02T15:04:10Z", + }, + Expect: endpoint.MCPOutput{ + Error: "since and until parameters are required", + }, + }, + { + Name: "without_until", + Args: endpoint.MCPLogsInput{ + Since: "2021-01-02T15:04:00Z", + }, + Expect: endpoint.MCPOutput{ + 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", + Query: `.[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", + Query: `.[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", + Query: `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", + Query: `.[] | select(.target == "dummy:nonexistent")`, + }, + Expect: endpoint.MCPOutput{ + Result: nil, + }, + }, + { + Name: "invalid_since", + Args: endpoint.MCPLogsInput{ + Since: "invalid-time-format", + Until: "2021-01-02T15:04:10Z", + }, + Expect: endpoint.MCPOutput{ + 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", + }, + Expect: endpoint.MCPOutput{ + 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", + Query: `.[`, + }, + Expect: endpoint.MCPOutput{ + Error: `failed to parse query: unexpected EOF`, + }, + }, + { + Name: "unknown_function", + Args: endpoint.MCPLogsInput{ + Since: "2000-01-01T00:00:00Z", + Until: "2100-01-01T00:00:00Z", + Query: `unknown_function(123)`, + }, + Expect: endpoint.MCPOutput{ + Error: `failed to parse query: function not defined: unknown_function/1`, + }, + }, + } + + RunMCPTest(t, "query_logs", tests) +} diff --git a/internal/meta/version.go b/internal/meta/version.go new file mode 100644 index 00000000..f9e35275 --- /dev/null +++ b/internal/meta/version.go @@ -0,0 +1,11 @@ +package meta + +var ( + // Version is the semantic version of the application. + // This value is injected at build time via ldflags. + Version = "HEAD" + + // Commit is the git commit hash. + // This value is injected at build time via ldflags. + Commit = "UNKNOWN" +) From 404ba095e04dfa68b6ef564129a91f41ba35a1f7 Mon Sep 17 00:00:00 2001 From: SHIDA Yuma Date: Sat, 22 Nov 2025 14:53:50 +0900 Subject: [PATCH 02/35] feat: implement Instance Name feature (#48) Co-authored-by: Claude Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/ayd/help.txt | 1 + cmd/ayd/main.go | 26 +++++----- cmd/ayd/main_test.go | 18 +++++++ cmd/ayd/server.go | 28 ++++++---- cmd/ayd/server_test.go | 4 +- go.mod | 4 +- go.sum | 12 ++--- internal/endpoint/benchmark_test.go | 2 +- internal/endpoint/healthz_test.go | 4 ++ internal/endpoint/log.go | 2 + internal/endpoint/log_fuzz_test.go | 2 +- internal/endpoint/log_test.go | 8 +-- internal/endpoint/mcp.go | 20 +++++-- internal/endpoint/mcp_test.go | 63 ++++++++++++++++++++++- internal/endpoint/store.go | 3 ++ internal/endpoint/templates/base.html | 4 +- internal/endpoint/testdata/incidents.html | 2 +- internal/endpoint/testdata/log.html | 2 +- internal/endpoint/testdata/status.html | 2 +- internal/logconv/csv_test.go | 2 +- internal/logconv/ltsv_test.go | 2 +- internal/logconv/xlsx_test.go | 2 +- internal/scheme/http.go | 5 ++ internal/store/scanner_test.go | 6 +-- internal/store/store.go | 10 +++- internal/store/store_test.go | 50 +++++++++++++----- internal/testutil/endpoint.go | 4 +- internal/testutil/store.go | 63 ++++++++++++++++------- lib-ayd/report.go | 3 ++ 29 files changed, 264 insertions(+), 90 deletions(-) diff --git a/cmd/ayd/help.txt b/cmd/ayd/help.txt index 13be6bcc..6da38529 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 292c256d..58cfe69f 100644 --- a/cmd/ayd/main.go +++ b/cmd/ayd/main.go @@ -16,23 +16,20 @@ import ( "github.com/spf13/pflag" ) -func init() { - scheme.HTTPUserAgent = fmt.Sprintf("ayd/%s health check", meta.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 @@ -60,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") @@ -133,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 065bebd0..a9b270e1 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/server.go b/cmd/ayd/server.go index fc8e545d..a8771182 100644 --- a/cmd/ayd/server.go +++ b/cmd/ayd/server.go @@ -33,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)", meta.Version, meta.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)", meta.Version, meta.Commit), - "since": cmd.StartedAt.Format(time.RFC3339), - }, + Extra: extra, }) } diff --git a/cmd/ayd/server_test.go b/cmd/ayd/server_test.go index 0f5fe1ab..7470d485 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 1d902f08..d7a7549e 100644 --- a/go.mod +++ b/go.mod @@ -20,9 +20,9 @@ require ( 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.43.0 + golang.org/x/crypto v0.44.0 golang.org/x/sys v0.38.0 - golang.org/x/text v0.30.0 + golang.org/x/text v0.31.0 ) require ( diff --git a/go.sum b/go.sum index d9ec1559..d89da738 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,8 @@ 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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= 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= @@ -96,12 +96,12 @@ golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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= gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/internal/endpoint/benchmark_test.go b/internal/endpoint/benchmark_test.go index e328f9b2..82f94a78 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/healthz_test.go b/internal/endpoint/healthz_test.go index c831a63e..dd3c8583 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 "" } diff --git a/internal/endpoint/log.go b/internal/endpoint/log.go index 926e2968..ed20286b 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 6b53e177..4ff639ea 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 8cee439f..65200224 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 index d9f7bb00..37b392de 100644 --- a/internal/endpoint/mcp.go +++ b/internal/endpoint/mcp.go @@ -109,7 +109,7 @@ func jqParseURL(x any, _ []any) any { if u.Opaque != "" && u.Host == "" { switch u.Scheme { - case "ping": + case "ping", "ping4", "ping6": u.Host = u.Opaque u.Opaque = "" case "dns", "dns4", "dns6", "file", "exec", "mailto", "source": @@ -316,10 +316,22 @@ func FetchLogsByJq(ctx context.Context, s Store, input MCPLogsInput) (output MCP } func MCPHandler(s Store) http.HandlerFunc { - server := mcp.NewServer(&mcp.Implementation{ - Name: "Ayd", + impl := &mcp.Implementation{ + Name: "ayd", Version: meta.Version, - }, nil) + 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 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: "list_targets", diff --git a/internal/endpoint/mcp_test.go b/internal/endpoint/mcp_test.go index fa30b0be..08ef632c 100644 --- a/internal/endpoint/mcp_test.go +++ b/internal/endpoint/mcp_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -50,7 +51,7 @@ func TestJQQuery(t *testing.T) { "hostname": "example.com", "port": "", "path": "/path", - "queries": map[string][]any{"query": []any{"value"}}, + "queries": map[string][]any{"query": {"value"}}, "fragment": "fragment", "opaque": "", }, @@ -624,3 +625,63 @@ func TestMCPHandler_QueryLogs(t *testing.T) { 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) + } + }) + } +} diff --git a/internal/endpoint/store.go b/internal/endpoint/store.go index f6ce15fb..92a539c2 100644 --- a/internal/endpoint/store.go +++ b/internal/endpoint/store.go @@ -7,6 +7,9 @@ import ( ) type Store interface { + // Name returns the Ayd instance name. + Name() string + // Targets returns target URLs include inactive target. Targets() []string diff --git a/internal/endpoint/templates/base.html b/internal/endpoint/templates/base.html index d0ec3122..5e6ccb87 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 }}