diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg
index 47441d4d..c742bb27 100644
--- a/.github/badges/coverage.svg
+++ b/.github/badges/coverage.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/internal/app/legacy.go b/internal/app/legacy.go
index 25fe605a..7b77ce82 100644
--- a/internal/app/legacy.go
+++ b/internal/app/legacy.go
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
+ "fmt"
"log/slog"
"net"
"net/http"
@@ -59,6 +60,12 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
// Tests may override discoveryBaseURLOverride to redirect to a local server;
// in that case the registry cache is always bypassed.
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
+ totalStart := time.Now()
+ defer func() {
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] loadDynamicCommands total: %v\n", time.Since(totalStart))
+ }
+ }()
store := cacheStoreFromEnv()
partition := config.DefaultPartition
@@ -70,13 +77,20 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == ""
// --- Cache-first server registry ---
+ cacheLoadStart := time.Now()
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] cache load: %v (err=%v)\n", time.Since(cacheLoadStart), cacheErr)
+ }
+
var servers []market.ServerDescriptor
now := store.Now().UTC()
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
if usingCachedRegistry {
- slog.Debug("loadDynamicCommands: using cached registry", "servers", len(snapshot.Servers), "freshness", freshness)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] using cached registry: servers=%d, freshness=%s\n", len(snapshot.Servers), freshness)
+ }
servers = snapshot.Servers
// Only trigger async revalidation in production (no URL override).
// Tests set discoveryBaseURLOverride and control cache expiry directly,
@@ -92,9 +106,15 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
if discoveryBaseURLOverride != "" {
baseURL = discoveryBaseURLOverride
}
- slog.Debug("loadDynamicCommands: fetching servers from market API", "base_url", baseURL)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] fetching from market API: %s\n", baseURL)
+ }
+ fetchStart := time.Now()
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] market API fetch: %v (err=%v)\n", time.Since(fetchStart), fetchErr)
+ }
if fetchErr != nil {
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
// Degrade to stale cache if available (production only).
@@ -106,12 +126,18 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
} else {
servers = market.NormalizeServers(resp, "market")
- slog.Debug("loadDynamicCommands: normalized servers", "count", len(servers))
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] normalized servers: %d\n", len(servers))
+ }
// Persist fresh data (only in non-test mode).
if useCache {
+ saveStart := time.Now()
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
}
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] cache save: %v\n", time.Since(saveStart))
+ }
}
}
}
@@ -122,9 +148,17 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
+ detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] load details: %v\n", time.Since(detailStart))
+ }
+
+ buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
- slog.Debug("loadDynamicCommands: built dynamic commands", "commands", len(cmds))
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] build commands: %v (count=%d)\n", time.Since(buildStart), len(cmds))
+ }
return cmds
}
diff --git a/internal/app/root.go b/internal/app/root.go
index c6efb026..a35774a1 100644
--- a/internal/app/root.go
+++ b/internal/app/root.go
@@ -51,19 +51,34 @@ const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
// Execute runs the root command and returns the process exit code.
func Execute() int {
+ totalStart := time.Now()
+ defer func() {
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] Execute total: %v\n", time.Since(totalStart))
+ }
+ }()
+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
+ initStart := time.Now()
recovery.ResetRuntimeState()
engine := newPipelineEngine()
root := NewRootCommandWithEngine(ctx, engine)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", time.Since(initStart))
+ }
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
pipeline.RunPreParse(root, engine)
+ execStart := time.Now()
executed, err := root.ExecuteC()
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", time.Since(execStart))
+ }
if err != nil {
if executed == nil {
executed = root
diff --git a/internal/app/runner.go b/internal/app/runner.go
index 25ce2192..3b017cb9 100644
--- a/internal/app/runner.go
+++ b/internal/app/runner.go
@@ -22,6 +22,7 @@ import (
"net/http"
"os"
"strings"
+ "sync"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
@@ -74,6 +75,13 @@ type runtimeRunner struct {
}
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
+ totalStart := time.Now()
+ defer func() {
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] runtimeRunner.Run total: %v\n", time.Since(totalStart))
+ }
+ }()
+
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
@@ -117,7 +125,11 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
}
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
+ authStart := time.Now()
tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders())
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", time.Since(authStart))
+ }
if invocation.DryRun {
return executor.Result{
@@ -148,7 +160,11 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}, nil
}
+ callStart := time.Now()
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", time.Since(callStart))
+ }
if err != nil {
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
@@ -207,25 +223,59 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
if token := strings.TrimSpace(explicitToken); token != "" {
return token
}
- configDir := defaultConfigDir()
- provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
- configureOAuthProviderCompatibility(provider, configDir)
- token, tokenErr := provider.GetAccessToken(ctx)
- if tokenErr == nil && strings.TrimSpace(token) != "" {
- return strings.TrimSpace(token)
- }
- // If the error is a decryption failure (corrupted data), surface
- // it immediately instead of falling back to empty token.
- if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
- slog.Error(tokenErr.Error())
- return ""
- }
- manager := authpkg.NewManager(configDir, nil)
- configureLegacyAuthManagerCompatibility(manager)
- if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
- return strings.TrimSpace(token)
- }
- return ""
+ // Use cached token to avoid repeated Keychain access (~70ms per call)
+ return getCachedRuntimeToken(ctx)
+}
+
+// Cached token state for process lifetime
+var (
+ cachedRuntimeToken string
+ cachedRuntimeTokenOnce sync.Once
+ cachedRuntimeTokenError error
+)
+
+// getCachedRuntimeToken returns a cached access token, loading it only once per process.
+// This avoids repeated Keychain access which takes ~70ms each time.
+func getCachedRuntimeToken(ctx context.Context) string {
+ cachedRuntimeTokenOnce.Do(func() {
+ loadStart := time.Now()
+ defer func() {
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", time.Since(loadStart))
+ }
+ }()
+
+ configDir := defaultConfigDir()
+ provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ configureOAuthProviderCompatibility(provider, configDir)
+ token, tokenErr := provider.GetAccessToken(ctx)
+ if tokenErr == nil && strings.TrimSpace(token) != "" {
+ cachedRuntimeToken = strings.TrimSpace(token)
+ return
+ }
+ // If the error is a decryption failure (corrupted data), record it
+ if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
+ slog.Error(tokenErr.Error())
+ cachedRuntimeTokenError = tokenErr
+ return
+ }
+ // Try legacy manager as fallback
+ manager := authpkg.NewManager(configDir, nil)
+ configureLegacyAuthManagerCompatibility(manager)
+ if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
+ cachedRuntimeToken = strings.TrimSpace(token)
+ return
+ }
+ })
+ return cachedRuntimeToken
+}
+
+// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
+// This should be called after login/logout operations.
+func ResetRuntimeTokenCache() {
+ cachedRuntimeTokenOnce = sync.Once{}
+ cachedRuntimeToken = ""
+ cachedRuntimeTokenError = nil
}
func newRuntimeContentScanner() safety.Scanner {
diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go
index f737d4a1..28be8645 100644
--- a/internal/app/skill_command_test.go
+++ b/internal/app/skill_command_test.go
@@ -421,7 +421,9 @@ func TestSkillAddRequiresAuth(t *testing.T) {
if err == nil {
t.Error("Execute() should have failed without auth")
}
- if !strings.Contains(err.Error(), "not logged in") && !strings.Contains(err.Error(), "token") {
+ // Check for authentication-related error (English or Chinese)
+ errStr := err.Error()
+ if !strings.Contains(errStr, "not logged in") && !strings.Contains(errStr, "token") && !strings.Contains(errStr, "未登录") && !strings.Contains(errStr, "auth") {
t.Errorf("error should mention authentication, got: %v", err)
}
}
diff --git a/internal/app/version.go b/internal/app/version.go
index 2942c201..04410556 100644
--- a/internal/app/version.go
+++ b/internal/app/version.go
@@ -13,7 +13,7 @@
package app
-var version = "v1.0.2"
+var version = "v1.0.6"
// Version returns the current CLI version string, including build metadata
// when injected via ldflags (buildTime, gitCommit).
diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go
index 0f8fb940..2eb5b682 100644
--- a/internal/auth/auth_extra_test.go
+++ b/internal/auth/auth_extra_test.go
@@ -26,6 +26,7 @@ func TestClientID_RuntimeOverride(t *testing.T) {
func TestClientID_EnvFallback(t *testing.T) {
SetClientID("")
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_ID", "env-id")
if got := ClientID(); got != "env-id" {
t.Fatalf("expected env-id, got %s", got)
@@ -34,9 +35,17 @@ func TestClientID_EnvFallback(t *testing.T) {
func TestClientID_Default(t *testing.T) {
SetClientID("")
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_ID", "")
- if got := ClientID(); got != DefaultClientID {
- t.Fatalf("expected default, got %s", got)
+ // When DefaultClientID is a placeholder (starts with "<"), ClientID() returns empty string
+ if strings.HasPrefix(DefaultClientID, "<") {
+ if got := ClientID(); got != "" {
+ t.Fatalf("expected empty string for placeholder, got %s", got)
+ }
+ } else {
+ if got := ClientID(); got != DefaultClientID {
+ t.Fatalf("expected default, got %s", got)
+ }
}
}
@@ -51,6 +60,7 @@ func TestClientSecret_RuntimeOverride(t *testing.T) {
func TestClientSecret_EnvFallback(t *testing.T) {
SetClientSecret("")
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_SECRET", "env-secret")
if got := ClientSecret(); got != "env-secret" {
t.Fatalf("expected env-secret, got %s", got)
@@ -59,6 +69,7 @@ func TestClientSecret_EnvFallback(t *testing.T) {
func TestClientSecret_Default(t *testing.T) {
SetClientSecret("")
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_SECRET", "")
if got := ClientSecret(); got != DefaultClientSecret {
t.Fatalf("expected default, got %s", got)
diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go
index 94f8cc9c..21b44a59 100644
--- a/internal/auth/device_flow.go
+++ b/internal/auth/device_flow.go
@@ -96,6 +96,23 @@ type serviceResult struct {
}
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
+ // Ensure we have a valid client ID (fetch from MCP if not available)
+ if p.clientID == "" {
+ if p.logger != nil {
+ p.logger.Debug("client ID not configured, fetching from MCP server")
+ }
+ mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
+ if mcpErr != nil {
+ return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
+ }
+ p.clientID = mcpClientID
+ // Mark that clientID is from MCP
+ SetClientIDFromMCP(mcpClientID)
+ if p.logger != nil {
+ p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
+ }
+ }
+
const maxAttempts = 3
for attempt := 1; attempt <= maxAttempts; attempt++ {
tokenData, err := p.loginOnce(ctx, attempt)
@@ -150,6 +167,43 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
}
+ // Check if CLI auth is enabled for this organization
+ dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
+ authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
+ if authErr != nil {
+ if p.logger != nil {
+ p.logger.Warn("failed to check CLI auth status", "error", authErr)
+ }
+ // Continue anyway - fail open for better UX
+ } else if authStatus.Success && !authStatus.Result.CLIAuthEnabled {
+ // CLI auth is disabled - show detailed error with admin info
+ _, _ = fmt.Fprintln(p.output(), "")
+ _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
+ _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
+ _, _ = fmt.Fprintln(p.output(), "")
+
+ // Try to get super admin list
+ admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
+ if adminErr == nil && admins.Success && len(admins.Result) > 0 {
+ // Show up to 3 admins
+ maxAdmins := 3
+ if len(admins.Result) < maxAdmins {
+ maxAdmins = len(admins.Result)
+ }
+ var adminNames []string
+ for i := 0; i < maxAdmins; i++ {
+ adminNames = append(adminNames, admins.Result[i].Name)
+ }
+ _, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
+ }
+
+ _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
+ _, _ = fmt.Fprintln(p.output(), "")
+ _, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings"))
+ _, _ = fmt.Fprintln(p.output(), "")
+ return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
+ }
+
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go
index 446cec57..d56201f4 100644
--- a/internal/auth/device_flow_test.go
+++ b/internal/auth/device_flow_test.go
@@ -46,6 +46,10 @@ func writeServiceResult(w http.ResponseWriter, success bool, result any, errCode
func TestRequestDeviceCodeSuccess(t *testing.T) {
t.Parallel()
+ // Set a test client ID
+ SetClientID("test-client-id")
+ t.Cleanup(func() { SetClientID("") })
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go
index af5aa791..797017a7 100644
--- a/internal/auth/endpoints.go
+++ b/internal/auth/endpoints.go
@@ -16,6 +16,7 @@ package auth
import (
"os"
"path/filepath"
+ "strings"
"sync"
)
@@ -59,16 +60,86 @@ const (
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
LogoutContinueURL = "https://login.dingtalk.com"
+
+ // MCP API endpoints for CLI authorization management.
+ DefaultMCPBaseURL = "https://mcp.dingtalk.com"
+ CLIAuthEnabledPath = "/cli/cliAuthEnabled"
+ SuperAdminPath = "/cli/superAdmin"
+ SendCliAuthApplyPath = "/cli/sendCliAuthApply"
+ ClientIDPath = "/cli/clientId"
+
+ // MCP OAuth endpoints (used when clientId is fetched from MCP).
+ MCPOAuthTokenPath = "/oauth2/getToken"
+ MCPRefreshTokenPath = "/oauth2/refreshToken"
+ MCPRevokeTokenPath = "/oauth2/revokeToken"
)
+// GetMCPBaseURL returns the MCP base URL with priority:
+// 1. ~/.dws/mcp_url file content (for pre-release environment)
+// 2. Default value (https://mcp.dingtalk.com)
+func GetMCPBaseURL() string {
+ mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url")
+ if data, err := os.ReadFile(mcpURLPath); err == nil {
+ if url := strings.TrimSpace(string(data)); url != "" {
+ return url
+ }
+ }
+ return DefaultMCPBaseURL
+}
+
// Runtime overrides set via CLI flags (--client-id, --client-secret).
// These take highest priority over environment variables and defaults.
var (
clientMu sync.RWMutex
runtimeClientID string
runtimeClientSecret string
+ // clientIDFromMCP indicates whether the clientID was fetched from MCP server.
+ // When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
+ clientIDFromMCP bool
)
+// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
+func SetClientIDFromMCP(id string) {
+ clientMu.Lock()
+ defer clientMu.Unlock()
+ runtimeClientID = id
+ clientIDFromMCP = true
+}
+
+// IsClientIDFromMCP returns true if the current clientID was fetched from MCP server.
+func IsClientIDFromMCP() bool {
+ clientMu.RLock()
+ defer clientMu.RUnlock()
+ return clientIDFromMCP
+}
+
+// GetUserAccessTokenURL returns the appropriate token exchange URL.
+// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
+func GetUserAccessTokenURL() string {
+ if IsClientIDFromMCP() {
+ return GetMCPBaseURL() + MCPOAuthTokenPath
+ }
+ return UserAccessTokenURL
+}
+
+// GetRefreshTokenURL returns the appropriate token refresh URL.
+// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
+func GetRefreshTokenURL() string {
+ if IsClientIDFromMCP() {
+ return GetMCPBaseURL() + MCPRefreshTokenPath
+ }
+ return UserAccessTokenURL // DingTalk uses same endpoint for refresh
+}
+
+// GetRevokeTokenURL returns the token revocation URL (MCP only).
+// Returns empty string if not using MCP mode.
+func GetRevokeTokenURL() string {
+ if IsClientIDFromMCP() {
+ return GetMCPBaseURL() + MCPRevokeTokenPath
+ }
+ return "" // Direct mode doesn't have revoke endpoint
+}
+
// SetClientID allows runtime override of the client ID (e.g., from CLI flags).
func SetClientID(id string) {
clientMu.Lock()
@@ -87,7 +158,9 @@ func SetClientSecret(secret string) {
// 1. Runtime override (CLI flag --client-id)
// 2. Persisted app config (from previous login)
// 3. Environment variable (DWS_CLIENT_ID)
-// 4. Default hardcoded value
+// 4. Default hardcoded value (if not a placeholder)
+// Returns empty string if no valid client ID is available.
+// Note: MCP server fetch (priority 4 in the full flow) is handled in OAuthProvider.Login()
func ClientID() string {
clientMu.RLock()
override := runtimeClientID
@@ -102,7 +175,11 @@ func ClientID() string {
if v := os.Getenv("DWS_CLIENT_ID"); v != "" {
return v
}
- return DefaultClientID
+ // Only return default if it's not a placeholder
+ if !strings.HasPrefix(DefaultClientID, "<") {
+ return DefaultClientID
+ }
+ return ""
}
// ClientSecret returns the OAuth client secret with priority:
@@ -127,6 +204,13 @@ func ClientSecret() string {
return DefaultClientSecret
}
+// HasValidClientSecret returns true if a valid client secret is available.
+// A valid secret is one that is not a placeholder (e.g., ).
+func HasValidClientSecret() bool {
+ secret := ClientSecret()
+ return secret != "" && !strings.HasPrefix(secret, "<")
+}
+
// getRuntimeCredentials returns the runtime-override credentials if set.
// Returns empty strings if no runtime overrides were provided.
func getRuntimeCredentials() (clientID, clientSecret string) {
diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go
index a5a97ce7..58b948e4 100644
--- a/internal/auth/oauth_helpers.go
+++ b/internal/auth/oauth_helpers.go
@@ -27,6 +27,11 @@ import (
)
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
+ // Use MCP mode if clientID is from MCP server
+ if IsClientIDFromMCP() {
+ return p.exchangeCodeViaMCP(ctx, code)
+ }
+ // Direct mode with client secret
body := map[string]string{
"clientId": ClientID(),
"clientSecret": ClientSecret(),
@@ -40,7 +45,28 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
return p.parseTokenResponse(resp)
}
+// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
+// This is used when client secret is not available (server-side secret management).
+func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
+ url := GetMCPBaseURL() + MCPOAuthTokenPath
+ body := map[string]string{
+ "clientId": ClientID(),
+ "authCode": code,
+ "grantType": "authorization_code",
+ }
+ resp, err := p.postJSON(ctx, url, body)
+ if err != nil {
+ return nil, err
+ }
+ return p.parseMCPTokenResponse(resp)
+}
+
func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) {
+ // Use MCP mode if clientID is from MCP server
+ if IsClientIDFromMCP() {
+ return p.refreshViaMCP(ctx, data)
+ }
+ // Direct mode with client secret
body := map[string]string{
"clientId": ClientID(),
"clientSecret": ClientSecret(),
@@ -67,6 +93,34 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
return updated, nil
}
+// refreshViaMCP refreshes token via MCP proxy.
+func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) {
+ url := GetMCPBaseURL() + MCPRefreshTokenPath
+ body := map[string]string{
+ "clientId": ClientID(),
+ "refreshToken": data.RefreshToken,
+ "grantType": "refresh_token",
+ }
+ resp, err := p.postJSON(ctx, url, body)
+ if err != nil {
+ return nil, err
+ }
+ updated, err := p.parseMCPTokenResponse(resp)
+ if err != nil {
+ return nil, err
+ }
+ updated.PersistentCode = data.PersistentCode
+ updated.CorpID = data.CorpID
+ updated.UserID = data.UserID
+ updated.UserName = data.UserName
+ updated.CorpName = data.CorpName
+
+ if err := SaveTokenData(p.configDir, updated); err != nil {
+ return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
+ }
+ return updated, nil
+}
+
func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any) ([]byte, error) {
b, err := json.Marshal(body)
if err != nil {
@@ -132,6 +186,48 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
return data, nil
}
+// parseMCPTokenResponse parses token response from MCP proxy.
+// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
+func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
+ var resp struct {
+ AccessToken string `json:"accessToken"`
+ RefreshToken string `json:"refreshToken"`
+ PersistentCode string `json:"persistentCode"`
+ ExpiresIn int64 `json:"expiresIn"`
+ CorpID string `json:"corpId"`
+ // Error fields (when request fails)
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMsg string `json:"errorMsg,omitempty"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return nil, fmt.Errorf("parsing MCP token response: %w (body: %s)", err, string(body))
+ }
+ // Check for error response
+ if resp.ErrorCode != "" || resp.ErrorMsg != "" {
+ return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg)
+ }
+ if resp.AccessToken == "" {
+ return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
+ }
+
+ now := time.Now()
+ expiresIn := resp.ExpiresIn
+ if expiresIn <= 0 {
+ expiresIn = config.DefaultAccessTokenExpiry
+ }
+ data := &TokenData{
+ AccessToken: resp.AccessToken,
+ RefreshToken: resp.RefreshToken,
+ ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
+ RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
+ CorpID: resp.CorpID,
+ }
+ if resp.PersistentCode != "" {
+ data.PersistentCode = resp.PersistentCode
+ }
+ return data, nil
+}
+
func buildAuthURL(clientID, redirectURI string) string {
params := url.Values{
"client_id": {clientID},
@@ -149,3 +245,336 @@ const successHTML = `
.card{background:#fff;border-radius:12px;padding:40px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.08)}
h1{color:#1677ff;margin:0 0 8px}p{color:#666;margin:0}