From 870ae88e299b97cef1804dc4ee1c7857bd7a54d8 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 22 Jun 2026 11:02:55 +0800 Subject: [PATCH 01/20] docs: Align docs with current CLI behavior Update stale documentation and user-facing MCP OAuth guidance to match the current dialog-based flows, SDK permission semantics, current links, and Qwen OAuth status. Also replace Ink internal imports with public Ink APIs for the shared text input so the workspace builds against Ink 7. Co-authored-by: Qwen-Coder --- CONTRIBUTING.md | 2 +- docs/developers/contributing.md | 2 +- docs/developers/daemon/00-index.md | 2 +- docs/developers/daemon/12-auth-security.md | 4 +- .../developers/daemon/13-sdk-daemon-client.md | 4 + docs/developers/development/telemetry.md | 2 +- docs/developers/qwen-serve-protocol.md | 9 +- docs/developers/sdk-java.md | 2 +- docs/developers/sdk-typescript.md | 8 +- docs/developers/tools/introduction.md | 8 +- docs/developers/tools/mcp-server.md | 31 ++--- docs/users/configuration/settings.md | 22 +-- docs/users/configuration/themes.md | 11 +- docs/users/extension/extension-releasing.md | 2 +- docs/users/extension/introduction.md | 18 +-- docs/users/features/arena.md | 6 +- docs/users/features/channels/overview.md | 2 +- docs/users/features/channels/plugins.md | 2 +- docs/users/features/headless.md | 2 +- docs/users/features/mcp.md | 21 ++- docs/users/features/sandbox.md | 4 +- docs/users/features/tool-use-summaries.md | 2 +- docs/users/integration-github-action.md | 4 +- docs/users/qwen-serve.md | 10 +- docs/users/support/tos-privacy.md | 2 +- packages/channels/base/README.md | 2 +- packages/channels/plugin-example/README.md | 4 +- packages/cli/src/ui/commands/mcpCommand.ts | 2 +- .../cli/src/ui/components/BaseTextInput.tsx | 131 ++++++------------ .../cli/src/ui/components/views/McpStatus.tsx | 3 +- .../__snapshots__/McpStatus.test.tsx.snap | 2 +- packages/core/src/mcp/oauth-provider.test.ts | 3 +- packages/core/src/tools/mcp-client.ts | 43 ++++-- packages/sdk-java/client/README.md | 2 +- packages/sdk-typescript/README.md | 10 +- packages/sdk-typescript/src/types/types.ts | 37 ++--- .../completions/slashCompletion.test.ts | 2 +- .../client/constants/localCommands.ts | 2 +- 38 files changed, 210 insertions(+), 215 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b96c586b6fa..74bde1f007b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,7 +148,7 @@ To run the integration tests, use the following command: npm run test:e2e ``` -For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./docs/integration-tests.md). +For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./docs/developers/development/integration-tests.md). ### Linting and Preflight Checks diff --git a/docs/developers/contributing.md b/docs/developers/contributing.md index 9dc512cb246..86818f34253 100644 --- a/docs/developers/contributing.md +++ b/docs/developers/contributing.md @@ -138,7 +138,7 @@ To run the integration tests, use the following command: npm run test:e2e ``` -For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./docs/integration-tests.md). +For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./development/integration-tests.md). ### Linting and Preflight Checks diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index 5990e6e34ed..dba05a2b3bd 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -9,7 +9,7 @@ It complements, rather than replaces, these existing docs: | [`../../users/qwen-serve.md`](../../users/qwen-serve.md) | Operators | User quickstart, flags, threat model | | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md) | Protocol implementers | HTTP route catalog, request/response shapes, error codes | | [`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md) | SDK users | End-to-end TypeScript walkthrough | -| [`../daemon-client-adapters/`](../daemon-client-adapters/) | Adapter authors | Client adapter design notes | +| [`../daemon-client-adapters/tui.md`](../daemon-client-adapters/tui.md) | Adapter authors | Client adapter design notes | | [`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) | F2 maintainers | Workspace MCP transport pool design v2.2 | If you want to **start a daemon and use it**, read `qwen-serve.md` first. If you want to **build a client against the wire format**, read `qwen-serve-protocol.md`. If you want to **understand, extend, or debug the daemon internals**, read this set. diff --git a/docs/developers/daemon/12-auth-security.md b/docs/developers/daemon/12-auth-security.md index e705ee1790b..f4d93dbf394 100644 --- a/docs/developers/daemon/12-auth-security.md +++ b/docs/developers/daemon/12-auth-security.md @@ -171,7 +171,9 @@ details. ### Device-flow auth -Separate OAuth surface for provider authentication (Qwen OAuth, etc.): +Separate OAuth surface for provider authentication. The v1 provider identifier is +`qwen-oauth`, but Qwen OAuth free tier was discontinued on 2026-04-15; new +setups should use a currently supported auth provider when one is available. - `POST /workspace/auth/device-flow` — start a flow; returns `{deviceFlowId, providerId, expiresAt, verificationUrl, userCode}`. - `GET /workspace/auth/device-flow/:id` — poll state. diff --git a/docs/developers/daemon/13-sdk-daemon-client.md b/docs/developers/daemon/13-sdk-daemon-client.md index 299c3e0d319..8343c13f417 100644 --- a/docs/developers/daemon/13-sdk-daemon-client.md +++ b/docs/developers/daemon/13-sdk-daemon-client.md @@ -201,6 +201,10 @@ sequenceDiagram AF-->>App: final state ``` +`qwen-oauth` is the legacy v1 provider identifier. Qwen OAuth free tier was +discontinued on 2026-04-15, so new clients should prefer a currently supported +auth provider when one is available. + ## State & Lifecycle - `DaemonClient` is connection-less; nothing happens at construction. Every method opens a fresh `fetch`. diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index 266ce05cc83..53361864855 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -125,7 +125,7 @@ OpenTelemetry names: `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, The `QWEN_TELEMETRY_OTLP_*` variants take precedence over the `OTEL_*` variants. For detailed information about all configuration options, see the -[Configuration Guide](./cli/configuration.md). +[Configuration Guide](../../users/configuration/settings.md). ### Resource attributes diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index fceb46fe21d..6f3019a333a 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -1559,7 +1559,14 @@ After a successful vote, every connected client sees `permission_resolved` with The daemon brokers an OAuth 2.0 Device Authorization Grant (RFC 8628) so a remote SDK client can trigger a login whose tokens land on the **daemon** filesystem — not on the client. The daemon polls the IdP itself; the client's only job is to display the verification URL + user code and (optionally) subscribe to SSE for completion events. -Capability tag: `auth_device_flow` (always advertised). Supported providers in v1: `qwen-oauth`. +Capability tag: `auth_device_flow` (always advertised). Supported providers in +v1: `qwen-oauth`. + +> [!note] +> +> Qwen OAuth free tier was discontinued on 2026-04-15. Treat `qwen-oauth` as the +> legacy v1 provider identifier in this protocol; new clients should prefer a +> currently supported auth provider when one is available. **Runtime locality.** The daemon never spawns a browser — even if it can. The client decides whether to call `open(verificationUri)` locally; on a headless pod (the canonical Mode B deployment) the user opens the URL on whatever device they have a browser on. See `docs/users/qwen-serve.md` for the recommended UX. diff --git a/docs/developers/sdk-java.md b/docs/developers/sdk-java.md index 8e2a8dbfec2..7c001977985 100644 --- a/docs/developers/sdk-java.md +++ b/docs/developers/sdk-java.md @@ -308,4 +308,4 @@ A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment vari ## License -Apache-2.0 - see [LICENSE](./LICENSE) for details. +Apache-2.0 - see [LICENSE](../../LICENSE) for details. diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index 253a16815bd..df1782e5b43 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -68,10 +68,10 @@ Creates a new query session with the Qwen Code. | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | | `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Equivalent to `tool.core` in settings.json. If specified, only these tools will be available to the AI. Example: `['read_file', 'write_file', 'run_shell_command']`. | -| `excludeTools` | `string[]` | - | Equivalent to `tool.exclude` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports pattern matching: tool name (`'write_file'`), tool class (`'ShellTool'`), or shell command prefix (`'ShellTool(rm )'`). | -| `allowedTools` | `string[]` | - | Equivalent to `tool.allowed` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. | -| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Using `'qwen-oauth'` in SDK is not recommended as credentials are stored in `~/.qwen` and may need periodic refresh. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['Read', 'Edit', 'Bash(git *)']`. | +| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | +| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | | `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | | `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | diff --git a/docs/developers/tools/introduction.md b/docs/developers/tools/introduction.md index ed6bc8a8aa3..ba7ac1b2641 100644 --- a/docs/developers/tools/introduction.md +++ b/docs/developers/tools/introduction.md @@ -35,7 +35,7 @@ You will typically see messages in the CLI indicating when a tool is being calle Many tools, especially those that can modify your file system or execute commands (`write_file`, `edit`, `run_shell_command`), are designed with safety in mind. Qwen Code will typically: - **Require confirmation:** Prompt you before executing potentially sensitive operations, showing you what action is about to be taken. -- **Utilize sandboxing:** All tools are subject to restrictions enforced by sandboxing (see [Sandboxing in Qwen Code](../sandbox.md)). This means that when operating in a sandbox, any tools (including MCP servers) you wish to use must be available _inside_ the sandbox environment. For example, to run an MCP server through `npx`, the `npx` executable must be installed within the sandbox's Docker image or be available in the `sandbox-exec` environment. +- **Utilize sandboxing:** All tools are subject to restrictions enforced by sandboxing (see [Sandboxing in Qwen Code](./sandbox.md)). This means that when operating in a sandbox, any tools (including MCP servers) you wish to use must be available _inside_ the sandbox environment. For example, to run an MCP server through `npx`, the `npx` executable must be installed within the sandbox's Docker image or be available in the `sandbox-exec` environment. It's important to always review confirmation prompts carefully before allowing a tool to proceed. @@ -54,8 +54,6 @@ Qwen Code's built-in tools can be broadly categorized as follows: Additionally, these tools incorporate: - **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the model and your local environment or other services like APIs. - - **[MCP Quick Start Guide](../mcp-quick-start.md)**: Get started with MCP in 5 minutes with practical examples - - **[MCP Example Configurations](../mcp-example-configs.md)**: Ready-to-use configurations for common scenarios + - **[MCP User Guide](../../users/features/mcp.md)**: Configure MCP servers and manage them from Qwen Code - **[Web Search via MCP](./web-search.md)**: Connect to web search services (Bailian, Tavily, GLM) through MCP - - **[MCP Testing & Validation](../mcp-testing-validation.md)**: Test and validate your MCP server setups -- **[Sandboxing](../sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk. +- **[Sandboxing](./sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk. diff --git a/docs/developers/tools/mcp-server.md b/docs/developers/tools/mcp-server.md index a340b6899b7..dcbd0d0f2a8 100644 --- a/docs/developers/tools/mcp-server.md +++ b/docs/developers/tools/mcp-server.md @@ -183,18 +183,8 @@ OAuth will not work in: #### Managing OAuth Authentication -Use the `/mcp auth` command to manage OAuth authentication: - -```bash -# List servers requiring authentication -/mcp auth - -# Authenticate with a specific server -/mcp auth serverName - -# Re-authenticate if tokens expire -/mcp auth serverName -``` +Use the `/mcp` dialog inside an interactive Qwen Code session to inspect MCP +servers and manage OAuth authentication. #### OAuth Configuration Properties @@ -212,7 +202,7 @@ Use the `/mcp auth` command to manage OAuth authentication: OAuth tokens are automatically: -- **Stored securely** in `~/.qwen/mcp-oauth-tokens-v2.json` (AES-256-GCM encrypted), with keychain storage preferred when available +- **Stored** in `~/.qwen/mcp-oauth-tokens.json` by default. If `QWEN_CODE_FORCE_ENCRYPTED_FILE_STORAGE=true` is set, Qwen Code uses encrypted file storage/keychain-backed storage where available. - **Refreshed** when expired (if refresh tokens are available) - **Validated** before each connection attempt - **Cleaned up** when invalid or expired @@ -859,9 +849,10 @@ qwen mcp add --transport sse oauth-server https://api.example.com/sse/ \ --oauth-token-url https://provider.example.com/token ``` -### Managing Servers (`qwen mcp`) +### Managing Servers (`/mcp`) -To view and manage all MCP servers currently configured, use the `manage` command or simply `qwen mcp`. This opens an interactive TUI dialog where you can: +To view and manage all MCP servers currently configured, open the `/mcp` +dialog inside an interactive Qwen Code session. This dialog lets you: - View all MCP servers with their connection status - Enable/disable servers @@ -872,9 +863,13 @@ To view and manage all MCP servers currently configured, use the `manage` comman **Command:** ```bash -qwen mcp -# or -qwen mcp manage +qwen +``` + +Then enter: + +```text +/mcp ``` The management dialog provides a visual interface showing each server's name, configuration details, connection status, and available tools/prompts. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 28b687f83f5..f07fd180769 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -480,7 +480,7 @@ Configures connections to one or more Model-Context Protocol (MCP) servers for d #### telemetry -Configures logging and metrics collection for Qwen Code. For more information, see [telemetry](/developers/development/telemetry). +Configures logging and metrics collection for Qwen Code. For more information, see [telemetry](../../developers/development/telemetry.md). | Setting | Type | Description | Default | | ------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | @@ -640,11 +640,11 @@ For sandbox image selection, precedence is: | `--approval-mode` | | Sets the approval mode for tool calls. | `plan`, `default`, `auto-edit`, `auto`, `yolo` | Supported modes: `plan`: Analyze only—do not modify files or execute commands. `default`: Require approval for file edits or shell commands (default behavior). `auto-edit`: Automatically approve edit tools (`edit`, `write_file`, `notebook_edit`) while prompting for others. `auto`: LLM classifier auto-approves safe actions and blocks risky ones. `yolo`: Automatically approve all tool calls (equivalent to `--yolo`). Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. Example: `qwen --approval-mode auto-edit`
See more about [Approval Mode](../features/approval-mode). | | `--allowed-tools` | | A comma-separated list of tool names that will bypass the confirmation dialog. | Tool names | Example: `qwen --allowed-tools "Shell(git status)"` | | `--disabled-slash-commands` | | Slash command names to hide/disable (comma-separated or repeated). Unioned with the `slashCommands.disabled` setting and the `QWEN_DISABLED_SLASH_COMMANDS` environment variable. Matched case-insensitively against the final command name. | Command names | Example: `qwen --disabled-slash-commands "auth,mcp,extensions"` | -| `--telemetry` | | Enables [telemetry](/developers/development/telemetry). | | | -| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](/developers/development/telemetry) for more information. | -| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | -| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. | -| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | +| `--telemetry` | | Enables [telemetry](../../developers/development/telemetry.md). | | | +| `--telemetry-target` | | Sets the telemetry target. | | See [telemetry](../../developers/development/telemetry.md) for more information. | +| `--telemetry-otlp-endpoint` | | Sets the OTLP endpoint for telemetry. | | See [telemetry](../../developers/development/telemetry.md) for more information. | +| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry.md) for more information. | +| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry.md) for more information. | | `--acp` | | Enables ACP mode (Agent Client Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. | | `--experimental-lsp` | | Enables experimental [LSP (Language Server Protocol)](../features/lsp) feature for code intelligence (go-to-definition, find references, diagnostics, etc.). | | Experimental. Requires language servers to be installed. | | `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | @@ -693,7 +693,7 @@ Here's a conceptual example of what a context file at the root of a TypeScript p This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context. -- **Hierarchical Loading and Precedence:** The CLI implements a hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is: +- **Hierarchical Loading and Precedence:** The CLI implements a hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected from the `/memory` dialog. The typical loading order is: 1. **Global Context File:** - Location: `~/.qwen/` (e.g., `~/.qwen/QWEN.md` in your user home directory). - Scope: Provides default instructions for all your projects. @@ -701,11 +701,11 @@ This example demonstrates how you can provide general project context, specific - Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory. - Scope: Provides context relevant to the entire project or a significant portion of it. - **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context. -- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../configuration/memory). +- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory documentation](../features/memory.md). - **Commands for Memory Management:** - - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - - See the [Commands documentation](../features/commands) for full details on the `/memory` command and its sub-commands (`show` and `refresh`). + - Use `/memory` to open the memory management dialog. + - Refresh memory from the dialog to re-scan and reload context files from all configured locations. + - See the [Commands documentation](../features/commands.md) for full details on the `/memory` command. By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor Qwen Code's responses to your specific needs and projects. diff --git a/docs/users/configuration/themes.md b/docs/users/configuration/themes.md index 093634c923d..c3ebba4fece 100644 --- a/docs/users/configuration/themes.md +++ b/docs/users/configuration/themes.md @@ -1,6 +1,6 @@ # Themes -Qwen Code supports a variety of themes to customize its color scheme and appearance. You can change the theme to suit your preferences via the `/theme` command or `"theme":` configuration setting. +Qwen Code supports a variety of themes to customize its color scheme and appearance. You can change the theme to suit your preferences via the `/theme` command or the `"ui.theme"` configuration setting. ## Available Themes @@ -13,12 +13,15 @@ Qwen Code comes with a selection of pre-defined themes, which you can list using - `Default` - `Dracula` - `GitHub` + - `Qwen Dark` + - `Shades Of Purple` - **Light Themes:** - `ANSI Light` - `Ayu Light` - `Default Light` - `GitHub Light` - `Google Code` + - `Qwen Light` - `Xcode` ### Changing Themes @@ -28,7 +31,7 @@ Qwen Code comes with a selection of pre-defined themes, which you can list using 3. Using the arrow keys, select a theme. Some interfaces might offer a live preview or highlight as you select. 4. Confirm your selection to apply the theme. -**Note:** If a theme is defined in your `settings.json` file (either by name or by a file path), you must remove the `"theme"` setting from the file before you can change the theme using the `/theme` command. +**Note:** If a theme is defined in your `settings.json` file (either by name or by a file path), you must remove the `"ui.theme"` setting from the file before you can change the theme using the `/theme` command. ### Theme Persistence @@ -139,7 +142,7 @@ You can define multiple custom themes by adding more entries to the `customTheme In addition to defining custom themes in `settings.json`, you can also load a theme directly from a JSON file by specifying the file path in your `settings.json`. This is useful for sharing themes or keeping them separate from your main configuration. -To load a theme from a file, set the `theme` property in your `settings.json` to the path of your theme file: +To load a theme from a file, set the `ui.theme` property in your `settings.json` to the path of your theme file: ```json { @@ -175,7 +178,7 @@ The theme file must be a valid JSON file that follows the same structure as a cu } ``` -**Security Note:** For your safety, Gemini CLI will only load theme files that are located within your home directory. If you attempt to load a theme from outside your home directory, a warning will be displayed and the theme will not be loaded. This is to prevent loading potentially malicious theme files from untrusted sources. +**Security Note:** For your safety, Qwen Code will only load theme files that are located within your home directory. If you attempt to load a theme from outside your home directory, a warning will be displayed and the theme will not be loaded. This is to prevent loading potentially malicious theme files from untrusted sources. ### Example Custom Theme diff --git a/docs/users/extension/extension-releasing.md b/docs/users/extension/extension-releasing.md index 9f175abfcd6..797e7264100 100644 --- a/docs/users/extension/extension-releasing.md +++ b/docs/users/extension/extension-releasing.md @@ -75,7 +75,7 @@ To ensure Qwen Code can automatically find the correct release asset for each pl Archives must be fully contained extensions and have all the standard requirements - specifically the `qwen-extension.json` file must be at the root of the archive. -The rest of the layout should look exactly the same as a typical extension, see [extensions.md](extension.md). +The rest of the layout should look exactly the same as a typical extension, see [extensions.md](./introduction.md). #### Example GitHub Actions workflow diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 30b6457bb23..5bff15dbbc7 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -245,7 +245,7 @@ The `qwen-extension.json` file contains the configuration for the extension. The - `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase or numbers and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name. - `version`: The version of the extension. -- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. +- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](../configuration/settings.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - Note that all MCP server configuration options are supported except for `trust`. - `channels`: A map of custom channel adapters. The key is the channel type name, and the value has an `entry` (path to compiled JS entry point) and optional `displayName`. The entry point must export a `plugin` object conforming to the `ChannelPlugin` interface. See [Channel Plugins](../features/channels/plugins) for a full guide. - `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded. @@ -269,24 +269,12 @@ Extensions can require configuration through settings (such as API keys or crede qwen extensions settings set [--scope user|workspace] ``` -**List all settings for an extension:** +**List all settings and current values for an extension:** ```bash qwen extensions settings list ``` -**View current values (user and workspace):** - -```bash -qwen extensions settings show -``` - -**Remove a setting value:** - -```bash -qwen extensions settings unset [--scope user|workspace] -``` - Settings can be configured at two levels: - **User level** (default): Settings apply across all projects (`~/.qwen/.env`) @@ -298,7 +286,7 @@ When Qwen Code starts, it loads all the extensions and merges their configuratio ### Custom commands -Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing Markdown files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions. +Extensions can provide [custom commands](../features/commands.md#4-custom-commands) by placing Markdown files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions. > **Note:** The command format has been updated from TOML to Markdown. TOML files are deprecated but still supported. You can migrate existing TOML commands using the automatic migration prompt that appears when TOML files are detected. diff --git a/docs/users/features/arena.md b/docs/users/features/arena.md index 67c879f9ad1..6f55aeae4b0 100644 --- a/docs/users/features/arena.md +++ b/docs/users/features/arena.md @@ -7,7 +7,7 @@ Agent Arena lets you pit multiple AI models against each other on the same task. Each model runs as a fully independent agent in its own isolated Git worktree, so file operations never interfere. When all agents finish, you compare results and select a winner to merge back into your main workspace. -Unlike [subagents](/users/features/sub-agents), which delegate focused subtasks within a single session, Arena agents are complete, top-level agent instances — each with its own model, context window, and full tool access. +Unlike [subagents](./sub-agents.md), which delegate focused subtasks within a single session, Arena agents are complete, top-level agent instances — each with its own model, context window, and full tool access. This page covers: @@ -104,7 +104,7 @@ If you want to inspect the complete reasoning path before deciding, each agent's ## Configuration -Arena behavior can be customized in [settings.json](/users/configuration/settings): +Arena behavior can be customized in [settings.json](../configuration/settings.md): ```json { @@ -215,5 +215,5 @@ Agent Arena is one of several planned multi-agent modes in Qwen Code. **Agent Te Explore related approaches for parallel and delegated work: -- **Lightweight delegation**: [Subagents](/users/features/sub-agents) handle focused subtasks within your session — better when you don't need model comparison +- **Lightweight delegation**: [Subagents](./sub-agents.md) handle focused subtasks within your session — better when you don't need model comparison - **Manual parallel sessions**: Run multiple Qwen Code sessions yourself in separate terminals with [Git worktrees](https://git-scm.com/docs/git-worktree) for full manual control diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 732267a3af3..0e927d4e1cc 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -7,7 +7,7 @@ Channels let you interact with a Qwen Code agent from messaging platforms like T When you run `qwen channel start`, Qwen Code: 1. Reads channel configurations from your `settings.json` -2. Spawns a single agent process using the [Agent Client Protocol (ACP)](../../developers/architecture) +2. Spawns a single agent process using the [Agent Client Protocol (ACP)](../../../developers/architecture.md) 3. Connects to each messaging platform and starts listening for messages 4. Routes incoming messages to the agent and sends responses back to the correct chat diff --git a/docs/users/features/channels/plugins.md b/docs/users/features/channels/plugins.md index 0c9108913fb..3459de0b755 100644 --- a/docs/users/features/channels/plugins.md +++ b/docs/users/features/channels/plugins.md @@ -84,4 +84,4 @@ Custom channels automatically support everything built-in channels do: ## Building Your Own Channel Plugin -Want to build a channel plugin for a new platform? See the [Channel Plugin Developer Guide](/developers/channel-plugins) for the `ChannelPlugin` interface, the `Envelope` format, and extension points. +Want to build a channel plugin for a new platform? See the [Channel Plugin Developer Guide](../../../developers/channel-plugins.md) for the `ChannelPlugin` interface, the `Envelope` format, and extension points. diff --git a/docs/users/features/headless.md b/docs/users/features/headless.md index 6dad885ec53..efa531a5600 100644 --- a/docs/users/features/headless.md +++ b/docs/users/features/headless.md @@ -406,6 +406,6 @@ These messages keep CI runners alive and let you monitor progress. They do not a ## Resources - [CLI Configuration](../configuration/settings#command-line-arguments) - Complete configuration guide -- [Authentication](../configuration/settings#environment-variables-for-api-access) - Setup authentication +- [Authentication](../configuration/auth.md) - Setup authentication - [Commands](../features/commands) - Interactive commands reference - [Tutorials](../quickstart) - Step-by-step automation guides diff --git a/docs/users/features/mcp.md b/docs/users/features/mcp.md index 95e6a29507b..07189423365 100644 --- a/docs/users/features/mcp.md +++ b/docs/users/features/mcp.md @@ -20,7 +20,7 @@ With MCP servers connected, you can ask Qwen Code to: Qwen Code loads MCP servers from `mcpServers` in your `settings.json`. You can configure servers either: - By editing `settings.json` directly -- By using `qwen mcp` commands (see [CLI reference](#qwen-mcp-cli)) +- By using `qwen mcp` commands (see [CLI reference](#manage-mcp-servers-with-qwen-mcp)) ### Add your first server @@ -30,13 +30,21 @@ Qwen Code loads MCP servers from `mcpServers` in your `settings.json`. You can c qwen mcp add --transport http my-server http://localhost:3000/mcp ``` -2. Open MCP management dialog to view and manage servers: +2. Start Qwen Code and open the MCP management dialog to view and manage + servers: ```bash -qwen mcp +qwen ``` -3. Restart Qwen Code in the same project (or start it if it wasn’t running yet), then ask the model to use tools from that server. +Then enter: + +```text +/mcp +``` + +3. If Qwen Code was already running before you added the server, restart it in + the same project. Then ask the model to use tools from that server. ## Where configuration is stored (scopes) @@ -281,11 +289,12 @@ OAuth configuration properties: OAuth tokens are automatically: -- **Stored securely** in `~/.qwen/mcp-oauth-tokens-v2.json` (AES-256-GCM encrypted), with keychain storage preferred when available +- **Stored** in `~/.qwen/mcp-oauth-tokens.json` by default. If `QWEN_CODE_FORCE_ENCRYPTED_FILE_STORAGE=true` is set, Qwen Code uses encrypted file storage/keychain-backed storage where available. - **Refreshed** when expired (if refresh tokens are available) - **Validated** before each connection attempt -Use the `/mcp auth` command within Qwen Code to manage OAuth authentication interactively. +Use the `/mcp` dialog within Qwen Code to inspect MCP servers and manage +authentication interactively. ### Tool filtering (allow/deny tools per server) diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md index c9a367f379a..e9807359418 100644 --- a/docs/users/features/sandbox.md +++ b/docs/users/features/sandbox.md @@ -163,7 +163,7 @@ If you want to restrict outbound network access to an allowlist, you can run a l This is especially useful with `*-proxied` Seatbelt profiles. -For a working allowlist-style proxy example, see: [Example Proxy Script](/developers/examples/proxy-script). +For a working allowlist-style proxy example, see: [Example Proxy Script](../../developers/examples/proxy-script.md). ## Linux UID/GID handling @@ -210,7 +210,7 @@ Then rebuild the sandbox image: QWEN_SANDBOX=docker BUILD_SANDBOX=1 qwen -s ``` -For more details on customizing the sandbox, see [Customizing the sandbox environment](/developers/tools/sandbox). +For more details on customizing the sandbox, see [Customizing the sandbox environment](../../developers/tools/sandbox.md). **Network issues** diff --git a/docs/users/features/tool-use-summaries.md b/docs/users/features/tool-use-summaries.md index 8be660ca13e..d634d39be75 100644 --- a/docs/users/features/tool-use-summaries.md +++ b/docs/users/features/tool-use-summaries.md @@ -174,5 +174,5 @@ If you do not want the extra cost, turn the feature off via `experimental.emitTo ## Related -- [Compact Mode](../configuration/settings#ui.compactMode) — toggle with `Ctrl+O`; the summary replaces the generic tool-group header when compact mode is on. +- [Compact Mode](../configuration/settings#ui) — toggle with `Ctrl+O`; the summary replaces the generic tool-group header when compact mode is on. - [Followup Suggestions](./followup-suggestions) — another fast-model-driven UX enhancement that shares the same `fastModel` setting. diff --git a/docs/users/integration-github-action.md b/docs/users/integration-github-action.md index 281967dd8f3..e98190b13d3 100644 --- a/docs/users/integration-github-action.md +++ b/docs/users/integration-github-action.md @@ -96,7 +96,7 @@ This workflow acts as a central dispatcher for Qwen Code CLI, routing requests t ### Issue Triage -This action can be used to triage GitHub Issues automatically or on a schedule. For a detailed guide on how to set up the issue triage system, go to the [GitHub Issue Triage workflow documentation](./examples/workflows/issue-triage). +This action can be used to triage GitHub Issues automatically or on a schedule. For a working issue triage setup, see the [automated issue triage workflow](https://github.com/QwenLM/qwen-code/blob/main/.github/workflows/qwen-automated-issue-triage.yml). ### Pull Request Review @@ -208,7 +208,7 @@ The Qwen Code CLI can be extended with additional functionality through extensio These extensions are installed from source from their GitHub repositories. For detailed instructions on how to set up and configure extensions, go to the -[Extensions documentation](../developers/extensions/extension). +[Extensions documentation](./extension/introduction.md). ## Best Practices diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 5178912f6f4..f74c63353c1 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -461,7 +461,15 @@ The bridge keeps **one channel per daemon** (one daemon per workspace, per §02) ## Logging in to a remote daemon (issue #4175 PR 21) -When the daemon runs on a remote pod (no shared display with you), you can still log in to a Qwen account by triggering an OAuth device flow over HTTP. The daemon polls the IdP itself; your job is just to open a URL on whatever device has a browser. +When the daemon runs on a remote pod (no shared display with you), a client can +trigger an OAuth device flow over HTTP. The daemon polls the IdP itself; your job +is just to open a URL on whatever device has a browser. + +> [!note] +> +> Qwen OAuth free tier was discontinued on 2026-04-15. The `qwen-oauth` +> examples below document the device-flow protocol shape and legacy provider +> identifier; new setups should use a currently supported auth provider. ```bash # 1. Start a flow. The daemon contacts the IdP, returns a code + URL. diff --git a/docs/users/support/tos-privacy.md b/docs/users/support/tos-privacy.md index ffc35f05936..80c231449c0 100644 --- a/docs/users/support/tos-privacy.md +++ b/docs/users/support/tos-privacy.md @@ -109,4 +109,4 @@ You can switch between Qwen OAuth, Alibaba Cloud Coding Plan, and your own API k 2. **Within the CLI**: Use the `/auth` command to reconfigure your authentication method 3. **Environment variables**: Set up `.env` files for automatic API key authentication -For detailed instructions, see the [Authentication Setup](../configuration/settings#environment-variables-for-api-access) documentation. +For detailed instructions, see the [Authentication Setup](../configuration/auth.md) documentation. diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md index ef89ca1f46e..f4549ec4d40 100644 --- a/packages/channels/base/README.md +++ b/packages/channels/base/README.md @@ -291,5 +291,5 @@ Block streaming and `onResponseChunk` work independently — plugins can overrid ## Further reading -- [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md) +- [Channel Plugin Developer Guide](../../../docs/developers/channel-plugins.md) - [`@qwen-code/channel-plugin-example`](../plugin-example/) — working reference implementation diff --git a/packages/channels/plugin-example/README.md b/packages/channels/plugin-example/README.md index a174610800a..1c95bd13bb1 100644 --- a/packages/channels/plugin-example/README.md +++ b/packages/channels/plugin-example/README.md @@ -5,7 +5,7 @@ A reference channel plugin for Qwen Code. It connects to a WebSocket server and Use this package to: - **Try out the channel plugin system** — install it as an extension and run it with the built-in mock server -- **Use it as a starting point** — fork the source to build your own channel adapter (see the [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md)) +- **Use it as a starting point** — fork the source to build your own channel adapter (see the [Channel Plugin Developer Guide](../../../docs/developers/channel-plugins.md)) ## Quick start @@ -101,4 +101,4 @@ See `src/MockPluginChannel.ts` for a working example. The key points: - **Streaming hooks** — override `onResponseChunk()` for progressive display (e.g., editing a message in-place) - Access control (allowlist, pairing, open), session routing, slash commands, crash recovery -Full guide: [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md) +Full guide: [Channel Plugin Developer Guide](../../../docs/developers/channel-plugins.md) diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index f149eaa13d3..5f6a669f465 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -13,7 +13,7 @@ export const mcpCommand: SlashCommand = { get description() { return t('Open MCP management dialog'); }, - argumentHint: 'desc|nodesc|schema|auth|noauth', + argumentHint: 'desc|nodesc|schema', kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, action: async (): Promise => ({ diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index b2a2390245d..81f41a18cff 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -20,10 +20,8 @@ */ import type { ReactNode } from 'react'; -import { useCallback, useContext, useEffect, useRef } from 'react'; -import { Box, Text } from 'ink'; -import { addLayoutListener, type DOMElement } from 'ink/dom'; -import CursorContext from 'ink/components/CursorContext'; +import { useCallback, useRef } from 'react'; +import { Box, Text, type DOMElement, useBoxMetrics, useCursor } from 'ink'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -128,15 +126,24 @@ export function defaultRenderLine({ // ─── Helpers ──────────────────────────────────────────────── -// Walk up Ink's internal DOM tree to find the root node (ink-root). -// addLayoutListener requires the root node specifically. -function findRootNode( - node: (Record & { parentNode?: unknown }) | null, -): DOMElement | undefined { +function getAbsolutePosition( + node: DOMElement | null, +): { top: number; left: number } | undefined { if (!node) return undefined; - if (!node.parentNode) - return node['nodeName'] === 'ink-root' ? (node as DOMElement) : undefined; - return findRootNode(node.parentNode as Record); + + let top = 0; + let left = 0; + let current: DOMElement | undefined = node; + while (current) { + const layout = current.yogaNode?.getComputedLayout(); + if (layout) { + top += layout.top; + left += layout.left; + } + current = current.parentNode; + } + + return { top, left }; } // ─── Component ────────────────────────────────────────────── @@ -268,79 +275,33 @@ export const BaseTextInput = ({ const scrollVisualRow = buffer.visualScrollRow; // ── Physical cursor positioning for IME ── - // addLayoutListener fires in resetAfterCommit AFTER calculateLayout() - // but BEFORE onRender() — yoga layout is fresh, terminal not yet written. - // addLayoutListener requires the root node (ink-root), not the component - // node. We find it by walking up the Ink DOM parent chain. - const rootRef = useRef(null); - const cursorCtx = useContext(CursorContext); - - // Use a ref to hold mutable state so the layout listener callback - // always reads the latest values without needing to resubscribe. - const stateRef = useRef({ - showCursor, - cursorVisualRow, - cursorVisualCol, - scrollVisualRow, - linesToRender, - prefixWidth, - }); - stateRef.current = { - showCursor, - cursorVisualRow, - cursorVisualCol, - scrollVisualRow, - linesToRender, - prefixWidth, - }; - - useEffect(() => { - const rootNode = findRootNode(rootRef.current); - if (!rootNode) return; - const unsub = addLayoutListener(rootNode, () => { - const { - showCursor: sc, - cursorVisualRow: vr, - cursorVisualCol: vc, - scrollVisualRow: sr, - linesToRender: lt, - prefixWidth: pw, - } = stateRef.current; - if (!sc) { - cursorCtx.setCursorPosition(undefined); - return; - } - const node = rootRef.current; - if (!node) return; - let absTop = 0; - let absLeft = 0; - let n: unknown = node; - while (n) { - const nd = n as { - yogaNode?: { getComputedLayout(): { top: number; left: number } }; - parentNode?: unknown; - }; - const layout = nd.yogaNode?.getComputedLayout(); - if (layout) { - absTop += layout.top; - absLeft += layout.left; - } - n = nd.parentNode; - } - const relativeRow = vr - sr; - const lineText = lt[relativeRow] || ''; - const textBeforeCursor = cpSlice(lineText, 0, vc); - const physicalCol = stringWidth(textBeforeCursor); - cursorCtx.setCursorPosition({ - x: absLeft + pw + physicalCol, - y: absTop + relativeRow + 1, - }); - }); - return () => { - unsub(); - cursorCtx.setCursorPosition(undefined); - }; - }, [cursorCtx]); + // useBoxMetrics subscribes to Ink layout changes through public API. We still + // walk the parent chain to convert the component-relative Yoga layout into + // the absolute Ink output coordinates expected by useCursor. + const rootRef = useRef(null); + const { hasMeasured } = useBoxMetrics(rootRef); + const { setCursorPosition } = useCursor(); + + const cursorPosition = + showCursor && hasMeasured + ? (() => { + const position = getAbsolutePosition(rootRef.current); + if (!position) return undefined; + + const relativeRow = cursorVisualRow - scrollVisualRow; + const lineText = linesToRender[relativeRow] || ''; + const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); + const physicalCol = stringWidth(textBeforeCursor); + return { + x: position.left + prefixWidth + physicalCol, + y: position.top + relativeRow + 1, + }; + })() + : undefined; + + // useCursor propagates its latest value during Ink's insertion effect, so the + // position must be set during render rather than in a normal effect. + setCursorPosition(cursorPosition); const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( diff --git a/packages/cli/src/ui/components/views/McpStatus.tsx b/packages/cli/src/ui/components/views/McpStatus.tsx index 0bf74db81e1..a82fb80f3f4 100644 --- a/packages/cli/src/ui/components/views/McpStatus.tsx +++ b/packages/cli/src/ui/components/views/McpStatus.tsx @@ -283,8 +283,7 @@ export const McpStatus: React.FC = ({ {t('to hide descriptions')} - {' '}- {t('Use')}{' '} - /mcp auth <server-name>{' '} + {' '}- {t('Use')} /mcp{' '} {t('to authenticate with OAuth-enabled servers')} diff --git a/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap b/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap index 293581a707c..2828e46c479 100644 --- a/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap +++ b/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap @@ -150,7 +150,7 @@ A test server - Use /mcp desc to show server and tool descriptions - Use /mcp schema to show tool parameter schemas - Use /mcp nodesc to hide descriptions - - Use /mcp auth to authenticate with OAuth-enabled servers + - Use /mcp to authenticate with OAuth-enabled servers - Press Ctrl+T to toggle tool descriptions on/off" `; diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 1d206709cc8..942057f4d8a 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -1248,7 +1248,8 @@ describe('MCPOAuthProvider', () => { // Regression test for https://github.com/QwenLM/qwen-code/issues/1749 // Scenario: user runs `qwen mcp add --transport http yuque https://mcp.alibaba-inc.com/yuque/mcp` - // then `/mcp auth yuque`. Per MCP spec / RFC 8707, the resource param should be the + // then authenticates the server from the `/mcp` dialog. Per MCP spec / + // RFC 8707, the resource param should be the // full canonical URI "https://mcp.alibaba-inc.com/yuque/mcp", not just the host. it('should use full canonical URI as resource parameter (issue #1749)', async () => { let capturedAuthUrl: string | undefined; diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 923d0f783f9..d9441da9448 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -75,6 +75,18 @@ const debugLogger = createDebugLogger('MCP'); const STREAMABLE_HTTP_GET_SSE_FALLBACK_STATUSES = new Set([400]); const STREAMABLE_HTTP_GET_SSE_ERROR_BODY_LIMIT = 512; +function getMcpOAuthDialogInstruction( + action: 'authenticate' | 're-authenticate', + mcpServerName?: string, +): string { + return mcpServerName + ? [ + `Open the /mcp dialog in Qwen Code to ${action}`, + `MCP server '${mcpServerName}'.`, + ].join(' ') + : `Open the /mcp dialog in Qwen Code to ${action} the MCP server.`; +} + async function readResponseBodyExcerpt( response: Response, ): Promise { @@ -608,7 +620,8 @@ async function handleAutomaticOAuth( if (!oauthConfig) { debugLogger.error( - `Could not configure OAuth for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, + `Could not configure OAuth for '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); return false; } @@ -1187,18 +1200,18 @@ export async function connectToMcpServer( if (hasStoredTokens) { debugLogger.warn( `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + - `Please re-authenticate using: /mcp auth ${mcpServerName}`, + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName), ); } else { debugLogger.warn( `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - `Please authenticate using: /mcp auth ${mcpServerName}`, + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } throw new Error( `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - `Please authenticate using: /mcp auth ${mcpServerName}`, + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } @@ -1347,18 +1360,21 @@ export async function connectToMcpServer( if (hasStoredTokens) { debugLogger.warn( `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + - `Please re-authenticate using: /mcp auth ${mcpServerName}`, + getMcpOAuthDialogInstruction( + 're-authenticate', + mcpServerName, + ), ); } else { debugLogger.warn( `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - `Please authenticate using: /mcp auth ${mcpServerName}`, + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } throw new Error( `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - `Please authenticate using: /mcp auth ${mcpServerName}`, + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } @@ -1467,15 +1483,18 @@ export async function connectToMcpServer( } } else { debugLogger.error( - `Could not configure OAuth for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, + `Could not configure OAuth for '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw new Error( - `OAuth configuration failed for '${mcpServerName}'. Please authenticate manually with /mcp auth ${mcpServerName}`, + `OAuth configuration failed for '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } catch (discoveryError) { debugLogger.error( - `OAuth discovery failed for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`, + `OAuth discovery failed for '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw discoveryError; } @@ -1602,11 +1621,11 @@ export async function createTransport( if (!accessToken) { debugLogger.error( `MCP server '${mcpServerName}' requires OAuth authentication. ` + - `Please authenticate using the /mcp auth command.`, + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw new Error( `MCP server '${mcpServerName}' requires OAuth authentication. ` + - `Please authenticate using the /mcp auth command.`, + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } else { diff --git a/packages/sdk-java/client/README.md b/packages/sdk-java/client/README.md index 7bfc390bf5c..d7871b317b4 100644 --- a/packages/sdk-java/client/README.md +++ b/packages/sdk-java/client/README.md @@ -167,7 +167,7 @@ Key dependencies include: ## License -This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the Apache 2.0 License. See the [LICENSE](../../../LICENSE) file for details. ## Contributing diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index a8ef91d0aee..22370093c94 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -65,17 +65,17 @@ Creates a new query session with the Qwen Code. | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | | `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json as an allowlist. If specified, only these tools will be available to the AI (all other tools are disabled at registry level). Supports tool name aliases and pattern matching. Example: `['Read', 'Edit', 'Bash(git *)']`. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['Read', 'Edit', 'Bash(git *)']`. | | `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | -| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['ShellTool(git status)', 'ShellTool(npm test)']`. | -| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Using `'qwen-oauth'` in SDK is not recommended as credentials are stored in `~/.qwen` and may need periodic refresh. | +| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | | `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | | `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | | `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | | `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | > [!tip] -> If you need to configure `coreTools`, `excludeTools`, or `allowedTools`, it is **strongly recommended** to read the [permissions configuration documentation](../docs/users/configuration/settings.md#permissions) first, especially the **Tool name aliases** and **Rule syntax examples** sections, to understand the available aliases and pattern matching syntax (e.g., `Bash(git *)`, `Read(.env)`, `Edit(/src/**)`). +> If you need to configure `coreTools`, `excludeTools`, or `allowedTools`, it is **strongly recommended** to read the [permissions configuration documentation](../../docs/users/configuration/settings.md#permissions) first, especially the **Tool name aliases** and **Rule syntax examples** sections, to understand the available aliases and pattern matching syntax (e.g., `Bash(git *)`, `Read(.env)`, `Edit(/src/**)`). ### Timeouts @@ -503,4 +503,4 @@ npm install -g @qwen-code/qwen-code@latest ## License -Apache-2.0 - see [LICENSE](./LICENSE) for details. +Apache-2.0 - see [LICENSE](../../LICENSE) for details. diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index cec7398abd9..69e41b34d69 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -367,15 +367,16 @@ export interface QueryOptions { maxSessionTurns?: number; /** - * Equivalent to `tool.core` in settings.json. - * List of core tools to enable for the session. - * If specified, only these tools will be available to the AI. - * @example ['read_file', 'write_file', 'run_terminal_cmd'] + * Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. + * If specified, only matching core tools are registered for the session. + * This is separate from `permissions.allow`, which auto-approves matching + * tool calls but does not restrict tool registration. + * @example ['Read', 'Edit', 'Bash(git *)'] */ coreTools?: string[]; /** - * Equivalent to `tool.exclude` in settings.json. + * Equivalent to `permissions.deny` in settings.json. * List of tools to exclude from the session. * * **Behavior:** @@ -384,17 +385,17 @@ export interface QueryOptions { * - Tools will not be available to the AI, even if in `coreTools` or `allowedTools` * * **Pattern matching:** - * - Tool name: `'write_file'`, `'run_shell_command'` - * - Tool class: `'WriteTool'`, `'ShellTool'` - * - Shell command prefix: `'ShellTool(git commit)'` (matches commands starting with "git commit") + * - Tool name: `'write_file'` + * - Shell command prefix: `'Bash(rm *)'` + * - Path patterns: `'Read(.env)'`, `'Edit(/src/**)'` * - * @example ['run_terminal_cmd', 'delete_file', 'ShellTool(rm )'] + * @example ['Bash(rm *)', 'Read(.env)', 'Edit(/secrets/**)'] * @see allowedTools For allowing specific tools */ excludeTools?: string[]; /** - * Equivalent to `tool.allowed` in settings.json. + * Equivalent to `permissions.allow` in settings.json. * List of tools that are allowed to run without confirmation. * * **Behavior:** @@ -405,16 +406,16 @@ export interface QueryOptions { * - Has no effect in `permissionMode: 'yolo'` (already auto-approved) * * **Pattern matching:** - * - Tool name: `'write_file'`, `'run_shell_command'` - * - Tool class: `'WriteTool'`, `'ShellTool'` - * - Shell command prefix: `'ShellTool(git status)'` (matches commands starting with "git status") + * - Tool name: `'write_file'` + * - Shell command prefix: `'Bash(git status)'` + * - Path patterns: `'Read(.env)'`, `'Edit(/src/**)'` * * **Use cases:** - * - Auto-approve safe shell commands: `['ShellTool(git status)', 'ShellTool(ls)']` + * - Auto-approve safe shell commands: `['Bash(git status)', 'Bash(ls)']` * - Auto-approve specific tools: `['write_file', 'edit']` * - Combine with `permissionMode: 'default'` to selectively auto-approve tools * - * @example ['read_file', 'ShellTool(git status)', 'ShellTool(npm test)'] + * @example ['Read', 'Bash(git status)', 'Bash(npm test)'] * @see canUseTool For custom approval logic * @see excludeTools For blocking specific tools */ @@ -423,10 +424,10 @@ export interface QueryOptions { /** * Authentication type for the AI service. * - 'openai': Use OpenAI-compatible authentication - * - 'qwen-oauth': Use Qwen OAuth authentication + * - 'qwen-oauth': Legacy Qwen OAuth authentication * - * Though we support 'qwen-oauth', it's not recommended to use it in the SDK. - * Because the credentials are stored in `~/.qwen` and may need to refresh periodically. + * Qwen OAuth free tier was discontinued on 2026-04-15. New SDK setups should + * use OpenAI-compatible authentication or another supported provider. */ authType?: AuthType; diff --git a/packages/web-shell/client/completions/slashCompletion.test.ts b/packages/web-shell/client/completions/slashCompletion.test.ts index da715d99931..3251b1dead1 100644 --- a/packages/web-shell/client/completions/slashCompletion.test.ts +++ b/packages/web-shell/client/completions/slashCompletion.test.ts @@ -352,7 +352,7 @@ describe('slashCompletionSource', () => { { name: 'mcp', description: 'Manage MCP servers', - argumentHint: 'desc|nodesc|schema|auth|noauth', + argumentHint: 'desc|nodesc|schema', }, ]; const source = slashCompletionSource(() => commands); diff --git a/packages/web-shell/client/constants/localCommands.ts b/packages/web-shell/client/constants/localCommands.ts index 81e03e1ff55..775f46c7a3a 100644 --- a/packages/web-shell/client/constants/localCommands.ts +++ b/packages/web-shell/client/constants/localCommands.ts @@ -51,7 +51,7 @@ export function getLocalCommands(t: Translate): CommandInfo[] { { name: 'mcp', description: t('local.mcp'), - argumentHint: 'desc|nodesc|schema|auth|noauth', + argumentHint: 'desc|nodesc|schema', }, { name: 'skills', description: t('local.skills') }, { name: 'status', description: t('local.status') }, From 71c8c4e89ca0342aa0c0527cf919bf1509d45703 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 22 Jun 2026 13:45:09 +0800 Subject: [PATCH 02/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- docs/developers/daemon/00-index.md | 2 +- docs/developers/tools/mcp-server.md | 2 +- docs/users/features/mcp.md | 2 +- packages/core/src/tools/mcp-client.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index dba05a2b3bd..dbd889c026b 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -9,7 +9,7 @@ It complements, rather than replaces, these existing docs: | [`../../users/qwen-serve.md`](../../users/qwen-serve.md) | Operators | User quickstart, flags, threat model | | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md) | Protocol implementers | HTTP route catalog, request/response shapes, error codes | | [`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md) | SDK users | End-to-end TypeScript walkthrough | -| [`../daemon-client-adapters/tui.md`](../daemon-client-adapters/tui.md) | Adapter authors | Client adapter design notes | +| [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) | Adapter authors | Client adapter design notes | | [`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) | F2 maintainers | Workspace MCP transport pool design v2.2 | If you want to **start a daemon and use it**, read `qwen-serve.md` first. If you want to **build a client against the wire format**, read `qwen-serve-protocol.md`. If you want to **understand, extend, or debug the daemon internals**, read this set. diff --git a/docs/developers/tools/mcp-server.md b/docs/developers/tools/mcp-server.md index dcbd0d0f2a8..4dc032c735d 100644 --- a/docs/developers/tools/mcp-server.md +++ b/docs/developers/tools/mcp-server.md @@ -202,7 +202,7 @@ servers and manage OAuth authentication. OAuth tokens are automatically: -- **Stored** in `~/.qwen/mcp-oauth-tokens.json` by default. If `QWEN_CODE_FORCE_ENCRYPTED_FILE_STORAGE=true` is set, Qwen Code uses encrypted file storage/keychain-backed storage where available. +- **Stored** in `~/.qwen/mcp-oauth-tokens.json` (plaintext, mode 0600) by default. If `QWEN_CODE_FORCE_ENCRYPTED_FILE_STORAGE=true` is set, Qwen Code uses keychain-backed storage where available, or `~/.qwen/mcp-oauth-tokens-v2.json` with AES-256-GCM encryption. - **Refreshed** when expired (if refresh tokens are available) - **Validated** before each connection attempt - **Cleaned up** when invalid or expired diff --git a/docs/users/features/mcp.md b/docs/users/features/mcp.md index 9ccbf9a98fe..e1487620007 100644 --- a/docs/users/features/mcp.md +++ b/docs/users/features/mcp.md @@ -338,7 +338,7 @@ OAuth configuration properties: OAuth tokens are automatically: -- **Stored** in `~/.qwen/mcp-oauth-tokens.json` by default. If `QWEN_CODE_FORCE_ENCRYPTED_FILE_STORAGE=true` is set, Qwen Code uses encrypted file storage/keychain-backed storage where available. +- **Stored** in `~/.qwen/mcp-oauth-tokens.json` (plaintext, mode 0600) by default. If `QWEN_CODE_FORCE_ENCRYPTED_FILE_STORAGE=true` is set, Qwen Code uses keychain-backed storage where available, or `~/.qwen/mcp-oauth-tokens-v2.json` with AES-256-GCM encryption. - **Refreshed** when expired (if refresh tokens are available) - **Validated** before each connection attempt diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 3042d74f9e0..c909620482c 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -85,7 +85,7 @@ function getMcpOAuthDialogInstruction( return mcpServerName ? [ `Open the /mcp dialog in Qwen Code to ${action}`, - `MCP server '${mcpServerName}'.`, + `with MCP server '${mcpServerName}'.`, ].join(' ') : `Open the /mcp dialog in Qwen Code to ${action} the MCP server.`; } From 5122c47295a81e4d35e78710cd7a7dbc0945a9dd Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 22 Jun 2026 16:13:55 +0800 Subject: [PATCH 03/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- .../src/ui/components/BaseTextInput.test.tsx | 30 ++++++++++++++- .../cli/src/ui/components/BaseTextInput.tsx | 8 +++- packages/core/src/tools/mcp-client.test.ts | 15 ++++++++ packages/core/src/tools/mcp-client.ts | 38 +++++++++---------- packages/sdk-typescript/src/types/types.ts | 5 ++- 5 files changed, 72 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index dd6e940675d..36e9c4fef63 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -6,7 +6,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from 'ink-testing-library'; -import { BaseTextInput } from './BaseTextInput.js'; +import type { DOMElement } from 'ink'; +import { BaseTextInput, getAbsolutePosition } from './BaseTextInput.js'; import { useKeypress } from '../hooks/useKeypress.js'; import type { Key } from '../hooks/useKeypress.js'; import type { TextBuffer } from './shared/text-buffer.js'; @@ -47,6 +48,19 @@ function createBuffer() { } as unknown as TextBuffer; } +function createElement( + top: number, + left: number, + parentNode?: DOMElement, +): DOMElement { + return { + yogaNode: { + getComputedLayout: () => ({ top, left }), + }, + parentNode, + } as unknown as DOMElement; +} + function captureKeypressHandler(): (key: Key) => void { const calls = mockedUseKeypress.mock.calls; if (calls.length === 0) { @@ -95,3 +109,17 @@ describe('BaseTextInput', () => { expect(buffer.handleInput).toHaveBeenCalledWith(typedKey); }); }); + +describe('getAbsolutePosition', () => { + it('returns undefined for a missing node', () => { + expect(getAbsolutePosition(null)).toBeUndefined(); + }); + + it('sums computed layout offsets across parent nodes', () => { + const root = createElement(2, 3); + const parent = createElement(5, 7, root); + const child = createElement(11, 13, parent); + + expect(getAbsolutePosition(child)).toEqual({ top: 18, left: 23 }); + }); +}); diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index a7d60cee960..c011dcc4141 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -126,7 +126,7 @@ export function defaultRenderLine({ // ─── Helpers ──────────────────────────────────────────────── -function getAbsolutePosition( +export function getAbsolutePosition( node: DOMElement | null, ): { top: number; left: number } | undefined { if (!node) return undefined; @@ -331,7 +331,11 @@ export const BaseTextInput = ({ borderColor={resolvedBorderColor} > {resolvedPrefix} - + {buffer.text.length === 0 && placeholder ? ( showCursor ? ( diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index b874714ef3a..10e0223fa10 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -27,6 +27,7 @@ import { discoverPrompts, discoverResources, getAllMCPServerStatuses, + getMcpOAuthDialogInstruction, getMCPServerStatus, hasNetworkTransport, isEnabled, @@ -88,6 +89,20 @@ describe('mcp-client', () => { process.env = ORIGINAL_ENV; }); + describe('getMcpOAuthDialogInstruction', () => { + it('builds an authenticate instruction for the named MCP server', () => { + expect(getMcpOAuthDialogInstruction('authenticate', 'foo')).toBe( + "Open the /mcp dialog in Qwen Code to authenticate with MCP server 'foo'.", + ); + }); + + it('builds a re-authenticate instruction for the named MCP server', () => { + expect(getMcpOAuthDialogInstruction('re-authenticate', 'foo')).toBe( + "Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'foo'.", + ); + }); + }); + describe('McpClient', () => { it('should discover tools', async () => { const mockedClient = { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index c909620482c..092648a1503 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -78,16 +78,14 @@ const debugLogger = createDebugLogger('MCP'); const STREAMABLE_HTTP_GET_SSE_FALLBACK_STATUSES = new Set([400]); const STREAMABLE_HTTP_GET_SSE_ERROR_BODY_LIMIT = 512; -function getMcpOAuthDialogInstruction( +export function getMcpOAuthDialogInstruction( action: 'authenticate' | 're-authenticate', - mcpServerName?: string, + mcpServerName: string, ): string { - return mcpServerName - ? [ - `Open the /mcp dialog in Qwen Code to ${action}`, - `with MCP server '${mcpServerName}'.`, - ].join(' ') - : `Open the /mcp dialog in Qwen Code to ${action} the MCP server.`; + return [ + `Open the /mcp dialog in Qwen Code to ${action}`, + `with MCP server '${mcpServerName}'.`, + ].join(' '); } async function readResponseBodyExcerpt( @@ -677,7 +675,7 @@ async function handleAutomaticOAuth( if (!oauthConfig) { debugLogger.error( - `Could not configure OAuth for '${mcpServerName}'. ` + + `Could not configure OAuth. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); return false; @@ -1340,18 +1338,18 @@ export async function connectToMcpServer( ); if (hasStoredTokens) { debugLogger.warn( - `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + + `Stored OAuth token for the SSE server was rejected. ` + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName), ); } else { debugLogger.warn( - `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + + `401 error received for the SSE server without OAuth configuration. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } throw new Error( - `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + + `401 error received for the SSE server without OAuth configuration. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } @@ -1500,7 +1498,7 @@ export async function connectToMcpServer( ); if (hasStoredTokens) { debugLogger.warn( - `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + + `Stored OAuth token for the SSE server was rejected. ` + getMcpOAuthDialogInstruction( 're-authenticate', mcpServerName, @@ -1508,13 +1506,13 @@ export async function connectToMcpServer( ); } else { debugLogger.warn( - `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + + `401 error received for the SSE server without OAuth configuration. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } throw new Error( - `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + + `401 error received for the SSE server without OAuth configuration. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } @@ -1624,17 +1622,17 @@ export async function connectToMcpServer( } } else { debugLogger.error( - `Could not configure OAuth for '${mcpServerName}'. ` + + `Could not configure OAuth. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw new Error( - `OAuth configuration failed for '${mcpServerName}'. ` + + `OAuth configuration failed. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } catch (discoveryError) { debugLogger.error( - `OAuth discovery failed for '${mcpServerName}'. ` + + `OAuth discovery failed. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw discoveryError; @@ -1761,11 +1759,11 @@ export async function createTransport( if (!accessToken) { debugLogger.error( - `MCP server '${mcpServerName}' requires OAuth authentication. ` + + `The MCP server requires OAuth authentication. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw new Error( - `MCP server '${mcpServerName}' requires OAuth authentication. ` + + `The MCP server requires OAuth authentication. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 69e41b34d69..3ebb163222d 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -371,7 +371,10 @@ export interface QueryOptions { * If specified, only matching core tools are registered for the session. * This is separate from `permissions.allow`, which auto-approves matching * tool calls but does not restrict tool registration. - * @example ['Read', 'Edit', 'Bash(git *)'] + * @example ['read_file', 'edit', 'run_shell_command'] + * // Aliases like 'Read', 'Edit', and 'Bash' also work but resolve to + * // single tools. Specifiers like 'Bash(git *)' are stripped; `coreTools` + * // restricts tool registration, not invocation. */ coreTools?: string[]; From 6023f47e4a47afff574d63d8ff59bd258eb4b371 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 22 Jun 2026 19:58:24 +0800 Subject: [PATCH 04/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- .../cli/src/ui/commands/mcpCommand.test.ts | 22 +++++++++++++ packages/cli/src/ui/commands/mcpCommand.ts | 31 ++++++++++++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index f6fe3ca8de1..17a56458abb 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -149,5 +149,27 @@ describe('mcpCommand', () => { dialog: 'mcp', }); }); + + it('should warn when using the legacy auth argument with a server name', async () => { + const result = await mcpCommand.action!(mockContext, 'auth server1'); + + expect(result).toEqual({ + type: 'message', + messageType: 'warning', + content: + "MCP OAuth is now managed in the /mcp dialog. Open /mcp, select 'server1', then use the Auth actions there.", + }); + }); + + it('should warn when using the legacy noauth argument without a server name', async () => { + const result = await mcpCommand.action!(mockContext, 'noauth'); + + expect(result).toEqual({ + type: 'message', + messageType: 'warning', + content: + 'MCP OAuth is now managed in the /mcp dialog. Open /mcp, select a server, then use the Auth actions there.', + }); + }); }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 5f6a669f465..c81055006fb 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, OpenDialogActionReturn } from './types.js'; +import type { + SlashCommand, + MessageActionReturn, + OpenDialogActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; @@ -16,8 +20,25 @@ export const mcpCommand: SlashCommand = { argumentHint: 'desc|nodesc|schema', kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, - action: async (): Promise => ({ - type: 'dialog', - dialog: 'mcp', - }), + action: async ( + _context, + args = '', + ): Promise => { + const [subcommand, serverName] = args.trim().split(/\s+/); + + if (subcommand === 'auth' || subcommand === 'noauth') { + return { + type: 'message', + messageType: 'warning', + content: serverName + ? `MCP OAuth is now managed in the /mcp dialog. Open /mcp, select '${serverName}', then use the Auth actions there.` + : 'MCP OAuth is now managed in the /mcp dialog. Open /mcp, select a server, then use the Auth actions there.', + }; + } + + return { + type: 'dialog', + dialog: 'mcp', + }; + }, }; From d704c1182413a014dd357b922933788facf96309 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 22 Jun 2026 21:12:18 +0800 Subject: [PATCH 05/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- .../cli/src/ui/commands/mcpCommand.test.ts | 22 ++++++++ packages/cli/src/ui/commands/mcpCommand.ts | 9 +++- packages/core/src/tools/mcp-client.ts | 50 +++++++++++-------- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 17a56458abb..085376719e9 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -161,6 +161,28 @@ describe('mcpCommand', () => { }); }); + it('should warn when using the legacy auth argument without a server name', async () => { + const result = await mcpCommand.action!(mockContext, 'auth'); + + expect(result).toEqual({ + type: 'message', + messageType: 'warning', + content: + 'MCP OAuth is now managed in the /mcp dialog. Open /mcp, select a server, then use the Auth actions there.', + }); + }); + + it('should warn when using the legacy noauth argument with a server name', async () => { + const result = await mcpCommand.action!(mockContext, 'noauth server1'); + + expect(result).toEqual({ + type: 'message', + messageType: 'warning', + content: + "MCP OAuth is now managed in the /mcp dialog. Open /mcp, select 'server1', then use the Auth actions there.", + }); + }); + it('should warn when using the legacy noauth argument without a server name', async () => { const result = await mcpCommand.action!(mockContext, 'noauth'); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index c81055006fb..83d29e6c9b8 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -31,8 +31,13 @@ export const mcpCommand: SlashCommand = { type: 'message', messageType: 'warning', content: serverName - ? `MCP OAuth is now managed in the /mcp dialog. Open /mcp, select '${serverName}', then use the Auth actions there.` - : 'MCP OAuth is now managed in the /mcp dialog. Open /mcp, select a server, then use the Auth actions there.', + ? t( + "MCP OAuth is now managed in the /mcp dialog. Open /mcp, select '{{serverName}}', then use the Auth actions there.", + { serverName }, + ) + : t( + 'MCP OAuth is now managed in the /mcp dialog. Open /mcp, select a server, then use the Auth actions there.', + ), }; } diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 092648a1503..b1f8876de1e 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -675,7 +675,7 @@ async function handleAutomaticOAuth( if (!oauthConfig) { debugLogger.error( - `Could not configure OAuth. ` + + `Could not configure OAuth for server '${mcpServerName}'. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); return false; @@ -1327,30 +1327,33 @@ export async function connectToMcpServer( // For SSE servers without explicit OAuth config, if a token was found but rejected, report it accurately. const tokenStorage = new MCPOAuthTokenStorage(); const credentials = await tokenStorage.getCredentials(mcpServerName); + let hasStoredTokens = false; if (credentials) { const authProvider = new MCPOAuthProvider(tokenStorage); - const hasStoredTokens = await authProvider.getValidToken( - mcpServerName, - { + hasStoredTokens = Boolean( + await authProvider.getValidToken(mcpServerName, { // Pass client ID if available clientId: credentials.clientId, - }, + }), ); if (hasStoredTokens) { debugLogger.warn( - `Stored OAuth token for the SSE server was rejected. ` + + `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName), ); } else { debugLogger.warn( - `401 error received for the SSE server without OAuth configuration. ` + + `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } throw new Error( - `401 error received for the SSE server without OAuth configuration. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName), + hasStoredTokens + ? `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName) + : `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } @@ -1487,18 +1490,18 @@ export async function connectToMcpServer( if (!shouldTryDiscovery) { const tokenStorage = new MCPOAuthTokenStorage(); const credentials = await tokenStorage.getCredentials(mcpServerName); + let hasStoredTokens = false; if (credentials) { const authProvider = new MCPOAuthProvider(tokenStorage); - const hasStoredTokens = await authProvider.getValidToken( - mcpServerName, - { + hasStoredTokens = Boolean( + await authProvider.getValidToken(mcpServerName, { // Pass client ID if available clientId: credentials.clientId, - }, + }), ); if (hasStoredTokens) { debugLogger.warn( - `Stored OAuth token for the SSE server was rejected. ` + + `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + getMcpOAuthDialogInstruction( 're-authenticate', mcpServerName, @@ -1506,14 +1509,17 @@ export async function connectToMcpServer( ); } else { debugLogger.warn( - `401 error received for the SSE server without OAuth configuration. ` + + `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } throw new Error( - `401 error received for the SSE server without OAuth configuration. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName), + hasStoredTokens + ? `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName) + : `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } @@ -1622,17 +1628,17 @@ export async function connectToMcpServer( } } else { debugLogger.error( - `Could not configure OAuth. ` + + `Could not configure OAuth for server '${mcpServerName}'. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw new Error( - `OAuth configuration failed. ` + + `OAuth configuration failed for server '${mcpServerName}'. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } catch (discoveryError) { debugLogger.error( - `OAuth discovery failed. ` + + `OAuth discovery failed for server '${mcpServerName}'. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw discoveryError; @@ -1759,11 +1765,11 @@ export async function createTransport( if (!accessToken) { debugLogger.error( - `The MCP server requires OAuth authentication. ` + + `The MCP server '${mcpServerName}' requires OAuth authentication. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); throw new Error( - `The MCP server requires OAuth authentication. ` + + `The MCP server '${mcpServerName}' requires OAuth authentication. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } From a56b5cf93a0474e386c0f4883b607c24532d2fbd Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 22 Jun 2026 21:58:00 +0800 Subject: [PATCH 06/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- packages/core/src/tools/mcp-client.test.ts | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 10e0223fa10..61421294baf 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -17,11 +17,13 @@ import { } from '../config/config.js'; import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { MCPOAuthProvider } from '../mcp/oauth-provider.js'; +import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; import type { ResourceRegistry } from '../resources/resource-registry.js'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; import { addMCPStatusChangeListener, + connectToMcpServer, createStreamableHttpCompatibilityFetch, createTransport, discoverPrompts, @@ -103,6 +105,89 @@ describe('mcp-client', () => { }); }); + describe('connectToMcpServer', () => { + afterEach(() => { + vi.mocked(MCPOAuthProvider).mockReset(); + vi.mocked(MCPOAuthTokenStorage).mockReset(); + }); + + it('reports rejected stored OAuth tokens for SSE servers', async () => { + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials: vi.fn().mockResolvedValue({ + clientId: 'client-id', + }), + }) as unknown as MCPOAuthTokenStorage, + ); + vi.mocked(MCPOAuthProvider).mockImplementation( + () => + ({ + getValidToken: vi.fn().mockResolvedValue('stored-token'), + }) as unknown as MCPOAuthProvider, + ); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + + await expect( + connectToMcpServer( + 'sse-server', + { url: 'http://test-server/sse' }, + false, + workspaceContext, + ), + ).rejects.toThrow( + "Stored OAuth token for SSE server 'sse-server' was rejected. Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'sse-server'.", + ); + }); + + it('reports missing OAuth configuration for SSE servers without valid stored tokens', async () => { + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials: vi.fn().mockResolvedValue({ + clientId: 'client-id', + }), + }) as unknown as MCPOAuthTokenStorage, + ); + vi.mocked(MCPOAuthProvider).mockImplementation( + () => + ({ + getValidToken: vi.fn().mockResolvedValue(null), + }) as unknown as MCPOAuthProvider, + ); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + + await expect( + connectToMcpServer( + 'sse-server', + { url: 'http://test-server/sse' }, + false, + workspaceContext, + ), + ).rejects.toThrow( + "401 error received for SSE server 'sse-server' without OAuth configuration. Open the /mcp dialog in Qwen Code to authenticate with MCP server 'sse-server'.", + ); + }); + }); + describe('McpClient', () => { it('should discover tools', async () => { const mockedClient = { From ac2a3d997d90f9de461bc557b1285bd80435fb54 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 22 Jun 2026 23:45:11 +0800 Subject: [PATCH 07/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- docs/developers/tools/mcp-server.md | 3 ++ docs/users/features/mcp.md | 3 ++ .../src/ui/components/BaseTextInput.test.tsx | 24 ++++++++++ .../cli/src/ui/components/BaseTextInput.tsx | 3 +- packages/core/src/tools/mcp-client.ts | 45 ++----------------- 5 files changed, 35 insertions(+), 43 deletions(-) diff --git a/docs/developers/tools/mcp-server.md b/docs/developers/tools/mcp-server.md index 4dc032c735d..9ec334e81ad 100644 --- a/docs/developers/tools/mcp-server.md +++ b/docs/developers/tools/mcp-server.md @@ -207,6 +207,9 @@ OAuth tokens are automatically: - **Validated** before each connection attempt - **Cleaned up** when invalid or expired +> [!WARNING] +> By default, OAuth tokens are stored unencrypted on disk. On shared or multi-user machines, set `QWEN_CODE_FORCE_ENCRYPTED_FILE_STORAGE=true` to protect credentials. + #### Authentication Provider Type You can specify the authentication provider type using the `authProviderType` property: diff --git a/docs/users/features/mcp.md b/docs/users/features/mcp.md index e1487620007..c3b99f73dc9 100644 --- a/docs/users/features/mcp.md +++ b/docs/users/features/mcp.md @@ -342,6 +342,9 @@ OAuth tokens are automatically: - **Refreshed** when expired (if refresh tokens are available) - **Validated** before each connection attempt +> [!WARNING] +> By default, OAuth tokens are stored unencrypted on disk. On shared or multi-user machines, set `QWEN_CODE_FORCE_ENCRYPTED_FILE_STORAGE=true` to protect credentials. + Use the `/mcp` dialog within Qwen Code to inspect MCP servers and manage authentication interactively. diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index 36e9c4fef63..88f1e994a24 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -12,6 +12,18 @@ import { useKeypress } from '../hooks/useKeypress.js'; import type { Key } from '../hooks/useKeypress.js'; import type { TextBuffer } from './shared/text-buffer.js'; +const mockSetCursorPosition = vi.hoisted(() => vi.fn()); + +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCursor: () => ({ + setCursorPosition: mockSetCursorPosition, + }), + }; +}); + vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); @@ -108,6 +120,18 @@ describe('BaseTextInput', () => { expect(buffer.handleInput).toHaveBeenCalledWith(typedKey); }); + + it('clears the physical cursor position on unmount', () => { + const buffer = createBuffer(); + const { unmount } = render( + , + ); + + mockSetCursorPosition.mockClear(); + unmount(); + + expect(mockSetCursorPosition).toHaveBeenCalledWith(undefined); + }); }); describe('getAbsolutePosition', () => { diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index c011dcc4141..2995806a076 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -20,7 +20,7 @@ */ import type { ReactNode } from 'react'; -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Box, Text, type DOMElement, useBoxMetrics, useCursor } from 'ink'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; @@ -281,6 +281,7 @@ export const BaseTextInput = ({ const rootRef = useRef(null); const { hasMeasured } = useBoxMetrics(rootRef); const { setCursorPosition } = useCursor(); + useEffect(() => () => setCursorPosition(undefined), [setCursorPosition]); const cursorPosition = showCursor && hasMeasured diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index b1f8876de1e..f7f0b70d271 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1482,48 +1482,9 @@ export async function connectToMcpServer( } } else { // No www-authenticate header found, but we got a 401 - // Only try OAuth discovery for HTTP servers or when OAuth is explicitly configured - // For SSE servers, we should not trigger new OAuth flows automatically - const shouldTryDiscovery = - mcpServerConfig.httpUrl || mcpServerConfig.oauth?.enabled; - - if (!shouldTryDiscovery) { - const tokenStorage = new MCPOAuthTokenStorage(); - const credentials = await tokenStorage.getCredentials(mcpServerName); - let hasStoredTokens = false; - if (credentials) { - const authProvider = new MCPOAuthProvider(tokenStorage); - hasStoredTokens = Boolean( - await authProvider.getValidToken(mcpServerName, { - // Pass client ID if available - clientId: credentials.clientId, - }), - ); - if (hasStoredTokens) { - debugLogger.warn( - `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + - getMcpOAuthDialogInstruction( - 're-authenticate', - mcpServerName, - ), - ); - } else { - debugLogger.warn( - `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName), - ); - } - } - throw new Error( - hasStoredTokens - ? `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + - getMcpOAuthDialogInstruction('re-authenticate', mcpServerName) - : `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName), - ); - } - - // For SSE/HTTP servers, try to discover OAuth configuration from the base URL + // For HTTP servers and explicitly configured OAuth, try to discover + // OAuth configuration from the base URL. SSE servers without explicit + // OAuth are handled above before this branch. debugLogger.info( `Attempting OAuth discovery for '${mcpServerName}'...`, ); From 57f604840facbcf874e29c2b887480c7139dcf4e Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 00:43:50 +0800 Subject: [PATCH 08/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- .../src/ui/components/BaseTextInput.test.tsx | 35 +++++++ .../cli/src/ui/components/BaseTextInput.tsx | 98 ++++++++++++++----- packages/core/src/tools/mcp-client.test.ts | 30 ++++++ packages/core/src/tools/mcp-client.ts | 33 +++---- 4 files changed, 156 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index 88f1e994a24..675e31c6d40 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -13,6 +13,9 @@ import type { Key } from '../hooks/useKeypress.js'; import type { TextBuffer } from './shared/text-buffer.js'; const mockSetCursorPosition = vi.hoisted(() => vi.fn()); +const mockAddLayoutListener = vi.hoisted(() => + vi.fn((_rootNode: DOMElement, _listener: () => void) => vi.fn()), +); vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); @@ -24,6 +27,14 @@ vi.mock('ink', async (importOriginal) => { }; }); +vi.mock('ink/dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + addLayoutListener: mockAddLayoutListener, + }; +}); + vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); @@ -84,6 +95,9 @@ function captureKeypressHandler(): (key: Key) => void { describe('BaseTextInput', () => { beforeEach(() => { vi.clearAllMocks(); + mockAddLayoutListener.mockImplementation( + (_rootNode: DOMElement, _listener: () => void) => vi.fn(), + ); }); it('does not type the render-mode shortcut into the buffer', () => { @@ -132,6 +146,27 @@ describe('BaseTextInput', () => { expect(mockSetCursorPosition).toHaveBeenCalledWith(undefined); }); + + it('updates the physical cursor position on root layout commits', () => { + const buffer = createBuffer(); + let layoutListener: (() => void) | undefined; + mockAddLayoutListener.mockImplementation((_rootNode, listener) => { + layoutListener = listener; + return vi.fn(); + }); + + render(); + + expect(mockAddLayoutListener).toHaveBeenCalledWith( + expect.objectContaining({ nodeName: 'ink-root' }), + expect.any(Function), + ); + + mockSetCursorPosition.mockClear(); + layoutListener?.(); + + expect(mockSetCursorPosition).toHaveBeenCalled(); + }); }); describe('getAbsolutePosition', () => { diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 2995806a076..268b92ce6cd 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -22,6 +22,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import { Box, Text, type DOMElement, useBoxMetrics, useCursor } from 'ink'; +import { addLayoutListener } from 'ink/dom'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -146,6 +147,47 @@ export function getAbsolutePosition( return { top, left }; } +function findRootNode(node: DOMElement | null): DOMElement | undefined { + let current: DOMElement | undefined = node ?? undefined; + while (current?.parentNode) { + current = current.parentNode; + } + return current?.nodeName === 'ink-root' ? current : undefined; +} + +function getPhysicalCursorPosition( + node: DOMElement | null, + { + showCursor, + cursorVisualRow, + cursorVisualCol, + scrollVisualRow, + linesToRender, + prefixWidth, + }: { + showCursor: boolean; + cursorVisualRow: number; + cursorVisualCol: number; + scrollVisualRow: number; + linesToRender: string[]; + prefixWidth: number; + }, +): { x: number; y: number } | undefined { + if (!showCursor) return undefined; + + const position = getAbsolutePosition(node); + if (!position) return undefined; + + const relativeRow = cursorVisualRow - scrollVisualRow; + const lineText = linesToRender[relativeRow] || ''; + const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); + const physicalCol = stringWidth(textBeforeCursor); + return { + x: position.left + prefixWidth + physicalCol, + y: position.top + relativeRow + 1, + }; +} + // ─── Component ────────────────────────────────────────────── export const BaseTextInput = ({ @@ -275,30 +317,42 @@ export const BaseTextInput = ({ const scrollVisualRow = buffer.visualScrollRow; // ── Physical cursor positioning for IME ── - // useBoxMetrics subscribes to Ink layout changes through public API. We still - // walk the parent chain to convert the component-relative Yoga layout into - // the absolute Ink output coordinates expected by useCursor. - const rootRef = useRef(null); - const { hasMeasured } = useBoxMetrics(rootRef); + const boxRef = useRef(null); + const { hasMeasured } = useBoxMetrics(boxRef); const { setCursorPosition } = useCursor(); + const cursorStateRef = useRef({ + showCursor, + cursorVisualRow, + cursorVisualCol, + scrollVisualRow, + linesToRender, + prefixWidth, + }); + cursorStateRef.current = { + showCursor, + cursorVisualRow, + cursorVisualCol, + scrollVisualRow, + linesToRender, + prefixWidth, + }; + + const getCurrentCursorPosition = useCallback( + () => getPhysicalCursorPosition(boxRef.current, cursorStateRef.current), + [], + ); + + useEffect(() => { + const rootNode = findRootNode(boxRef.current); + if (!rootNode) return undefined; + return addLayoutListener(rootNode, () => { + setCursorPosition(getCurrentCursorPosition()); + }); + }, [getCurrentCursorPosition, setCursorPosition]); + useEffect(() => () => setCursorPosition(undefined), [setCursorPosition]); - const cursorPosition = - showCursor && hasMeasured - ? (() => { - const position = getAbsolutePosition(rootRef.current); - if (!position) return undefined; - - const relativeRow = cursorVisualRow - scrollVisualRow; - const lineText = linesToRender[relativeRow] || ''; - const textBeforeCursor = cpSlice(lineText, 0, cursorVisualCol); - const physicalCol = stringWidth(textBeforeCursor); - return { - x: position.left + prefixWidth + physicalCol, - y: position.top + relativeRow + 1, - }; - })() - : undefined; + const cursorPosition = hasMeasured ? getCurrentCursorPosition() : undefined; // useCursor propagates its latest value during Ink's insertion effect, so the // position must be set during render rather than in a normal effect. @@ -319,7 +373,7 @@ export const BaseTextInput = ({ : '─'.repeat(columns); return ( - + {topBorderLine} diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 61421294baf..24c35cb2226 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -1566,6 +1566,36 @@ describe('mcp-client', () => { }); describe('authenticated Streamable HTTP compatibility fetch', () => { + it('throws the /mcp instruction when OAuth has no valid token', async () => { + const getValidToken = vi.fn().mockResolvedValue(null); + vi.mocked(MCPOAuthProvider).mockImplementation( + () => + ({ + getValidToken, + }) as unknown as MCPOAuthProvider, + ); + + await expect( + createTransport( + 'oauth-test-server', + { + httpUrl: 'http://test-server', + oauth: { + enabled: true, + clientId: 'client-id', + }, + }, + false, + ), + ).rejects.toThrow( + "Open the /mcp dialog in Qwen Code to authenticate with MCP server 'oauth-test-server'.", + ); + expect(getValidToken).toHaveBeenCalledWith('oauth-test-server', { + enabled: true, + clientId: 'client-id', + }); + }); + it('wires the compatibility fetch for OAuth httpUrl transports', async () => { const getValidToken = vi.fn().mockResolvedValue('oauth-token'); vi.mocked(MCPOAuthProvider).mockImplementation( diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index f7f0b70d271..2adf00091c3 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -88,6 +88,17 @@ export function getMcpOAuthDialogInstruction( ].join(' '); } +function getSseOAuth401Message( + mcpServerName: string, + hasStoredTokens: boolean, +): string { + return hasStoredTokens + ? `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName) + : `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName); +} + async function readResponseBodyExcerpt( response: Response, ): Promise { @@ -1336,25 +1347,11 @@ export async function connectToMcpServer( clientId: credentials.clientId, }), ); - if (hasStoredTokens) { - debugLogger.warn( - `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + - getMcpOAuthDialogInstruction('re-authenticate', mcpServerName), - ); - } else { - debugLogger.warn( - `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName), - ); - } + debugLogger.warn( + getSseOAuth401Message(mcpServerName, hasStoredTokens), + ); } - throw new Error( - hasStoredTokens - ? `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + - getMcpOAuthDialogInstruction('re-authenticate', mcpServerName) - : `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName), - ); + throw new Error(getSseOAuth401Message(mcpServerName, hasStoredTokens)); } // Try to extract www-authenticate header from the error From 2fe19a0d2cead4ad63a28b9f81071c172dd496a3 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 02:01:35 +0800 Subject: [PATCH 09/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- .../src/ui/components/BaseTextInput.test.tsx | 10 +++ .../cli/src/ui/components/BaseTextInput.tsx | 11 ++-- .../core/src/mcp/oauth-token-storage.test.ts | 14 ++++ packages/core/src/mcp/oauth-token-storage.ts | 14 ++++ packages/core/src/tools/mcp-client.test.ts | 41 ++++++++++-- packages/core/src/tools/mcp-client.ts | 64 +++++++++++++------ 6 files changed, 126 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index 675e31c6d40..5f9188f5206 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -147,6 +147,16 @@ describe('BaseTextInput', () => { expect(mockSetCursorPosition).toHaveBeenCalledWith(undefined); }); + it('hides the physical cursor when showCursor is false', () => { + const buffer = createBuffer(); + + render( + , + ); + + expect(mockSetCursorPosition).toHaveBeenCalledWith(undefined); + }); + it('updates the physical cursor position on root layout commits', () => { const buffer = createBuffer(); let layoutListener: (() => void) | undefined; diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 268b92ce6cd..59dd82f18d8 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -20,7 +20,7 @@ */ import type { ReactNode } from 'react'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useInsertionEffect, useRef } from 'react'; import { Box, Text, type DOMElement, useBoxMetrics, useCursor } from 'ink'; import { addLayoutListener } from 'ink/dom'; import chalk from 'chalk'; @@ -350,13 +350,12 @@ export const BaseTextInput = ({ }); }, [getCurrentCursorPosition, setCursorPosition]); - useEffect(() => () => setCursorPosition(undefined), [setCursorPosition]); - const cursorPosition = hasMeasured ? getCurrentCursorPosition() : undefined; - // useCursor propagates its latest value during Ink's insertion effect, so the - // position must be set during render rather than in a normal effect. - setCursorPosition(cursorPosition); + useInsertionEffect(() => { + setCursorPosition(cursorPosition); + return () => setCursorPosition(undefined); + }, [cursorPosition, setCursorPosition]); const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( diff --git a/packages/core/src/mcp/oauth-token-storage.test.ts b/packages/core/src/mcp/oauth-token-storage.test.ts index a8d036db47c..ccbe8ac99e7 100644 --- a/packages/core/src/mcp/oauth-token-storage.test.ts +++ b/packages/core/src/mcp/oauth-token-storage.test.ts @@ -144,6 +144,20 @@ describe('MCPOAuthTokenStorage', () => { }); describe('saveToken', () => { + it('should warn once when writing plaintext tokens', async () => { + vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(atomicWriteFile).mockResolvedValue(undefined); + + await tokenStorage.saveToken('server1', mockToken); + await tokenStorage.saveToken('server2', mockToken); + + expect(mockDebugLogger.warn).toHaveBeenCalledTimes(1); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining(FORCE_ENCRYPTED_FILE_ENV_VAR), + ); + }); + it('should save token with restricted permissions', async () => { vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); vi.mocked(fs.mkdir).mockResolvedValue(undefined); diff --git a/packages/core/src/mcp/oauth-token-storage.ts b/packages/core/src/mcp/oauth-token-storage.ts index fdd56a04079..616a89411b8 100644 --- a/packages/core/src/mcp/oauth-token-storage.ts +++ b/packages/core/src/mcp/oauth-token-storage.ts @@ -23,6 +23,19 @@ import { const debugLogger = createDebugLogger('MCP_OAUTH'); +let didWarnPlaintextTokenStorage = false; + +function warnPlaintextTokenStorage(tokenFile: string): void { + if (didWarnPlaintextTokenStorage) { + return; + } + didWarnPlaintextTokenStorage = true; + debugLogger.warn( + `MCP OAuth tokens are stored unencrypted at ${tokenFile}. ` + + `Set ${FORCE_ENCRYPTED_FILE_ENV_VAR}=true to require encrypted file storage.`, + ); +} + /** * Class for managing MCP OAuth token storage and retrieval. */ @@ -100,6 +113,7 @@ export class MCPOAuthTokenStorage implements TokenStorage { const tokenFile = this.getTokenFilePath(); try { + warnPlaintextTokenStorage(tokenFile); await atomicWriteFile(tokenFile, JSON.stringify(tokenArray, null, 2), { mode: 0o600, forceMode: true, diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 24c35cb2226..db6056f32f1 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -149,7 +149,12 @@ describe('mcp-client', () => { ); }); - it('reports missing OAuth configuration for SSE servers without valid stored tokens', async () => { + it('reports unusable stored OAuth tokens for SSE servers', async () => { + const getCredentials = vi + .fn() + .mockResolvedValueOnce({ clientId: 'client-id' }) + .mockResolvedValueOnce({ clientId: 'client-id' }) + .mockResolvedValueOnce(null); vi.mocked(ClientLib.Client).mockReturnValue({ connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), registerCapabilities: vi.fn(), @@ -159,9 +164,7 @@ describe('mcp-client', () => { vi.mocked(MCPOAuthTokenStorage).mockImplementation( () => ({ - getCredentials: vi.fn().mockResolvedValue({ - clientId: 'client-id', - }), + getCredentials, }) as unknown as MCPOAuthTokenStorage, ); vi.mocked(MCPOAuthProvider).mockImplementation( @@ -175,6 +178,36 @@ describe('mcp-client', () => { onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), } as unknown as WorkspaceContext; + await expect( + connectToMcpServer( + 'sse-server', + { url: 'http://test-server/sse' }, + false, + workspaceContext, + ), + ).rejects.toThrow( + "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'sse-server'.", + ); + }); + + it('reports missing OAuth configuration for SSE servers without stored credentials', async () => { + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials: vi.fn().mockResolvedValue(null), + }) as unknown as MCPOAuthTokenStorage, + ); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + await expect( connectToMcpServer( 'sse-server', diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 2adf00091c3..b9d15fcfd3b 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -88,15 +88,30 @@ export function getMcpOAuthDialogInstruction( ].join(' '); } +type SseOAuth401TokenState = 'accepted-token-rejected' | 'unusable' | 'missing'; + function getSseOAuth401Message( mcpServerName: string, - hasStoredTokens: boolean, + tokenState: SseOAuth401TokenState, ): string { - return hasStoredTokens - ? `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + - getMcpOAuthDialogInstruction('re-authenticate', mcpServerName) - : `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName); + if (tokenState === 'accepted-token-rejected') { + return ( + `Stored OAuth token for SSE server '${mcpServerName}' was rejected. ` + + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName) + ); + } + + if (tokenState === 'unusable') { + return ( + `Stored OAuth tokens for SSE server '${mcpServerName}' are expired or could not be refreshed. ` + + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName) + ); + } + + return ( + `401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName) + ); } async function readResponseBodyExcerpt( @@ -1304,7 +1319,20 @@ export async function connectToMcpServer( unlistenDirectories = undefined; }; + let hadStoredSseOAuthCredentials = false; + try { + if ( + mcpServerConfig.url && + !mcpServerConfig.httpUrl && + !mcpServerConfig.oauth?.enabled + ) { + const tokenStorage = new MCPOAuthTokenStorage(); + hadStoredSseOAuthCredentials = Boolean( + await tokenStorage.getCredentials(mcpServerName), + ); + } + const transport = await createTransport( mcpServerName, mcpServerConfig, @@ -1335,23 +1363,23 @@ export async function connectToMcpServer( mcpServerConfig.httpUrl || mcpServerConfig.oauth?.enabled; if (!shouldTriggerOAuth) { - // For SSE servers without explicit OAuth config, if a token was found but rejected, report it accurately. + // For SSE servers without explicit OAuth config, report whether + // the 401 came after trying stored credentials or without any OAuth setup. const tokenStorage = new MCPOAuthTokenStorage(); const credentials = await tokenStorage.getCredentials(mcpServerName); - let hasStoredTokens = false; + let tokenState: SseOAuth401TokenState = + credentials || hadStoredSseOAuthCredentials ? 'unusable' : 'missing'; if (credentials) { const authProvider = new MCPOAuthProvider(tokenStorage); - hasStoredTokens = Boolean( - await authProvider.getValidToken(mcpServerName, { - // Pass client ID if available - clientId: credentials.clientId, - }), - ); - debugLogger.warn( - getSseOAuth401Message(mcpServerName, hasStoredTokens), - ); + tokenState = (await authProvider.getValidToken(mcpServerName, { + // Pass client ID if available + clientId: credentials.clientId, + })) + ? 'accepted-token-rejected' + : 'unusable'; + debugLogger.warn(getSseOAuth401Message(mcpServerName, tokenState)); } - throw new Error(getSseOAuth401Message(mcpServerName, hasStoredTokens)); + throw new Error(getSseOAuth401Message(mcpServerName, tokenState)); } // Try to extract www-authenticate header from the error From 265b103caf15303827284abc3ef7e738575f7f6b Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 03:42:43 +0800 Subject: [PATCH 10/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- packages/cli/src/ui/components/BaseTextInput.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 59dd82f18d8..6bcb91bb802 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -350,12 +350,14 @@ export const BaseTextInput = ({ }); }, [getCurrentCursorPosition, setCursorPosition]); - const cursorPosition = hasMeasured ? getCurrentCursorPosition() : undefined; + // Ink publishes this ref from its own insertion effect, so write it before + // Ink's effect setup reads the latest committed position. + setCursorPosition(hasMeasured ? getCurrentCursorPosition() : undefined); - useInsertionEffect(() => { - setCursorPosition(cursorPosition); - return () => setCursorPosition(undefined); - }, [cursorPosition, setCursorPosition]); + useInsertionEffect( + () => () => setCursorPosition(undefined), + [setCursorPosition], + ); const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( From 269c864aada74d237de2b3656ec79a2d95cba317 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 05:24:21 +0800 Subject: [PATCH 11/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- docs/developers/sdk-typescript.md | 23 +++++---- packages/cli/src/ui/commands/mcpCommand.ts | 2 + .../core/src/mcp/oauth-token-storage.test.ts | 48 ++++++++++++++++--- packages/core/src/mcp/oauth-token-storage.ts | 8 ++-- packages/core/src/tools/mcp-client.test.ts | 37 ++++++++++++++ packages/core/src/tools/mcp-client.ts | 6 ++- packages/sdk-typescript/README.md | 21 ++++---- 7 files changed, 119 insertions(+), 26 deletions(-) diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index df1782e5b43..d46168a7ed8 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -68,12 +68,14 @@ Creates a new query session with the Qwen Code. | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | | `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['Read', 'Edit', 'Bash(git *)']`. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['Read', 'Edit', 'Bash(git *)']`. | | `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | | `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | -| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | | `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | | `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | +| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | +| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | ### Timeouts @@ -163,12 +165,17 @@ The SDK supports different permission modes for controlling tool execution: ### Permission Priority Chain -1. `excludeTools` - Blocks tools completely -2. `permissionMode: 'plan'` - Blocks non-read-only tools -3. `permissionMode: 'yolo'` - Auto-approves all tools -4. `allowedTools` - Auto-approves matching tools -5. `canUseTool` callback - Custom approval logic -6. Default behavior - Auto-deny in SDK mode +Decision priority (highest first): `deny` > `ask` > `allow` > _(default/interactive mode)_ + +The first matching rule wins. + +1. `excludeTools` / `permissions.deny` - Blocks tools completely (returns permission error) +2. `permissions.ask` - Always requires user confirmation +3. `permissionMode: 'plan'` - Blocks all non-read-only tools +4. `permissionMode: 'yolo'` - Auto-approves all tools +5. `allowedTools` / `permissions.allow` - Auto-approves matching tools +6. `canUseTool` callback - Custom approval logic (if provided, not called for allowed tools) +7. Default behavior - Auto-deny in SDK mode (write tools require explicit approval) ## Examples diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 83d29e6c9b8..ad09782f86f 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -41,6 +41,8 @@ export const mcpCommand: SlashCommand = { }; } + // desc, nodesc, and schema open this same MCP dialog. Their display state + // is owned outside this command, including Ctrl+T's slash-command path. return { type: 'dialog', dialog: 'mcp', diff --git a/packages/core/src/mcp/oauth-token-storage.test.ts b/packages/core/src/mcp/oauth-token-storage.test.ts index ccbe8ac99e7..0ac53bc6786 100644 --- a/packages/core/src/mcp/oauth-token-storage.test.ts +++ b/packages/core/src/mcp/oauth-token-storage.test.ts @@ -4,11 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from 'vitest'; import { promises as fs } from 'node:fs'; import * as path from 'node:path'; -import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; -import { FORCE_ENCRYPTED_FILE_ENV_VAR } from './token-storage/index.js'; +import type { MCPOAuthTokenStorage as MCPOAuthTokenStorageType } from './oauth-token-storage.js'; import type { OAuthCredentials, OAuthToken } from './token-storage/types.js'; import { QWEN_DIR } from '../utils/paths.js'; import { atomicWriteFile } from '../utils/atomicFileWrite.js'; @@ -64,7 +71,18 @@ vi.mock('./token-storage/hybrid-token-storage.js', () => ({ const ONE_HR_MS = 3600000; describe('MCPOAuthTokenStorage', () => { - let tokenStorage: MCPOAuthTokenStorage; + let MCPOAuthTokenStorage: typeof import('./oauth-token-storage.js').MCPOAuthTokenStorage; + let FORCE_ENCRYPTED_FILE_ENV_VAR: string; + let stderrWriteSpy: MockInstance; + let tokenStorage: MCPOAuthTokenStorageType; + + async function loadStorageModule(): Promise { + vi.resetModules(); + ({ FORCE_ENCRYPTED_FILE_ENV_VAR } = await import( + './token-storage/index.js' + )); + ({ MCPOAuthTokenStorage } = await import('./oauth-token-storage.js')); + } const mockToken: OAuthToken = { accessToken: 'access_token_123', @@ -83,7 +101,11 @@ describe('MCPOAuthTokenStorage', () => { }; describe('with encrypted flag false', () => { - beforeEach(() => { + beforeEach(async () => { + await loadStorageModule(); + stderrWriteSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((() => true) as typeof process.stderr.write); vi.stubEnv(FORCE_ENCRYPTED_FILE_ENV_VAR, 'false'); tokenStorage = new MCPOAuthTokenStorage(); @@ -156,6 +178,10 @@ describe('MCPOAuthTokenStorage', () => { expect(mockDebugLogger.warn).toHaveBeenCalledWith( expect.stringContaining(FORCE_ENCRYPTED_FILE_ENV_VAR), ); + expect(stderrWriteSpy).toHaveBeenCalledTimes(1); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining(FORCE_ENCRYPTED_FILE_ENV_VAR), + ); }); it('should save token with restricted permissions', async () => { @@ -275,6 +301,12 @@ describe('MCPOAuthTokenStorage', () => { expect(savedData).toHaveLength(1); expect(savedData[0].serverName).toBe('server2'); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining(FORCE_ENCRYPTED_FILE_ENV_VAR), + ); + expect(stderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining(FORCE_ENCRYPTED_FILE_ENV_VAR), + ); }); it('should remove token file when no tokens remain', async () => { @@ -392,7 +424,11 @@ describe('MCPOAuthTokenStorage', () => { }); describe('with encrypted flag true', () => { - beforeEach(() => { + beforeEach(async () => { + await loadStorageModule(); + stderrWriteSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((() => true) as typeof process.stderr.write); vi.stubEnv(FORCE_ENCRYPTED_FILE_ENV_VAR, 'true'); tokenStorage = new MCPOAuthTokenStorage(); diff --git a/packages/core/src/mcp/oauth-token-storage.ts b/packages/core/src/mcp/oauth-token-storage.ts index 616a89411b8..f2502c5f46c 100644 --- a/packages/core/src/mcp/oauth-token-storage.ts +++ b/packages/core/src/mcp/oauth-token-storage.ts @@ -30,10 +30,11 @@ function warnPlaintextTokenStorage(tokenFile: string): void { return; } didWarnPlaintextTokenStorage = true; - debugLogger.warn( + const message = `MCP OAuth tokens are stored unencrypted at ${tokenFile}. ` + - `Set ${FORCE_ENCRYPTED_FILE_ENV_VAR}=true to require encrypted file storage.`, - ); + `Set ${FORCE_ENCRYPTED_FILE_ENV_VAR}=true to require encrypted file storage.`; + debugLogger.warn(message); + process.stderr.write(`Warning: ${message}\n`); } /** @@ -194,6 +195,7 @@ export class MCPOAuthTokenStorage implements TokenStorage { // Remove file if no tokens left await fs.unlink(tokenFile); } else { + warnPlaintextTokenStorage(tokenFile); await atomicWriteFile( tokenFile, JSON.stringify(tokenArray, null, 2), diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index db6056f32f1..125d094f629 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -190,6 +190,43 @@ describe('mcp-client', () => { ); }); + it('logs unusable SSE OAuth tokens when stored credentials disappear', async () => { + const getCredentials = vi + .fn() + .mockResolvedValueOnce({ clientId: 'client-id' }) + .mockResolvedValueOnce(null); + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials, + }) as unknown as MCPOAuthTokenStorage, + ); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + + await expect( + connectToMcpServer( + 'sse-server', + { url: 'http://test-server/sse' }, + false, + workspaceContext, + ), + ).rejects.toThrow( + "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'sse-server'.", + ); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'sse-server'.", + ); + }); + it('reports missing OAuth configuration for SSE servers without stored credentials', async () => { vi.mocked(ClientLib.Client).mockReturnValue({ connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index b9d15fcfd3b..f997eb12b39 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1319,6 +1319,10 @@ export async function connectToMcpServer( unlistenDirectories = undefined; }; + // Snapshot credentials before createTransport. Its internal getValidToken + // call may refresh or purge stored tokens, so the later 401 handler needs + // this pre-check to distinguish "had credentials, now unusable" from + // "never had OAuth configuration." let hadStoredSseOAuthCredentials = false; try { @@ -1377,8 +1381,8 @@ export async function connectToMcpServer( })) ? 'accepted-token-rejected' : 'unusable'; - debugLogger.warn(getSseOAuth401Message(mcpServerName, tokenState)); } + debugLogger.warn(getSseOAuth401Message(mcpServerName, tokenState)); throw new Error(getSseOAuth401Message(mcpServerName, tokenState)); } diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 22370093c94..a077e41a759 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -65,10 +65,10 @@ Creates a new query session with the Qwen Code. | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | | `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['Read', 'Edit', 'Bash(git *)']`. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['Read', 'Edit', 'Bash(git *)']`. | | `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | | `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | -| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | | `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | | `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | | `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | @@ -81,12 +81,12 @@ Creates a new query session with the Qwen Code. The SDK enforces the following default timeouts: -| Timeout | Default | Description | -| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. | -| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. | -| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. | -| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. | +| Timeout | Default | Description | +| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. | +| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. | +| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, `getContextUsage()`, and `interrupt()` to complete. | +| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. | You can customize these timeouts via the `timeout` option: @@ -217,6 +217,11 @@ await q.setPermissionMode('yolo'); // Change model mid-session await q.setModel('qwen-max'); +// Get context window usage breakdown (token counts per category) +const usage = await q.getContextUsage(); +// Pass true to hint that per-item details should be displayed +const detail = await q.getContextUsage(true); + // Close the session await q.close(); ``` From 6b68eafbd2078206d52e640368305c48d13f6915 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 07:15:28 +0800 Subject: [PATCH 12/20] codex: fix BaseTextInput Ink import (#5589) Co-authored-by: Qwen-Coder --- .../src/ui/components/BaseTextInput.test.tsx | 68 ++++++++++--------- .../cli/src/ui/components/BaseTextInput.tsx | 63 ++++++----------- 2 files changed, 55 insertions(+), 76 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index 5f9188f5206..40cf5dee8db 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -7,34 +7,37 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render } from 'ink-testing-library'; import type { DOMElement } from 'ink'; -import { BaseTextInput, getAbsolutePosition } from './BaseTextInput.js'; +import { + BaseTextInput, + getAbsolutePosition, + getPhysicalCursorPosition, +} from './BaseTextInput.js'; import { useKeypress } from '../hooks/useKeypress.js'; import type { Key } from '../hooks/useKeypress.js'; import type { TextBuffer } from './shared/text-buffer.js'; const mockSetCursorPosition = vi.hoisted(() => vi.fn()); -const mockAddLayoutListener = vi.hoisted(() => - vi.fn((_rootNode: DOMElement, _listener: () => void) => vi.fn()), +const mockUseBoxMetrics = vi.hoisted(() => + vi.fn(() => ({ + width: 0, + height: 0, + top: 0, + left: 0, + hasMeasured: true, + })), ); vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + useBoxMetrics: mockUseBoxMetrics, useCursor: () => ({ setCursorPosition: mockSetCursorPosition, }), }; }); -vi.mock('ink/dom', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - addLayoutListener: mockAddLayoutListener, - }; -}); - vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); @@ -95,9 +98,13 @@ function captureKeypressHandler(): (key: Key) => void { describe('BaseTextInput', () => { beforeEach(() => { vi.clearAllMocks(); - mockAddLayoutListener.mockImplementation( - (_rootNode: DOMElement, _listener: () => void) => vi.fn(), - ); + mockUseBoxMetrics.mockReturnValue({ + width: 0, + height: 0, + top: 0, + left: 0, + hasMeasured: true, + }); }); it('does not type the render-mode shortcut into the buffer', () => { @@ -157,25 +164,22 @@ describe('BaseTextInput', () => { expect(mockSetCursorPosition).toHaveBeenCalledWith(undefined); }); - it('updates the physical cursor position on root layout commits', () => { - const buffer = createBuffer(); - let layoutListener: (() => void) | undefined; - mockAddLayoutListener.mockImplementation((_rootNode, listener) => { - layoutListener = listener; - return vi.fn(); - }); - - render(); - - expect(mockAddLayoutListener).toHaveBeenCalledWith( - expect.objectContaining({ nodeName: 'ink-root' }), - expect.any(Function), - ); - - mockSetCursorPosition.mockClear(); - layoutListener?.(); + it('positions the physical cursor from absolute Ink DOM position', () => { + const root = createElement(2, 3); + const parent = createElement(5, 7, root); + const child = createElement(11, 13, parent); - expect(mockSetCursorPosition).toHaveBeenCalled(); + expect( + getPhysicalCursorPosition(child, { + hasMeasured: true, + showCursor: true, + cursorVisualRow: 2, + cursorVisualCol: 3, + scrollVisualRow: 1, + linesToRender: ['', 'ab😀cd'], + prefixWidth: 2, + }), + ).toEqual({ x: 29, y: 20 }); }); }); diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 6bcb91bb802..c8f59efbcf6 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -20,9 +20,8 @@ */ import type { ReactNode } from 'react'; -import { useCallback, useEffect, useInsertionEffect, useRef } from 'react'; +import { useCallback, useInsertionEffect, useRef } from 'react'; import { Box, Text, type DOMElement, useBoxMetrics, useCursor } from 'ink'; -import { addLayoutListener } from 'ink/dom'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -127,6 +126,16 @@ export function defaultRenderLine({ // ─── Helpers ──────────────────────────────────────────────── +type PhysicalCursorState = { + hasMeasured: boolean; + showCursor: boolean; + cursorVisualRow: number; + cursorVisualCol: number; + scrollVisualRow: number; + linesToRender: string[]; + prefixWidth: number; +}; + export function getAbsolutePosition( node: DOMElement | null, ): { top: number; left: number } | undefined { @@ -147,33 +156,19 @@ export function getAbsolutePosition( return { top, left }; } -function findRootNode(node: DOMElement | null): DOMElement | undefined { - let current: DOMElement | undefined = node ?? undefined; - while (current?.parentNode) { - current = current.parentNode; - } - return current?.nodeName === 'ink-root' ? current : undefined; -} - -function getPhysicalCursorPosition( +export function getPhysicalCursorPosition( node: DOMElement | null, { + hasMeasured, showCursor, cursorVisualRow, cursorVisualCol, scrollVisualRow, linesToRender, prefixWidth, - }: { - showCursor: boolean; - cursorVisualRow: number; - cursorVisualCol: number; - scrollVisualRow: number; - linesToRender: string[]; - prefixWidth: number; - }, + }: PhysicalCursorState, ): { x: number; y: number } | undefined { - if (!showCursor) return undefined; + if (!showCursor || !hasMeasured) return undefined; const position = getAbsolutePosition(node); if (!position) return undefined; @@ -320,7 +315,8 @@ export const BaseTextInput = ({ const boxRef = useRef(null); const { hasMeasured } = useBoxMetrics(boxRef); const { setCursorPosition } = useCursor(); - const cursorStateRef = useRef({ + const cursorPosition = getPhysicalCursorPosition(boxRef.current, { + hasMeasured, showCursor, cursorVisualRow, cursorVisualCol, @@ -328,31 +324,10 @@ export const BaseTextInput = ({ linesToRender, prefixWidth, }); - cursorStateRef.current = { - showCursor, - cursorVisualRow, - cursorVisualCol, - scrollVisualRow, - linesToRender, - prefixWidth, - }; - - const getCurrentCursorPosition = useCallback( - () => getPhysicalCursorPosition(boxRef.current, cursorStateRef.current), - [], - ); - - useEffect(() => { - const rootNode = findRootNode(boxRef.current); - if (!rootNode) return undefined; - return addLayoutListener(rootNode, () => { - setCursorPosition(getCurrentCursorPosition()); - }); - }, [getCurrentCursorPosition, setCursorPosition]); - // Ink publishes this ref from its own insertion effect, so write it before + // Ink publishes this value from its own insertion effect, so write it before // Ink's effect setup reads the latest committed position. - setCursorPosition(hasMeasured ? getCurrentCursorPosition() : undefined); + setCursorPosition(cursorPosition); useInsertionEffect( () => () => setCursorPosition(undefined), From 1ff883fae5b4685df62f53247d826d4cb442b74f Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 09:19:48 +0800 Subject: [PATCH 13/20] codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder --- .../core/src/mcp/oauth-token-storage.test.ts | 20 ++++++++++++++++++ packages/core/src/mcp/oauth-token-storage.ts | 4 ++-- packages/core/src/tools/mcp-client.test.ts | 21 ++++++++++++------- packages/core/src/tools/mcp-client.ts | 4 +++- packages/sdk-typescript/src/types/types.ts | 6 +++--- 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/core/src/mcp/oauth-token-storage.test.ts b/packages/core/src/mcp/oauth-token-storage.test.ts index 0ac53bc6786..6edda43993c 100644 --- a/packages/core/src/mcp/oauth-token-storage.test.ts +++ b/packages/core/src/mcp/oauth-token-storage.test.ts @@ -247,6 +247,26 @@ describe('MCPOAuthTokenStorage', () => { expect.stringContaining('Failed to save MCP OAuth token'), ); }); + + it('should warn on a successful retry after a plaintext write fails', async () => { + vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(atomicWriteFile) + .mockRejectedValueOnce(new Error('Disk full')) + .mockResolvedValueOnce(undefined); + + await expect( + tokenStorage.saveToken('test-server', mockToken), + ).rejects.toThrow('Disk full'); + + expect(mockDebugLogger.warn).not.toHaveBeenCalled(); + expect(stderrWriteSpy).not.toHaveBeenCalled(); + + await tokenStorage.saveToken('test-server', mockToken); + + expect(mockDebugLogger.warn).toHaveBeenCalledTimes(1); + expect(stderrWriteSpy).toHaveBeenCalledTimes(1); + }); }); describe('getCredentials', () => { diff --git a/packages/core/src/mcp/oauth-token-storage.ts b/packages/core/src/mcp/oauth-token-storage.ts index f2502c5f46c..5e9454b4f74 100644 --- a/packages/core/src/mcp/oauth-token-storage.ts +++ b/packages/core/src/mcp/oauth-token-storage.ts @@ -114,12 +114,12 @@ export class MCPOAuthTokenStorage implements TokenStorage { const tokenFile = this.getTokenFilePath(); try { - warnPlaintextTokenStorage(tokenFile); await atomicWriteFile(tokenFile, JSON.stringify(tokenArray, null, 2), { mode: 0o600, forceMode: true, noFollow: true, }); + warnPlaintextTokenStorage(tokenFile); } catch (error) { debugLogger.error( `Failed to save MCP OAuth token: ${getErrorMessage(error)}`, @@ -195,12 +195,12 @@ export class MCPOAuthTokenStorage implements TokenStorage { // Remove file if no tokens left await fs.unlink(tokenFile); } else { - warnPlaintextTokenStorage(tokenFile); await atomicWriteFile( tokenFile, JSON.stringify(tokenArray, null, 2), { mode: 0o600, forceMode: true, noFollow: true }, ); + warnPlaintextTokenStorage(tokenFile); } } catch (error) { debugLogger.error( diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 125d094f629..31c540c742b 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -94,13 +94,13 @@ describe('mcp-client', () => { describe('getMcpOAuthDialogInstruction', () => { it('builds an authenticate instruction for the named MCP server', () => { expect(getMcpOAuthDialogInstruction('authenticate', 'foo')).toBe( - "Open the /mcp dialog in Qwen Code to authenticate with MCP server 'foo'.", + "In interactive Qwen Code sessions, open the /mcp dialog to authenticate with MCP server 'foo'. For headless or SDK usage, configure MCP OAuth with qwen mcp add --oauth-* or settings.json, then authenticate once in an interactive session before connecting.", ); }); it('builds a re-authenticate instruction for the named MCP server', () => { expect(getMcpOAuthDialogInstruction('re-authenticate', 'foo')).toBe( - "Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'foo'.", + "In interactive Qwen Code sessions, open the /mcp dialog to re-authenticate with MCP server 'foo'. For headless or SDK usage, configure MCP OAuth with qwen mcp add --oauth-* or settings.json, then re-authenticate once in an interactive session before connecting.", ); }); }); @@ -145,7 +145,8 @@ describe('mcp-client', () => { workspaceContext, ), ).rejects.toThrow( - "Stored OAuth token for SSE server 'sse-server' was rejected. Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'sse-server'.", + "Stored OAuth token for SSE server 'sse-server' was rejected. " + + getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'), ); }); @@ -186,7 +187,8 @@ describe('mcp-client', () => { workspaceContext, ), ).rejects.toThrow( - "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'sse-server'.", + "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " + + getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'), ); }); @@ -220,10 +222,12 @@ describe('mcp-client', () => { workspaceContext, ), ).rejects.toThrow( - "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'sse-server'.", + "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " + + getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'), ); expect(mockDebugLogger.warn).toHaveBeenCalledWith( - "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. Open the /mcp dialog in Qwen Code to re-authenticate with MCP server 'sse-server'.", + "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " + + getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'), ); }); @@ -253,7 +257,8 @@ describe('mcp-client', () => { workspaceContext, ), ).rejects.toThrow( - "401 error received for SSE server 'sse-server' without OAuth configuration. Open the /mcp dialog in Qwen Code to authenticate with MCP server 'sse-server'.", + "401 error received for SSE server 'sse-server' without OAuth configuration. " + + getMcpOAuthDialogInstruction('authenticate', 'sse-server'), ); }); }); @@ -1658,7 +1663,7 @@ describe('mcp-client', () => { false, ), ).rejects.toThrow( - "Open the /mcp dialog in Qwen Code to authenticate with MCP server 'oauth-test-server'.", + getMcpOAuthDialogInstruction('authenticate', 'oauth-test-server'), ); expect(getValidToken).toHaveBeenCalledWith('oauth-test-server', { enabled: true, diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index f997eb12b39..cc2f8328ef1 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -83,8 +83,10 @@ export function getMcpOAuthDialogInstruction( mcpServerName: string, ): string { return [ - `Open the /mcp dialog in Qwen Code to ${action}`, + `In interactive Qwen Code sessions, open the /mcp dialog to ${action}`, `with MCP server '${mcpServerName}'.`, + `For headless or SDK usage, configure MCP OAuth with qwen mcp add --oauth-*`, + `or settings.json, then ${action} once in an interactive session before connecting.`, ].join(' '); } diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 3ebb163222d..3a7e26ee0aa 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -371,10 +371,10 @@ export interface QueryOptions { * If specified, only matching core tools are registered for the session. * This is separate from `permissions.allow`, which auto-approves matching * tool calls but does not restrict tool registration. + * Aliases like 'Read', 'Edit', and 'Bash' also work but resolve to single + * tools. Specifiers like 'Bash(git *)' are stripped; `coreTools` restricts + * tool registration, not invocation. * @example ['read_file', 'edit', 'run_shell_command'] - * // Aliases like 'Read', 'Edit', and 'Bash' also work but resolve to - * // single tools. Specifiers like 'Bash(git *)' are stripped; `coreTools` - * // restricts tool registration, not invocation. */ coreTools?: string[]; From 5e7c3b005ec592915ff76de994a4856947ef5ff7 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 11:10:02 +0800 Subject: [PATCH 14/20] fix(core): surface MCP OAuth credential read failures Fix SSE OAuth credential pre-check failures by reporting token storage read errors before connecting. Update SDK coreTools docs and extension release link text from the follow-up review. Co-authored-by: Qwen-Coder --- docs/developers/sdk-typescript.md | 5 ++- docs/users/extension/extension-releasing.md | 2 +- packages/core/src/tools/mcp-client.test.ts | 37 +++++++++++++++++++++ packages/core/src/tools/mcp-client.ts | 22 ++++++++---- packages/sdk-typescript/README.md | 4 +-- 5 files changed, 59 insertions(+), 11 deletions(-) diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index d46168a7ed8..ebbb526b0bd 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -68,7 +68,7 @@ Creates a new query session with the Qwen Code. | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | | `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['Read', 'Edit', 'Bash(git *)']`. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. | | `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | | `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | | `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | @@ -77,6 +77,9 @@ Creates a new query session with the Qwen Code. | `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | | `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | +> [!note] +> For `coreTools`, aliases like `Read`, `Edit`, and `Bash` also work, but invocation specifiers such as `Bash(git *)` are stripped. `coreTools` restricts tool registration, not invocation patterns. + ### Timeouts The SDK enforces the following default timeouts: diff --git a/docs/users/extension/extension-releasing.md b/docs/users/extension/extension-releasing.md index 797e7264100..426f3e53745 100644 --- a/docs/users/extension/extension-releasing.md +++ b/docs/users/extension/extension-releasing.md @@ -75,7 +75,7 @@ To ensure Qwen Code can automatically find the correct release asset for each pl Archives must be fully contained extensions and have all the standard requirements - specifically the `qwen-extension.json` file must be at the root of the archive. -The rest of the layout should look exactly the same as a typical extension, see [extensions.md](./introduction.md). +The rest of the layout should look exactly the same as a typical extension, see [introduction.md](./introduction.md). #### Example GitHub Actions workflow diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 31c540c742b..f4c26d21cec 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -261,6 +261,43 @@ describe('mcp-client', () => { getMcpOAuthDialogInstruction('authenticate', 'sse-server'), ); }); + + it('reports SSE OAuth credential read failures before connecting', async () => { + const connect = vi.fn(); + vi.mocked(ClientLib.Client).mockReturnValue({ + connect, + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials: vi + .fn() + .mockRejectedValue(new Error('Corrupt token file')), + }) as unknown as MCPOAuthTokenStorage, + ); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + + await expect( + connectToMcpServer( + 'sse-server', + { url: 'http://test-server/sse' }, + false, + workspaceContext, + ), + ).rejects.toThrow( + "Failed to read stored OAuth credentials for SSE server 'sse-server': Corrupt token file", + ); + expect(connect).not.toHaveBeenCalled(); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + "Failed to read stored OAuth credentials for SSE server 'sse-server': Corrupt token file", + ); + }); }); describe('McpClient', () => { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index cc2f8328ef1..d944da95770 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1327,18 +1327,26 @@ export async function connectToMcpServer( // "never had OAuth configuration." let hadStoredSseOAuthCredentials = false; - try { - if ( - mcpServerConfig.url && - !mcpServerConfig.httpUrl && - !mcpServerConfig.oauth?.enabled - ) { - const tokenStorage = new MCPOAuthTokenStorage(); + if ( + mcpServerConfig.url && + !mcpServerConfig.httpUrl && + !mcpServerConfig.oauth?.enabled + ) { + const tokenStorage = new MCPOAuthTokenStorage(); + try { hadStoredSseOAuthCredentials = Boolean( await tokenStorage.getCredentials(mcpServerName), ); + } catch (error) { + unlistenDirectories?.(); + unlistenDirectories = undefined; + const message = `Failed to read stored OAuth credentials for SSE server '${mcpServerName}': ${getErrorMessage(error)}`; + debugLogger.error(message); + throw new Error(message); } + } + try { const transport = await createTransport( mcpServerName, mcpServerConfig, diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index a077e41a759..027e697e71c 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -65,7 +65,7 @@ Creates a new query session with the Qwen Code. | `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | | `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | | `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['Read', 'Edit', 'Bash(git *)']`. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. | | `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | | `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | | `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | @@ -75,7 +75,7 @@ Creates a new query session with the Qwen Code. | `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | > [!tip] -> If you need to configure `coreTools`, `excludeTools`, or `allowedTools`, it is **strongly recommended** to read the [permissions configuration documentation](../../docs/users/configuration/settings.md#permissions) first, especially the **Tool name aliases** and **Rule syntax examples** sections, to understand the available aliases and pattern matching syntax (e.g., `Bash(git *)`, `Read(.env)`, `Edit(/src/**)`). +> If you need to configure `coreTools`, `excludeTools`, or `allowedTools`, it is **strongly recommended** to read the [permissions configuration documentation](../../docs/users/configuration/settings.md#permissions) first, especially the **Tool name aliases** and **Rule syntax examples** sections. Rule patterns such as `Bash(git *)`, `Read(.env)`, and `Edit(/src/**)` apply to `excludeTools` and `allowedTools`; `coreTools` accepts aliases but strips invocation specifiers. ### Timeouts From 02052e1b10009224299ee1f43e8fad6fe7905f0b Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 12:45:00 +0800 Subject: [PATCH 15/20] fix(core): harden MCP OAuth error handling Handle stderr warning failures as best-effort and keep SSE 401 OAuth guidance when credential re-read fails. Co-authored-by: Qwen-Coder --- .../core/src/mcp/oauth-token-storage.test.ts | 16 ++++++++ packages/core/src/mcp/oauth-token-storage.ts | 6 ++- packages/core/src/tools/mcp-client.test.ts | 40 +++++++++++++++++++ packages/core/src/tools/mcp-client.ts | 15 +++++-- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/core/src/mcp/oauth-token-storage.test.ts b/packages/core/src/mcp/oauth-token-storage.test.ts index 6edda43993c..187c139944a 100644 --- a/packages/core/src/mcp/oauth-token-storage.test.ts +++ b/packages/core/src/mcp/oauth-token-storage.test.ts @@ -267,6 +267,22 @@ describe('MCPOAuthTokenStorage', () => { expect(mockDebugLogger.warn).toHaveBeenCalledTimes(1); expect(stderrWriteSpy).toHaveBeenCalledTimes(1); }); + + it('should not fail token writes when stderr warning output fails', async () => { + vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(atomicWriteFile).mockResolvedValue(undefined); + stderrWriteSpy.mockImplementationOnce(() => { + throw new Error('EPIPE'); + }); + + await expect( + tokenStorage.saveToken('test-server', mockToken), + ).resolves.toBeUndefined(); + + expect(stderrWriteSpy).toHaveBeenCalledTimes(1); + expect(mockDebugLogger.error).not.toHaveBeenCalled(); + }); }); describe('getCredentials', () => { diff --git a/packages/core/src/mcp/oauth-token-storage.ts b/packages/core/src/mcp/oauth-token-storage.ts index 5e9454b4f74..7877d45387a 100644 --- a/packages/core/src/mcp/oauth-token-storage.ts +++ b/packages/core/src/mcp/oauth-token-storage.ts @@ -34,7 +34,11 @@ function warnPlaintextTokenStorage(tokenFile: string): void { `MCP OAuth tokens are stored unencrypted at ${tokenFile}. ` + `Set ${FORCE_ENCRYPTED_FILE_ENV_VAR}=true to require encrypted file storage.`; debugLogger.warn(message); - process.stderr.write(`Warning: ${message}\n`); + try { + process.stderr.write(`Warning: ${message}\n`); + } catch { + // Stderr is best-effort; token persistence has already succeeded. + } } /** diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index f4c26d21cec..b011893f59b 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -231,6 +231,46 @@ describe('mcp-client', () => { ); }); + it('reports SSE OAuth guidance when credentials fail to re-read after 401', async () => { + const getCredentials = vi + .fn() + .mockResolvedValueOnce({ clientId: 'client-id' }) + .mockResolvedValueOnce({ clientId: 'client-id' }) + .mockRejectedValueOnce(new Error('Corrupt token file')); + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials, + }) as unknown as MCPOAuthTokenStorage, + ); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + const oauthMessage = + "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " + + getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'); + + await expect( + connectToMcpServer( + 'sse-server', + { url: 'http://test-server/sse' }, + false, + workspaceContext, + ), + ).rejects.toThrow(oauthMessage); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + "Failed to re-read stored OAuth credentials for SSE server 'sse-server' after 401: Corrupt token file", + ); + expect(mockDebugLogger.warn).toHaveBeenCalledWith(oauthMessage); + }); + it('reports missing OAuth configuration for SSE servers without stored credentials', async () => { vi.mocked(ClientLib.Client).mockReturnValue({ connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index d944da95770..d85391fab63 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -52,6 +52,7 @@ import { pathToFileURL } from 'node:url'; import { MCPOAuthProvider } from '../mcp/oauth-provider.js'; import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js'; import { OAuthUtils } from '../mcp/oauth-utils.js'; +import type { OAuthCredentials } from '../mcp/token-storage/types.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; import type { ResourceRegistry } from '../resources/resource-registry.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -1380,7 +1381,14 @@ export async function connectToMcpServer( // For SSE servers without explicit OAuth config, report whether // the 401 came after trying stored credentials or without any OAuth setup. const tokenStorage = new MCPOAuthTokenStorage(); - const credentials = await tokenStorage.getCredentials(mcpServerName); + let credentials: OAuthCredentials | null = null; + try { + credentials = await tokenStorage.getCredentials(mcpServerName); + } catch (credentialError) { + debugLogger.error( + `Failed to re-read stored OAuth credentials for SSE server '${mcpServerName}' after 401: ${getErrorMessage(credentialError)}`, + ); + } let tokenState: SseOAuth401TokenState = credentials || hadStoredSseOAuthCredentials ? 'unusable' : 'missing'; if (credentials) { @@ -1392,8 +1400,9 @@ export async function connectToMcpServer( ? 'accepted-token-rejected' : 'unusable'; } - debugLogger.warn(getSseOAuth401Message(mcpServerName, tokenState)); - throw new Error(getSseOAuth401Message(mcpServerName, tokenState)); + const oauthMessage = getSseOAuth401Message(mcpServerName, tokenState); + debugLogger.warn(oauthMessage); + throw new Error(oauthMessage); } // Try to extract www-authenticate header from the error From 6dcca8daa81cafcb43132a1232421c33aea1fbd4 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 16:10:14 +0800 Subject: [PATCH 16/20] fix(core): keep SSE OAuth pre-read best effort Avoid blocking SSE MCP connections when the diagnostic credential pre-read fails, and cover BaseTextInput absolute-position edge cases. Co-authored-by: Qwen-Coder --- .../src/ui/components/BaseTextInput.test.tsx | 21 +++++++++++++++++++ packages/core/src/tools/mcp-client.test.ts | 20 +++++++++--------- packages/core/src/tools/mcp-client.ts | 8 +++---- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index 4df8249fc13..71024973962 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -197,6 +197,27 @@ describe('getAbsolutePosition', () => { expect(getAbsolutePosition(child)).toEqual({ top: 18, left: 23 }); }); + + it('skips nodes without yogaNode in the parent chain', () => { + const root = createElement(2, 3); + const middle = { parentNode: root } as unknown as DOMElement; + const child = createElement(11, 13, middle); + + expect(getAbsolutePosition(child)).toEqual({ top: 13, left: 16 }); + }); + + it('skips nodes whose getComputedLayout returns undefined', () => { + const root = createElement(2, 3); + const middle = { + yogaNode: { + getComputedLayout: () => undefined, + }, + parentNode: root, + } as unknown as DOMElement; + const child = createElement(11, 13, middle); + + expect(getAbsolutePosition(child)).toEqual({ top: 13, left: 16 }); + }); }); describe('defaultRenderLine', () => { diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index b011893f59b..fdb8c779c52 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -302,8 +302,12 @@ describe('mcp-client', () => { ); }); - it('reports SSE OAuth credential read failures before connecting', async () => { + it('continues connecting when the SSE OAuth credential pre-read fails', async () => { const connect = vi.fn(); + const getCredentials = vi + .fn() + .mockRejectedValueOnce(new Error('Corrupt token file')) + .mockResolvedValue(null); vi.mocked(ClientLib.Client).mockReturnValue({ connect, registerCapabilities: vi.fn(), @@ -313,9 +317,7 @@ describe('mcp-client', () => { vi.mocked(MCPOAuthTokenStorage).mockImplementation( () => ({ - getCredentials: vi - .fn() - .mockRejectedValue(new Error('Corrupt token file')), + getCredentials, }) as unknown as MCPOAuthTokenStorage, ); const workspaceContext = { @@ -330,12 +332,10 @@ describe('mcp-client', () => { false, workspaceContext, ), - ).rejects.toThrow( - "Failed to read stored OAuth credentials for SSE server 'sse-server': Corrupt token file", - ); - expect(connect).not.toHaveBeenCalled(); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - "Failed to read stored OAuth credentials for SSE server 'sse-server': Corrupt token file", + ).resolves.toBeDefined(); + expect(connect).toHaveBeenCalledOnce(); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + "Failed to pre-read stored OAuth credentials for SSE server 'sse-server': Corrupt token file", ); }); }); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index d85391fab63..21714b97aa7 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1339,11 +1339,9 @@ export async function connectToMcpServer( await tokenStorage.getCredentials(mcpServerName), ); } catch (error) { - unlistenDirectories?.(); - unlistenDirectories = undefined; - const message = `Failed to read stored OAuth credentials for SSE server '${mcpServerName}': ${getErrorMessage(error)}`; - debugLogger.error(message); - throw new Error(message); + debugLogger.warn( + `Failed to pre-read stored OAuth credentials for SSE server '${mcpServerName}': ${getErrorMessage(error)}`, + ); } } From 7881659e6c4b0dc74e4e105e934c7ca72ffb0333 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 18:11:39 +0800 Subject: [PATCH 17/20] fix(core): handle SSE OAuth validation errors Co-authored-by: Qwen-Coder --- packages/core/src/tools/mcp-client.test.ts | 50 ++++++++++++++++++++++ packages/core/src/tools/mcp-client.ts | 19 +++++--- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index fdb8c779c52..bebda66b0ac 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -192,6 +192,56 @@ describe('mcp-client', () => { ); }); + it('reports unusable SSE OAuth tokens when token validation fails', async () => { + const getCredentials = vi + .fn() + .mockResolvedValueOnce({ clientId: 'client-id' }) + .mockResolvedValueOnce({ clientId: 'client-id' }) + .mockResolvedValueOnce({ clientId: 'client-id' }); + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials, + }) as unknown as MCPOAuthTokenStorage, + ); + const getValidToken = vi + .fn() + .mockResolvedValueOnce(null) + .mockRejectedValue(new Error('Token store unavailable')); + vi.mocked(MCPOAuthProvider).mockImplementation( + () => + ({ + getValidToken, + }) as unknown as MCPOAuthProvider, + ); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + const oauthMessage = + "Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " + + getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'); + + await expect( + connectToMcpServer( + 'sse-server', + { url: 'http://test-server/sse' }, + false, + workspaceContext, + ), + ).rejects.toThrow(oauthMessage); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + "Failed to validate stored OAuth token for SSE server 'sse-server': Token store unavailable", + ); + expect(mockDebugLogger.warn).toHaveBeenCalledWith(oauthMessage); + }); + it('logs unusable SSE OAuth tokens when stored credentials disappear', async () => { const getCredentials = vi .fn() diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 21714b97aa7..8fee27d846e 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1391,12 +1391,19 @@ export async function connectToMcpServer( credentials || hadStoredSseOAuthCredentials ? 'unusable' : 'missing'; if (credentials) { const authProvider = new MCPOAuthProvider(tokenStorage); - tokenState = (await authProvider.getValidToken(mcpServerName, { - // Pass client ID if available - clientId: credentials.clientId, - })) - ? 'accepted-token-rejected' - : 'unusable'; + try { + tokenState = (await authProvider.getValidToken(mcpServerName, { + // Pass client ID if available + clientId: credentials.clientId, + })) + ? 'accepted-token-rejected' + : 'unusable'; + } catch (tokenError) { + debugLogger.error( + `Failed to validate stored OAuth token for SSE server '${mcpServerName}': ${getErrorMessage(tokenError)}`, + ); + tokenState = 'unusable'; + } } const oauthMessage = getSseOAuth401Message(mcpServerName, tokenState); debugLogger.warn(oauthMessage); From 8732cd720f7d20ae12d96d9d0f94bc7e433d7949 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 23 Jun 2026 19:57:06 +0800 Subject: [PATCH 18/20] fix(core): surface MCP OAuth recovery guidance Co-authored-by: Qwen-Coder --- packages/core/src/tools/mcp-client.test.ts | 120 ++++++++++++++ packages/core/src/tools/mcp-client.ts | 174 +++++++++++---------- 2 files changed, 211 insertions(+), 83 deletions(-) diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index bebda66b0ac..7f877cd8a5a 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -18,6 +18,7 @@ import { import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { MCPOAuthProvider } from '../mcp/oauth-provider.js'; import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js'; +import { OAuthUtils } from '../mcp/oauth-utils.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; import type { ResourceRegistry } from '../resources/resource-registry.js'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; @@ -388,6 +389,84 @@ describe('mcp-client', () => { "Failed to pre-read stored OAuth credentials for SSE server 'sse-server': Corrupt token file", ); }); + + it('reports OAuth guidance when automatic OAuth handling fails', async () => { + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi + .fn() + .mockRejectedValue( + new Error( + 'HTTP 401 Unauthorized\nwww-authenticate: Bearer realm="example", resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + ), + ), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials: vi.fn().mockResolvedValue(null), + }) as unknown as MCPOAuthTokenStorage, + ); + vi.spyOn(OAuthUtils, 'discoverOAuthConfig').mockResolvedValue(null); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + const oauthMessage = + "Failed to handle automatic OAuth for server 'http-server'. " + + getMcpOAuthDialogInstruction('authenticate', 'http-server'); + + await expect( + connectToMcpServer( + 'http-server', + { httpUrl: 'http://test-server/mcp' }, + false, + workspaceContext, + ), + ).rejects.toThrow(oauthMessage); + expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage); + }); + + it('wraps OAuth discovery errors with remediation guidance', async () => { + vi.mocked(ClientLib.Client).mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials: vi.fn().mockResolvedValue(null), + }) as unknown as MCPOAuthTokenStorage, + ); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 404 }), + ); + vi.spyOn(OAuthUtils, 'discoverOAuthConfig').mockRejectedValue( + new Error('Discovery timed out'), + ); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + const oauthMessage = + "OAuth discovery failed for server 'http-server'. " + + getMcpOAuthDialogInstruction('authenticate', 'http-server') + + ' Original error: Discovery timed out'; + + await expect( + connectToMcpServer( + 'http-server', + { httpUrl: 'http://test-server/mcp' }, + false, + workspaceContext, + ), + ).rejects.toThrow(oauthMessage); + expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage); + }); }); describe('McpClient', () => { @@ -1768,6 +1847,11 @@ describe('mcp-client', () => { }); describe('authenticated Streamable HTTP compatibility fetch', () => { + afterEach(() => { + vi.mocked(MCPOAuthProvider).mockReset(); + vi.mocked(MCPOAuthTokenStorage).mockReset(); + }); + it('throws the /mcp instruction when OAuth has no valid token', async () => { const getValidToken = vi.fn().mockResolvedValue(null); vi.mocked(MCPOAuthProvider).mockImplementation( @@ -1798,6 +1882,42 @@ describe('mcp-client', () => { }); }); + it('warns when stored OAuth credentials cannot produce a token', async () => { + const getCredentials = vi.fn().mockResolvedValue({ + clientId: 'client-id', + }); + const getValidToken = vi.fn().mockResolvedValue(null); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials, + }) as unknown as MCPOAuthTokenStorage, + ); + vi.mocked(MCPOAuthProvider).mockImplementation( + () => + ({ + getValidToken, + }) as unknown as MCPOAuthProvider, + ); + + const transport = await createTransport( + 'oauth-test-server', + { + httpUrl: 'http://test-server', + }, + false, + ); + + expect(transport).toBeInstanceOf(StreamableHTTPClientTransport); + expect(getCredentials).toHaveBeenCalledWith('oauth-test-server'); + expect(getValidToken).toHaveBeenCalledWith('oauth-test-server', { + clientId: 'client-id', + }); + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + "Stored OAuth credentials exist for server 'oauth-test-server' but no valid token could be obtained. Transport will be created without authentication; expect a 401.", + ); + }); + it('wires the compatibility fetch for OAuth httpUrl transports', async () => { const getValidToken = vi.fn().mockResolvedValue('oauth-token'); vi.mocked(MCPOAuthProvider).mockImplementation( diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 8fee27d846e..72b6460bd02 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1526,12 +1526,11 @@ export async function connectToMcpServer( ); } } else { - debugLogger.error( - `Failed to handle automatic OAuth for server '${mcpServerName}'`, - ); - throw new Error( - `Failed to handle automatic OAuth for server '${mcpServerName}'`, - ); + const oauthMessage = + `Failed to handle automatic OAuth for server '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName); + debugLogger.error(oauthMessage); + throw new Error(oauthMessage); } } else { // No www-authenticate header found, but we got a 401 @@ -1548,114 +1547,119 @@ export async function connectToMcpServer( ); const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`; + let oauthConfig: Awaited< + ReturnType + >; try { // Try to discover OAuth configuration from the base URL - const oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl); - if (oauthConfig) { - debugLogger.info( - `Discovered OAuth configuration from base URL for server '${mcpServerName}'`, - ); + oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl); + } catch (discoveryError) { + const oauthMessage = + `OAuth discovery failed for server '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName) + + ` Original error: ${getErrorMessage(discoveryError)}`; + debugLogger.error(oauthMessage); + throw new Error(oauthMessage, { cause: discoveryError }); + } - // Create OAuth configuration for authentication - const oauthAuthConfig = { - enabled: true, - authorizationUrl: oauthConfig.authorizationUrl, - tokenUrl: oauthConfig.tokenUrl, - scopes: oauthConfig.scopes || [], - }; - - // Perform OAuth authentication - // Pass the server URL for proper discovery - const authServerUrl = - mcpServerConfig.httpUrl || mcpServerConfig.url; - debugLogger.info( - `Starting OAuth authentication for server '${mcpServerName}'...`, - ); - const authProvider = new MCPOAuthProvider( - new MCPOAuthTokenStorage(), - ); - await authProvider.authenticate( + if (oauthConfig) { + debugLogger.info( + `Discovered OAuth configuration from base URL for server '${mcpServerName}'`, + ); + + // Create OAuth configuration for authentication + const oauthAuthConfig = { + enabled: true, + authorizationUrl: oauthConfig.authorizationUrl, + tokenUrl: oauthConfig.tokenUrl, + scopes: oauthConfig.scopes || [], + }; + + // Perform OAuth authentication + // Pass the server URL for proper discovery + const authServerUrl = + mcpServerConfig.httpUrl || mcpServerConfig.url; + debugLogger.info( + `Starting OAuth authentication for server '${mcpServerName}'...`, + ); + const authProvider = new MCPOAuthProvider( + new MCPOAuthTokenStorage(), + ); + await authProvider.authenticate( + mcpServerName, + oauthAuthConfig, + authServerUrl, + ); + + // Retry connection with OAuth token + const tokenStorage = new MCPOAuthTokenStorage(); + const credentials = + await tokenStorage.getCredentials(mcpServerName); + if (credentials) { + const authProvider = new MCPOAuthProvider(tokenStorage); + const accessToken = await authProvider.getValidToken( mcpServerName, - oauthAuthConfig, - authServerUrl, + { + // Pass client ID if available + clientId: credentials.clientId, + }, ); - - // Retry connection with OAuth token - const tokenStorage = new MCPOAuthTokenStorage(); - const credentials = - await tokenStorage.getCredentials(mcpServerName); - if (credentials) { - const authProvider = new MCPOAuthProvider(tokenStorage); - const accessToken = await authProvider.getValidToken( + if (accessToken) { + // Create transport with OAuth token + const oauthTransport = await createTransportWithOAuth( mcpServerName, - { - // Pass client ID if available - clientId: credentials.clientId, - }, + mcpServerConfig, + accessToken, ); - if (accessToken) { - // Create transport with OAuth token - const oauthTransport = await createTransportWithOAuth( - mcpServerName, - mcpServerConfig, - accessToken, - ); - if (oauthTransport) { - try { - await mcpClient.connect(oauthTransport, { - timeout: - mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, - }); - // Connection successful with OAuth - return mcpClient; - } catch (retryError) { - debugLogger.error( - `Failed to connect with OAuth token: ${getErrorMessage( - retryError, - )}`, - ); - throw retryError; - } - } else { + if (oauthTransport) { + try { + await mcpClient.connect(oauthTransport, { + timeout: + mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, + }); + // Connection successful with OAuth + return mcpClient; + } catch (retryError) { debugLogger.error( - `Failed to create OAuth transport for server '${mcpServerName}'`, - ); - throw new Error( - `Failed to create OAuth transport for server '${mcpServerName}'`, + `Failed to connect with OAuth token: ${getErrorMessage( + retryError, + )}`, ); + throw retryError; } } else { debugLogger.error( - `Failed to get OAuth token for server '${mcpServerName}'`, + `Failed to create OAuth transport for server '${mcpServerName}'`, ); throw new Error( - `Failed to get OAuth token for server '${mcpServerName}'`, + `Failed to create OAuth transport for server '${mcpServerName}'`, ); } } else { debugLogger.error( - `Failed to get stored credentials for server '${mcpServerName}'`, + `Failed to get OAuth token for server '${mcpServerName}'`, ); throw new Error( - `Failed to get stored credentials for server '${mcpServerName}'`, + `Failed to get OAuth token for server '${mcpServerName}'`, ); } } else { debugLogger.error( - `Could not configure OAuth for server '${mcpServerName}'. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName), + `Failed to get stored credentials for server '${mcpServerName}'`, ); throw new Error( - `OAuth configuration failed for server '${mcpServerName}'. ` + - getMcpOAuthDialogInstruction('authenticate', mcpServerName), + `Failed to get stored credentials for server '${mcpServerName}'`, ); } - } catch (discoveryError) { + } else { debugLogger.error( - `OAuth discovery failed for server '${mcpServerName}'. ` + + `Could not configure OAuth for server '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName), + ); + throw new Error( + `OAuth configuration failed for server '${mcpServerName}'. ` + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); - throw discoveryError; } } else { debugLogger.error( @@ -1803,6 +1807,10 @@ export async function createTransport( debugLogger.debug( `Found stored OAuth token for server '${mcpServerName}'`, ); + } else { + debugLogger.warn( + `Stored OAuth credentials exist for server '${mcpServerName}' but no valid token could be obtained. Transport will be created without authentication; expect a 401.`, + ); } } } From 6f4af3900732409cfa4d522eb951986b77f9e567 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 24 Jun 2026 02:03:09 +0800 Subject: [PATCH 19/20] fix(core): cover MCP OAuth retry paths Co-authored-by: Qwen-Coder --- .../cli/src/ui/components/BaseTextInput.tsx | 2 +- packages/core/src/tools/mcp-client.test.ts | 126 ++++++++++++++++++ packages/core/src/tools/mcp-client.ts | 3 +- 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index b4f340b17f4..8d9b57d1b0c 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -126,7 +126,7 @@ export function defaultRenderLine({ // ─── Helpers ──────────────────────────────────────────────── -type PhysicalCursorState = { +export type PhysicalCursorState = { hasMeasured: boolean; showCursor: boolean; cursorVisualRow: number; diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 7f877cd8a5a..6a0e80c5607 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -112,6 +112,57 @@ describe('mcp-client', () => { vi.mocked(MCPOAuthTokenStorage).mockReset(); }); + function setupHttpOAuthRetry(connectError: Error) { + const connect = vi + .fn() + .mockRejectedValueOnce(connectError) + .mockResolvedValueOnce(undefined); + vi.mocked(ClientLib.Client).mockReturnValue({ + connect, + registerCapabilities: vi.fn(), + setRequestHandler: vi.fn(), + notification: vi.fn(), + } as unknown as ClientLib.Client); + const getCredentials = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ clientId: 'client-id' }); + vi.mocked(MCPOAuthTokenStorage).mockImplementation( + () => + ({ + getCredentials, + }) as unknown as MCPOAuthTokenStorage, + ); + const authenticate = vi.fn().mockResolvedValue(undefined); + const getValidToken = vi.fn().mockResolvedValue('access-token'); + vi.mocked(MCPOAuthProvider).mockImplementation( + () => + ({ + authenticate, + getValidToken, + }) as unknown as MCPOAuthProvider, + ); + const discoverOAuthConfig = vi + .spyOn(OAuthUtils, 'discoverOAuthConfig') + .mockResolvedValue({ + authorizationUrl: 'https://auth.example/authorize', + tokenUrl: 'https://auth.example/token', + scopes: ['mcp.read'], + }); + const workspaceContext = { + getDirectories: vi.fn().mockReturnValue([]), + onDirectoriesChanged: vi.fn().mockReturnValue(vi.fn()), + } as unknown as WorkspaceContext; + + return { + authenticate, + connect, + discoverOAuthConfig, + getValidToken, + workspaceContext, + }; + } + it('reports rejected stored OAuth tokens for SSE servers', async () => { vi.mocked(ClientLib.Client).mockReturnValue({ connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), @@ -429,6 +480,81 @@ describe('mcp-client', () => { expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage); }); + it('retries HTTP connections after base-url OAuth discovery without www-authenticate', async () => { + const { + authenticate, + connect, + discoverOAuthConfig, + getValidToken, + workspaceContext, + } = setupHttpOAuthRetry(new Error('HTTP 401 Unauthorized')); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 401 }), + ); + + await expect( + connectToMcpServer( + 'http-server', + { httpUrl: 'http://test-server/mcp' }, + false, + workspaceContext, + ), + ).resolves.toBeDefined(); + + expect(discoverOAuthConfig).toHaveBeenCalledWith('http://test-server'); + expect(authenticate).toHaveBeenCalledWith( + 'http-server', + { + enabled: true, + authorizationUrl: 'https://auth.example/authorize', + tokenUrl: 'https://auth.example/token', + scopes: ['mcp.read'], + }, + 'http://test-server/mcp', + ); + expect(getValidToken).toHaveBeenCalledWith('http-server', { + clientId: 'client-id', + }); + expect(connect).toHaveBeenCalledTimes(2); + const oauthTransport = connect.mock.calls[1][0] as { + _requestInit?: { headers?: Record }; + }; + expect(oauthTransport._requestInit?.headers).toMatchObject({ + Authorization: 'Bearer access-token', + }); + }); + + it('falls back to base-url OAuth discovery when www-authenticate lacks resource metadata', async () => { + const { authenticate, connect, discoverOAuthConfig, workspaceContext } = + setupHttpOAuthRetry( + new Error( + 'HTTP 401 Unauthorized\nwww-authenticate: Bearer realm="example"', + ), + ); + + await expect( + connectToMcpServer( + 'http-server', + { httpUrl: 'http://test-server/mcp' }, + false, + workspaceContext, + ), + ).resolves.toBeDefined(); + + expect(discoverOAuthConfig).toHaveBeenCalledWith('http://test-server'); + expect(authenticate).toHaveBeenCalledWith( + 'http-server', + { + enabled: true, + authorizationUrl: 'https://auth.example/authorize', + tokenUrl: 'https://auth.example/token', + scopes: ['mcp.read'], + }, + 'http://test-server/mcp', + ); + expect(connect).toHaveBeenCalledTimes(2); + }); + it('wraps OAuth discovery errors with remediation guidance', async () => { vi.mocked(ClientLib.Client).mockReturnValue({ connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 72b6460bd02..60f2a014d2b 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1666,7 +1666,8 @@ export async function connectToMcpServer( `'${mcpServerName}' requires authentication but no OAuth configuration found`, ); throw new Error( - `MCP server '${mcpServerName}' requires authentication. Please configure OAuth or check server settings.`, + `MCP server '${mcpServerName}' requires authentication. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName), ); } } From 31b649bae45e02767eeb2dbb93733cbe815d74d3 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 24 Jun 2026 03:52:38 +0800 Subject: [PATCH 20/20] fix(core): address OAuth guidance review Co-authored-by: Qwen-Coder --- docs/developers/daemon/00-index.md | 13 +-- .../cli/src/ui/components/BaseTextInput.tsx | 12 +-- packages/core/src/tools/mcp-client.test.ts | 80 ++++++++++++++++++- packages/core/src/tools/mcp-client.ts | 36 ++++----- 4 files changed, 107 insertions(+), 34 deletions(-) diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index 57c0e036df9..990e34d3528 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -9,6 +9,7 @@ It complements, rather than replaces, these existing docs: | [`../../users/qwen-serve.md`](../../users/qwen-serve.md) | Operators | User quickstart, flags, threat model | | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md) | Protocol implementers | HTTP route catalog, request/response shapes, error codes | | [`../examples/daemon-client-quickstart.md`](../examples/daemon-client-quickstart.md) | SDK users | End-to-end TypeScript walkthrough | +| [`../daemon-client-adapters/`](../daemon-client-adapters/) | Adapter authors | Legacy client adapter design docs | | [`14-cli-tui-adapter.md`](./14-cli-tui-adapter.md) | Adapter authors | Client adapter design notes | | [`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md) | F2 maintainers | Workspace MCP transport pool design v2.2 | @@ -85,7 +86,7 @@ Use these anchors when moving from the docs into the latest `main` code: | Surface | Implementation anchors | Primary docs | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| Bootstrap and HTTP assembly | `packages/cli/src/serve/run-qwen-serve.ts`, `server.ts`, `/demo` | [`02`](./02-serve-runtime.md), [`20`](./20-quickstart-operations.md) | +| Bootstrap and HTTP assembly | `packages/cli/src/serve/run-qwen-serve.ts`, `server.ts`, `/demo` | [`02`](./02-serve-runtime.md), [`20`](./20-quickstart-operations.md) | | ACP bridge and session multiplexing | `packages/acp-bridge/src/bridge.ts`, `packages/acp-bridge/src/bridgeTypes.ts`, `@qwen-code/acp-bridge` | [`03`](./03-acp-bridge.md), [`08`](./08-session-lifecycle.md) | | Permission mediation | `packages/acp-bridge/src/permissionMediator.ts`, `fromLoopback: boolean`, `policy.*` | [`04`](./04-permission-mediation.md), [`12`](./12-auth-security.md) | | MCP transport pool | `packages/core/src/tools/mcp-transport-pool.ts`, `mcp-pool-key.ts`, `pid-descendants.ts`, `session-mcp-view.ts`, `/mcp refresh`, `MCPCallInterruptedError` | [`05`](./05-mcp-transport-pool.md), [`06`](./06-mcp-budget-guardrails.md) | @@ -94,7 +95,7 @@ Use these anchors when moving from the docs into the latest `main` code: | Event schema and SSE writer | `packages/sdk-typescript/src/daemon/events.ts`, `packages/cli/src/serve/server.ts`, `formatSseFrame`, `packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts`, `ToolCallEmitter.resolveToolProvenance`, `tool_call.provenance`, `serverId` | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | | Event resync | `state_resync_required`, `awaitingResync`, `RESYNC_PASSTHROUGH_TYPES`, `asKnownDaemonEvent`, `unrecognizedKnownEventCount` | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | | Capabilities | `packages/cli/src/serve/capabilities.ts`, `mcp_server_restart_refused.reason`, `MCP_RESTART_REFUSED_REASONS.has` | [`11`](./11-capabilities-versioning.md) | -| Auth and device flow | `packages/cli/src/serve/auth.ts`, `packages/cli/src/serve/auth/device-flow.ts` | [`12`](./12-auth-security.md) | +| Auth and device flow | `packages/cli/src/serve/auth.ts`, `packages/cli/src/serve/auth/device-flow.ts` | [`12`](./12-auth-security.md) | | TypeScript SDK daemon client | `packages/sdk-typescript/src/daemon/{DaemonClient,DaemonSessionClient,DaemonAuthFlow,sse,events,types}.ts`, `MCP_RESTART_DEFAULT_TIMEOUT_MS` | [`13`](./13-sdk-daemon-client.md) | | Shared UI transcript layer | `DaemonUiEventType`, `DaemonSessionProvider`, `packages/webui/src/daemon/` | [`13`](./13-sdk-daemon-client.md), [`14`](./14-cli-tui-adapter.md), [`../daemon-ui/README.md`](../daemon-ui/README.md) | | Channels and IDE adapters | `packages/channels/`, `packages/vscode-ide-companion/src/services/daemonIdeConnection.ts` | [`15`](./15-channel-adapters.md), [`16`](./16-vscode-ide-adapter.md) | @@ -149,11 +150,11 @@ Use these anchors when moving from the docs into the latest `main` code: ### Historical or deprecated surfaces -| Surface | Status | -| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | -| `docs/developers/daemon-client-adapters/tui.md` | Historical draft for the old `DaemonTuiAdapter` spike; current shared UI transcript architecture is in doc 14. | +| Surface | Status | +| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `docs/developers/daemon-client-adapters/tui.md` | Historical draft for the old `DaemonTuiAdapter` spike; current shared UI transcript architecture is in doc 14. | | `packages/cli/src/ui/daemon/daemon-tui-adapter.ts` | Legacy experimental adapter still in-tree. New shared UI work should prefer SDK `daemon/ui/*`. | -| `--no-http-bridge` | Accepted for compatibility but falls back to http-bridge and prints stderr. | +| `--no-http-bridge` | Accepted for compatibility but falls back to http-bridge and prints stderr. | ### Forward compatibility diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 8d9b57d1b0c..26f91f15aaf 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -325,14 +325,10 @@ export const BaseTextInput = ({ prefixWidth, }); - // Ink publishes this value from its own insertion effect, so write it before - // Ink's effect setup reads the latest committed position. - setCursorPosition(cursorPosition); - - useInsertionEffect( - () => () => setCursorPosition(undefined), - [setCursorPosition], - ); + useInsertionEffect(() => { + setCursorPosition(cursorPosition); + return () => setCursorPosition(undefined); + }, [setCursorPosition, cursorPosition]); const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 6a0e80c5607..ed1dc5a094c 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -158,6 +158,7 @@ describe('mcp-client', () => { authenticate, connect, discoverOAuthConfig, + getCredentials, getValidToken, workspaceContext, }; @@ -555,6 +556,79 @@ describe('mcp-client', () => { expect(connect).toHaveBeenCalledTimes(2); }); + it('reports OAuth guidance when post-discovery transport creation fails', async () => { + const config = { httpUrl: 'http://test-server/mcp' }; + const { connect, discoverOAuthConfig, getValidToken, workspaceContext } = + setupHttpOAuthRetry(new Error('HTTP 401 Unauthorized')); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 401 }), + ); + getValidToken.mockImplementation(async () => { + config.httpUrl = 'not a valid URL'; + return 'access-token'; + }); + const oauthMessage = + "Failed to create OAuth transport for server 'http-server'. " + + getMcpOAuthDialogInstruction('authenticate', 'http-server'); + + await expect( + connectToMcpServer('http-server', config, false, workspaceContext), + ).rejects.toThrow(oauthMessage); + + expect(discoverOAuthConfig).toHaveBeenCalledWith('http://test-server'); + expect(connect).toHaveBeenCalledTimes(1); + expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage); + }); + + it('reports OAuth guidance when post-discovery token lookup fails', async () => { + const { getValidToken, workspaceContext } = setupHttpOAuthRetry( + new Error('HTTP 401 Unauthorized'), + ); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 401 }), + ); + getValidToken.mockResolvedValue(null); + const oauthMessage = + "Failed to get OAuth token for server 'http-server'. " + + getMcpOAuthDialogInstruction('authenticate', 'http-server'); + + await expect( + connectToMcpServer( + 'http-server', + { httpUrl: 'http://test-server/mcp' }, + false, + workspaceContext, + ), + ).rejects.toThrow(oauthMessage); + + expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage); + }); + + it('reports OAuth guidance when post-discovery credentials are unavailable', async () => { + const { getCredentials, workspaceContext } = setupHttpOAuthRetry( + new Error('HTTP 401 Unauthorized'), + ); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 401 }), + ); + getCredentials.mockReset(); + getCredentials.mockResolvedValue(null); + const oauthMessage = + "Failed to get stored credentials for server 'http-server'. " + + getMcpOAuthDialogInstruction('authenticate', 'http-server'); + + await expect( + connectToMcpServer( + 'http-server', + { httpUrl: 'http://test-server/mcp' }, + false, + workspaceContext, + ), + ).rejects.toThrow(oauthMessage); + + expect(mockDebugLogger.error).toHaveBeenCalledWith(oauthMessage); + }); + it('wraps OAuth discovery errors with remediation guidance', async () => { vi.mocked(ClientLib.Client).mockReturnValue({ connect: vi.fn().mockRejectedValue(new Error('HTTP 401 Unauthorized')), @@ -2040,7 +2114,11 @@ describe('mcp-client', () => { clientId: 'client-id', }); expect(mockDebugLogger.warn).toHaveBeenCalledWith( - "Stored OAuth credentials exist for server 'oauth-test-server' but no valid token could be obtained. Transport will be created without authentication; expect a 401.", + "Stored OAuth credentials exist for server 'oauth-test-server' but no valid token could be obtained. Transport will be created without authentication; expect a 401. " + + getMcpOAuthDialogInstruction( + 're-authenticate', + 'oauth-test-server', + ), ); }); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 60f2a014d2b..c19dc67edbb 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1628,28 +1628,25 @@ export async function connectToMcpServer( throw retryError; } } else { - debugLogger.error( - `Failed to create OAuth transport for server '${mcpServerName}'`, - ); - throw new Error( - `Failed to create OAuth transport for server '${mcpServerName}'`, - ); + const oauthMessage = + `Failed to create OAuth transport for server '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName); + debugLogger.error(oauthMessage); + throw new Error(oauthMessage); } } else { - debugLogger.error( - `Failed to get OAuth token for server '${mcpServerName}'`, - ); - throw new Error( - `Failed to get OAuth token for server '${mcpServerName}'`, - ); + const oauthMessage = + `Failed to get OAuth token for server '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName); + debugLogger.error(oauthMessage); + throw new Error(oauthMessage); } } else { - debugLogger.error( - `Failed to get stored credentials for server '${mcpServerName}'`, - ); - throw new Error( - `Failed to get stored credentials for server '${mcpServerName}'`, - ); + const oauthMessage = + `Failed to get stored credentials for server '${mcpServerName}'. ` + + getMcpOAuthDialogInstruction('authenticate', mcpServerName); + debugLogger.error(oauthMessage); + throw new Error(oauthMessage); } } else { debugLogger.error( @@ -1810,7 +1807,8 @@ export async function createTransport( ); } else { debugLogger.warn( - `Stored OAuth credentials exist for server '${mcpServerName}' but no valid token could be obtained. Transport will be created without authentication; expect a 401.`, + `Stored OAuth credentials exist for server '${mcpServerName}' but no valid token could be obtained. Transport will be created without authentication; expect a 401. ` + + getMcpOAuthDialogInstruction('re-authenticate', mcpServerName), ); } }