From d831db13e21520a95528da6734c37821360701b3 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 01:26:46 -0700 Subject: [PATCH 1/5] feat(server): accept OAuth access tokens on the HTTP transport Act as an OAuth 2.0 protected resource for MCP clients that cannot send a static header (claude.ai, Claude Desktop and ChatGPT connectors): - Requests to /mcp or /flashduty without a credential get 401 with a WWW-Authenticate challenge carrying resource_metadata. - Serve RFC 9728 protected resource metadata at /.well-known/oauth-protected-resource[/mcp|/flashduty]; the resource is derived from the request origin (X-Forwarded-Proto/Host aware) and the authorization server is the configured Flashduty API base URL. - Bearer credentials prefixed with oauth: are forwarded to the API as bearer access tokens via go-flashduty NewClientWithAccessToken; they ignore the ?base_url= override and use a separate client cache key. Any other bearer value is still treated as an APP key. - Adapt the status page change update to go-flashduty's pointer fields. --- README.md | 10 +- README_zh.md | 12 +- internal/flashduty/context.go | 18 ++- internal/flashduty/server.go | 102 +++++++++++-- internal/flashduty/server_test.go | 238 ++++++++++++++++++++++++++++++ pkg/flashduty/statuspage.go | 4 +- 6 files changed, 358 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 3f06661..49178ab 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,14 @@ The remote Flashduty MCP Server provides the easiest method for getting up and r ### Prerequisites 1. An MCP host that supports the latest MCP specification and remote servers, such as [Cursor](https://www.cursor.com/). -2. A Flashduty APP key from your Flashduty account. +2. A Flashduty account. Hosts that support MCP OAuth sign you in through the browser; other hosts need a Flashduty APP key. ### Installation +#### With OAuth (claude.ai, Claude Desktop, ChatGPT connectors) + +Add a custom connector with the URL `https://mcp.flashcat.cloud/mcp` and no credentials. The host discovers the Flashduty authorization server, opens a Flashduty consent page in your browser, and acts as you through the Flashduty Open API, the same access an APP key grants. + #### For example, with Cursor: @@ -57,7 +61,7 @@ For Cursors that support Remote MCP, use the following configuration: } ``` -> **Note:** Refer to your MCP host's documentation for the correct syntax and location for remote MCP server setup. +> **Note:** Refer to your MCP host's documentation for the correct syntax and location for remote MCP server setup. Requests with neither an OAuth token nor an APP key receive `401` with a `WWW-Authenticate` header pointing to `/.well-known/oauth-protected-resource/mcp`. --- @@ -169,7 +173,7 @@ Here is an example of configuring the remote service, specifying toolsets and re } ``` -- `headers.Authorization`: Your Flashduty APP key for authentication, prefixed with `Bearer `. +- `headers.Authorization`: Your Flashduty APP key for authentication, prefixed with `Bearer `. Omit it when the host signs in with OAuth. - `toolsets=...`: Use a comma-separated list to specify the toolsets to enable (e.g., `incidents,users,channels`). - `read_only=true`: Enables read-only mode. diff --git a/README_zh.md b/README_zh.md index 97b155f..6e61fcb 100644 --- a/README_zh.md +++ b/README_zh.md @@ -34,10 +34,16 @@ Flashduty MCP Server 是一个基于 [Model Context Protocol (MCP)](https://mode ### 前置条件 1. 支持 MCP 协议的客户端,如 [Cursor](https://www.cursor.com/) -2. Flashduty 账户的 APP Key +2. Flashduty 账户。支持 MCP OAuth 的客户端在浏览器中登录授权;其他客户端需要 APP Key ### 配置示例 +#### OAuth 方式(claude.ai、Claude Desktop、ChatGPT 连接器) + +添加自定义连接器,URL 填 `https://mcp.flashcat.cloud/mcp`,不填凭据。客户端会自动发现 Flashduty 授权服务器,在浏览器中打开 Flashduty 授权页;授权后以你的身份调用 Flashduty Open API,权限与 APP Key 相同。 + +#### APP Key 方式 + 以 Cursor 为例: @@ -55,7 +61,7 @@ Flashduty MCP Server 是一个基于 [Model Context Protocol (MCP)](https://mode } ``` -> **提示:** 具体配置位置请参考你所使用的 MCP 客户端文档。 +> **提示:** 具体配置位置请参考你所使用的 MCP 客户端文档。未携带 OAuth Token 或 APP Key 的请求会收到 `401`,`WWW-Authenticate` 头指向 `/.well-known/oauth-protected-resource/mcp`。 --- @@ -156,7 +162,7 @@ Flashduty MCP Server 支持以下配置: } ``` -- `headers.Authorization`:用于认证的 Flashduty APP Key,需添加 `Bearer ` 前缀 +- `headers.Authorization`:用于认证的 Flashduty APP Key,需添加 `Bearer ` 前缀;使用 OAuth 登录时省略 - `toolsets=...`:启用指定的工具集,多个用逗号分隔 - `read_only=true`:启用只读模式 diff --git a/internal/flashduty/context.go b/internal/flashduty/context.go index 3aa65af..34c0082 100644 --- a/internal/flashduty/context.go +++ b/internal/flashduty/context.go @@ -60,12 +60,16 @@ func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) cfg = defaultCfg } - if cfg.APPKey == "" { + if cfg.APPKey == "" && cfg.AccessToken == "" { return ctx, nil, fmt.Errorf("flashduty app key is not configured") } - // Use APP key and BaseURL as cache key to handle different environments. - cacheKey := fmt.Sprintf("%s|%s", cfg.APPKey, cfg.BaseURL) + // Key by credential kind, credential and BaseURL so app keys and OAuth + // access tokens never share an entry, and environments stay separate. + cacheKey := fmt.Sprintf("app_key|%s|%s", cfg.APPKey, cfg.BaseURL) + if cfg.AccessToken != "" { + cacheKey = fmt.Sprintf("access_token|%s|%s", cfg.AccessToken, cfg.BaseURL) + } if cached, err := clientCache.Get(cacheKey); err == nil { clients := cached.(*flashduty.Clients) return contextWithClients(ctx, clients), clients, nil @@ -86,7 +90,13 @@ func getClient(ctx context.Context, defaultCfg FlashdutyConfig, version string) if cfg.BaseURL != "" { newOpts = append(newOpts, goflashduty.WithBaseURL(cfg.BaseURL)) } - newClient, err := goflashduty.NewClient(cfg.APPKey, newOpts...) + var newClient *goflashduty.Client + var err error + if cfg.AccessToken != "" { + newClient, err = goflashduty.NewClientWithAccessToken(cfg.AccessToken, newOpts...) + } else { + newClient, err = goflashduty.NewClient(cfg.APPKey, newOpts...) + } if err != nil { return ctx, nil, fmt.Errorf("failed to create go-flashduty client: %w", err) } diff --git a/internal/flashduty/server.go b/internal/flashduty/server.go index abe480b..aaf2001 100644 --- a/internal/flashduty/server.go +++ b/internal/flashduty/server.go @@ -34,6 +34,10 @@ type FlashdutyConfig struct { // Flashduty APP Key to authenticate with the Flashduty API APPKey string + // AccessToken is an OAuth access token issued by the Flashduty + // authorization server; when set it is used instead of APPKey. + AccessToken string + // EnabledToolsets is a list of toolsets to enable EnabledToolsets []string @@ -274,8 +278,13 @@ type HTTPServerConfig struct { LogFilePath string } -// extractAppKey extracts app_key from Authorization header or query parameters -func extractAppKey(r *http.Request) string { +// oauthTokenPrefix marks access tokens issued by the Flashduty authorization +// server; any other bearer credential is an APP key. +const oauthTokenPrefix = "oauth:" + +// extractCredential extracts the bearer credential from the Authorization +// header, falling back to the app_key query parameter. +func extractCredential(r *http.Request) string { if authHeader := r.Header.Get("Authorization"); authHeader != "" { tokenParts := strings.Split(authHeader, " ") if len(tokenParts) == 2 && strings.ToLower(tokenParts[0]) == "bearer" { @@ -294,21 +303,90 @@ func httpContextFunc(ctx context.Context, r *http.Request, defaultBaseURL string enabledToolsets = strings.Split(toolsets, ",") } - baseURL := queryParams.Get("base_url") - if baseURL == "" { - baseURL = defaultBaseURL - } - cfg := FlashdutyConfig{ - BaseURL: baseURL, - APPKey: extractAppKey(r), + BaseURL: defaultBaseURL, EnabledToolsets: enabledToolsets, ReadOnly: queryParams.Get("read_only") == "true", } + // An OAuth access token is only valid at the API of the authorization + // server that issued it, so it never follows a ?base_url= override. + credential := extractCredential(r) + if strings.HasPrefix(credential, oauthTokenPrefix) { + cfg.AccessToken = credential + } else { + cfg.APPKey = credential + if baseURL := queryParams.Get("base_url"); baseURL != "" { + cfg.BaseURL = baseURL + } + } + return ContextWithConfig(ctx, cfg) } +// requestOrigin returns the scheme and host the client used to reach this +// server, honoring X-Forwarded-Proto/X-Forwarded-Host set by a reverse proxy. +func requestOrigin(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if proto := firstHeaderValue(r, "X-Forwarded-Proto"); proto != "" { + scheme = proto + } + host := r.Host + if fwdHost := firstHeaderValue(r, "X-Forwarded-Host"); fwdHost != "" { + host = fwdHost + } + return scheme + "://" + host +} + +// firstHeaderValue returns the first entry of a possibly comma-separated +// header value, as appended by chained proxies. +func firstHeaderValue(r *http.Request, name string) string { + v, _, _ := strings.Cut(r.Header.Get(name), ",") + return strings.TrimSpace(v) +} + +// requireCredential answers requests that carry no credential with 401 and a +// WWW-Authenticate challenge pointing at the protected resource metadata +// (RFC 9728 §5.1), which starts the MCP OAuth authorization flow. +func requireCredential(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if extractCredential(r) == "" { + metadataURL := requestOrigin(r) + server.WellKnownProtectedResourcePath + r.URL.Path + w.Header().Set("WWW-Authenticate", fmt.Sprintf("Bearer resource_metadata=%q", metadataURL)) + http.Error(w, "missing credential: authenticate with OAuth or send Authorization: Bearer ", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// protectedResourceMetadataHandler serves RFC 9728 metadata for the MCP +// endpoint at path. The resource identifier is derived from the request +// origin; the authorization server is the Flashduty API base URL. +func protectedResourceMetadataHandler(path, authorizationServer string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server.NewProtectedResourceMetadataHandler(server.ProtectedResourceMetadataConfig{ + Resource: requestOrigin(r) + path, + AuthorizationServers: []string{authorizationServer}, + }).ServeHTTP(w, r) + }) +} + +// newHTTPMux routes the MCP endpoints behind requireCredential and serves +// their protected resource metadata. The root well-known path describes /mcp. +func newHTTPMux(mcpHandler http.Handler, baseURL string) *http.ServeMux { + mux := http.NewServeMux() + for _, path := range []string{"/mcp", "/flashduty"} { // /flashduty is kept for backward compatibility + mux.Handle(path, requireCredential(mcpHandler)) + mux.Handle(server.WellKnownProtectedResourcePath+path, protectedResourceMetadataHandler(path, baseURL)) + } + mux.Handle(server.WellKnownProtectedResourcePath, protectedResourceMetadataHandler("/mcp", baseURL)) + return mux +} + func RunHTTPServer(cfg HTTPServerConfig) error { // Set the global output format flashduty.SetOutputFormat(flashduty.ParseOutputFormat(cfg.OutputFormat)) @@ -359,13 +437,9 @@ func RunHTTPServer(cfg HTTPServerConfig) error { return httpContextFunc(ctx, r, cfg.BaseURL) }) - mux := http.NewServeMux() - mux.Handle("/mcp", httpServer) - mux.Handle("/flashduty", httpServer) // Keep for backward compatibility - srv := &http.Server{ Addr: ":" + cfg.Port, - Handler: mux, + Handler: newHTTPMux(httpServer, cfg.BaseURL), ReadHeaderTimeout: 30 * time.Second, ReadTimeout: 0, // No timeout for streaming WriteTimeout: 0, // No timeout for streaming diff --git a/internal/flashduty/server_test.go b/internal/flashduty/server_test.go index 83e7f28..a3899a3 100644 --- a/internal/flashduty/server_test.go +++ b/internal/flashduty/server_test.go @@ -2,13 +2,17 @@ package flashduty import ( "context" + "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" + "slices" "testing" "time" + goflashduty "github.com/flashcatcloud/go-flashduty" + "github.com/flashcatcloud/flashduty-mcp-server/pkg/translations" ) @@ -55,3 +59,237 @@ func TestNewStreamableHTTPServer_RejectsSSEGet(t *testing.T) { t.Fatalf("expected 405 Method Not Allowed for SSE GET, got %d", resp.StatusCode) } } + +// newTestMux returns the HTTP routes with a stub MCP handler that records +// whether a request got through. +func newTestMux(baseURL string) (http.Handler, *bool) { + reached := false + return newHTTPMux(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + }), baseURL), &reached +} + +func TestHTTPMux_MissingCredentialChallenges(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + headers map[string]string + want string + }{ + { + name: "direct", + path: "/mcp", + want: `Bearer resource_metadata="http://mcp.example.com/.well-known/oauth-protected-resource/mcp"`, + }, + { + name: "behind proxy", + path: "/mcp", + headers: map[string]string{"X-Forwarded-Proto": "https, http", "X-Forwarded-Host": "mcp.flashcat.cloud"}, + want: `Bearer resource_metadata="https://mcp.flashcat.cloud/.well-known/oauth-protected-resource/mcp"`, + }, + { + name: "legacy path", + path: "/flashduty", + want: `Bearer resource_metadata="http://mcp.example.com/.well-known/oauth-protected-resource/flashduty"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mux, reached := newTestMux("https://api.flashcat.cloud") + req := httptest.NewRequest(http.MethodPost, "http://mcp.example.com"+tt.path, nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); got != tt.want { + t.Fatalf("WWW-Authenticate = %q, want %q", got, tt.want) + } + if *reached { + t.Fatal("request without credential reached the MCP handler") + } + }) + } +} + +func TestHTTPMux_CredentialPassesThrough(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target string + authorization string + }{ + {name: "app key query", target: "http://mcp.example.com/mcp?app_key=key"}, + {name: "oauth bearer", target: "http://mcp.example.com/mcp", authorization: "Bearer oauth:token"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mux, reached := newTestMux("https://api.flashcat.cloud") + req := httptest.NewRequest(http.MethodPost, tt.target, nil) + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || !*reached { + t.Fatalf("status = %d, reached = %v; want 200 and reached", rec.Code, *reached) + } + }) + } +} + +func TestHTTPMux_ProtectedResourceMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + headers map[string]string + wantResource string + }{ + { + name: "path inserted", + path: "/.well-known/oauth-protected-resource/mcp", + wantResource: "http://mcp.example.com/mcp", + }, + { + name: "root describes /mcp", + path: "/.well-known/oauth-protected-resource", + wantResource: "http://mcp.example.com/mcp", + }, + { + name: "legacy path", + path: "/.well-known/oauth-protected-resource/flashduty", + wantResource: "http://mcp.example.com/flashduty", + }, + { + name: "behind proxy", + path: "/.well-known/oauth-protected-resource/mcp", + headers: map[string]string{"X-Forwarded-Proto": "https", "X-Forwarded-Host": "mcp.flashcat.cloud"}, + wantResource: "https://mcp.flashcat.cloud/mcp", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mux, _ := newTestMux("https://api.flashcat.cloud") + req := httptest.NewRequest(http.MethodGet, "http://mcp.example.com"+tt.path, nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode metadata: %v", err) + } + if got.Resource != tt.wantResource { + t.Fatalf("resource = %q, want %q", got.Resource, tt.wantResource) + } + if !slices.Equal(got.AuthorizationServers, []string{"https://api.flashcat.cloud"}) { + t.Fatalf("authorization_servers = %v", got.AuthorizationServers) + } + }) + } +} + +// upstreamRequest is what a fake Flashduty API observed. +type upstreamRequest struct { + authorization string + appKey string +} + +func newFakeAPI(t *testing.T) (*httptest.Server, *[]upstreamRequest) { + t.Helper() + var seen []upstreamRequest + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, upstreamRequest{ + authorization: r.Header.Get("Authorization"), + appKey: r.URL.Query().Get("app_key"), + }) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"items":[]}}`) + })) + t.Cleanup(ts.Close) + return ts, &seen +} + +// callAPIAs builds the request context the way the HTTP transport does and +// issues one API call through the resulting client. +func callAPIAs(t *testing.T, target, authorization, defaultBaseURL string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, target, nil) + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + ctx := httpContextFunc(context.Background(), req, defaultBaseURL) + ctx, clients, err := getClient(ctx, FlashdutyConfig{}, "test") + if err != nil { + t.Fatalf("getClient: %v", err) + } + if _, _, err := clients.New.Teams.ReadInfos(ctx, &goflashduty.TeamInfosRequest{TeamIDs: []uint64{1}}); err != nil { + t.Fatalf("API call: %v", err) + } +} + +func TestHTTPCredentialRouting(t *testing.T) { + t.Parallel() + + t.Run("oauth token is sent as bearer", func(t *testing.T) { + t.Parallel() + api, seen := newFakeAPI(t) + callAPIAs(t, "http://mcp.example.com/mcp", "Bearer oauth:routing-token", api.URL) + want := []upstreamRequest{{authorization: "Bearer oauth:routing-token"}} + if !slices.Equal(*seen, want) { + t.Fatalf("upstream saw %+v, want %+v", *seen, want) + } + }) + + t.Run("app key is sent as query parameter", func(t *testing.T) { + t.Parallel() + api, seen := newFakeAPI(t) + callAPIAs(t, "http://mcp.example.com/mcp", "Bearer routing-app-key", api.URL) + want := []upstreamRequest{{appKey: "routing-app-key"}} + if !slices.Equal(*seen, want) { + t.Fatalf("upstream saw %+v, want %+v", *seen, want) + } + }) + + t.Run("oauth token ignores base_url override", func(t *testing.T) { + t.Parallel() + api, seen := newFakeAPI(t) + other, otherSeen := newFakeAPI(t) + callAPIAs(t, "http://mcp.example.com/mcp?base_url="+other.URL, "Bearer oauth:base-url-token", api.URL) + if len(*seen) != 1 || len(*otherSeen) != 0 { + t.Fatalf("default API saw %d requests, override saw %d; want 1 and 0", len(*seen), len(*otherSeen)) + } + }) + + t.Run("app key follows base_url override", func(t *testing.T) { + t.Parallel() + api, seen := newFakeAPI(t) + other, otherSeen := newFakeAPI(t) + callAPIAs(t, "http://mcp.example.com/mcp?base_url="+other.URL, "Bearer base-url-app-key", api.URL) + if len(*seen) != 0 || len(*otherSeen) != 1 { + t.Fatalf("default API saw %d requests, override saw %d; want 0 and 1", len(*seen), len(*otherSeen)) + } + }) +} diff --git a/pkg/flashduty/statuspage.go b/pkg/flashduty/statuspage.go index 8ed40e3..4ea74e0 100644 --- a/pkg/flashduty/statuspage.go +++ b/pkg/flashduty/statuspage.go @@ -163,10 +163,10 @@ func CreateStatusIncident(getClient GetFlashdutyClientFn, t translations.Transla // (or the title when no message), and the affected components. update := flashduty.CreateStatusPageChangeRequestUpdatesItem{ AtSeconds: time.Now().Unix(), - Status: status, + Status: &status, } if message != "" { - update.Description = message + update.Description = &message } update.ComponentChanges = parseAffectedComponents(affectedComponents) From 6f2cc6f3b571cdc76bc8be566a93188a94851c1d Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 01:27:56 -0700 Subject: [PATCH 2/5] build(deps): bump go-flashduty for access-token client Pick up NewClientWithAccessToken, used for OAuth access tokens on the HTTP transport. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e427be1..d6651fe 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/bluele/gcache v0.0.2 - github.com/flashcatcloud/go-flashduty v0.5.5 + github.com/flashcatcloud/go-flashduty v0.15.10-0.20261008082335-eec3424e6af1 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.55.1 diff --git a/go.sum b/go.sum index 3aefefe..757f883 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/flashcatcloud/go-flashduty v0.5.5 h1:pXFv9taJlLwoGM9izt+hVCpiNW4N99+9LkNEvomojcE= -github.com/flashcatcloud/go-flashduty v0.5.5/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8= +github.com/flashcatcloud/go-flashduty v0.15.10-0.20261008082335-eec3424e6af1 h1:RfbiM++3ZIfT8fkmWFkR1oTvXtJ2r18RWz5dlV+ouGo= +github.com/flashcatcloud/go-flashduty v0.15.10-0.20261008082335-eec3424e6af1/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= From 125369557ee98dd663c67b98d3ad5f2d33ae8103 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 01:59:38 -0700 Subject: [PATCH 3/5] ci(lint): use the go.mod Go version like the other workflows go-version: stable now resolves to a Go release newer than the one golangci-lint v2.11 was built with, so the linter panics while loading the standard library ("file requires newer Go version"). --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6e87f1c..61131bb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: - go-version: stable + go-version-file: "go.mod" - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: From 7165b0c4a7bbbffe777f480dd04da4f746607d44 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 03:09:36 -0700 Subject: [PATCH 4/5] chore: depend on go-flashduty v0.15.11 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d6651fe..d41b956 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/bluele/gcache v0.0.2 - github.com/flashcatcloud/go-flashduty v0.15.10-0.20261008082335-eec3424e6af1 + github.com/flashcatcloud/go-flashduty v0.15.11 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.55.1 diff --git a/go.sum b/go.sum index 757f883..ff5e58b 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/flashcatcloud/go-flashduty v0.15.10-0.20261008082335-eec3424e6af1 h1:RfbiM++3ZIfT8fkmWFkR1oTvXtJ2r18RWz5dlV+ouGo= -github.com/flashcatcloud/go-flashduty v0.15.10-0.20261008082335-eec3424e6af1/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= +github.com/flashcatcloud/go-flashduty v0.15.11 h1:+WTTIExcKgLmJEg8ARexJM9q/UH8ZlJoI6ExuC2o60M= +github.com/flashcatcloud/go-flashduty v0.15.11/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= From 35e01de23fd9b5ad50e87f94ec8a0dd7cdc4412b Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 8 Oct 2026 03:09:36 -0700 Subject: [PATCH 5/5] ci(docker): build without the GitHub Actions layer cache The build failed whenever the gha cache index pointed at a blob that no longer existed (BlobNotFound on import). The image is a single Go binary that builds in about a minute, so the cache is not worth that failure mode. --- .github/workflows/docker-publish.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3a7afe7..349239f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -103,8 +103,6 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 build-args: | VERSION=${{ github.ref_name }}