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 }}
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:
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/go.mod b/go.mod
index e427be1..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.5.5
+ 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 3aefefe..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.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.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=
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)