Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<span id="remote-cursor"></span>

#### For example, with Cursor:
Expand All @@ -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`.

---

Expand Down Expand Up @@ -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.

Expand Down
12 changes: 9 additions & 3 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 方式

<span id="remote-cursor"></span>

以 Cursor 为例:
Expand All @@ -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`。

---

Expand Down Expand Up @@ -156,7 +162,7 @@ Flashduty MCP Server 支持以下配置:
}
```

- `headers.Authorization`:用于认证的 Flashduty APP Key,需添加 `Bearer ` 前缀
- `headers.Authorization`:用于认证的 Flashduty APP Key,需添加 `Bearer ` 前缀;使用 OAuth 登录时省略
- `toolsets=...`:启用指定的工具集,多个用逗号分隔
- `read_only=true`:启用只读模式

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
18 changes: 14 additions & 4 deletions internal/flashduty/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
102 changes: 88 additions & 14 deletions internal/flashduty/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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" {
Expand All @@ -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 <app_key>", 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))
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading