mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(core): Align MCP OAuth guidance and docs (#5589)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* 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 <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix BaseTextInput Ink import (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5589) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * fix(core): handle SSE OAuth validation errors Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): surface MCP OAuth recovery guidance Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): cover MCP OAuth retry paths Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): address OAuth guidance review Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
099b47edca
commit
a234860a4a
43 changed files with 1405 additions and 374 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ 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/`](../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 |
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1599,7 +1599,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -68,12 +68,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 `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_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. |
|
||||
| `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. |
|
||||
|
||||
> [!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
|
||||
|
||||
|
|
@ -163,12 +168,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,11 +202,14 @@ 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` (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
|
||||
|
||||
> [!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:
|
||||
|
|
@ -859,9 +852,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 +866,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.
|
||||
|
|
|
|||
|
|
@ -497,7 +497,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 |
|
||||
| ------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
|
|
@ -657,11 +657,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`<br>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` |
|
||||
|
|
@ -710,7 +710,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/<configured-context-filename>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
|
||||
- Scope: Provides default instructions for all your projects.
|
||||
|
|
@ -718,11 +718,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [introduction.md](./introduction.md).
|
||||
|
||||
#### Example GitHub Actions workflow
|
||||
|
||||
|
|
|
|||
|
|
@ -263,7 +263,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.
|
||||
|
|
@ -287,24 +287,12 @@ Extensions can require configuration through settings (such as API keys or crede
|
|||
qwen extensions settings set <extension-name> <setting-name> [--scope user|workspace]
|
||||
```
|
||||
|
||||
**List all settings for an extension:**
|
||||
**List all settings and current values for an extension:**
|
||||
|
||||
```bash
|
||||
qwen extensions settings list <extension-name>
|
||||
```
|
||||
|
||||
**View current values (user and workspace):**
|
||||
|
||||
```bash
|
||||
qwen extensions settings show <extension-name> <setting-name>
|
||||
```
|
||||
|
||||
**Remove a setting value:**
|
||||
|
||||
```bash
|
||||
qwen extensions settings unset <extension-name> <setting-name> [--scope user|workspace]
|
||||
```
|
||||
|
||||
Settings can be configured at two levels:
|
||||
|
||||
- **User level** (default): Settings apply across all projects (`~/.qwen/.env`)
|
||||
|
|
@ -316,7 +304,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -337,11 +345,15 @@ 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` (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
|
||||
|
||||
Use the `/mcp auth` command within Qwen Code to manage OAuth authentication interactively.
|
||||
> [!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.
|
||||
|
||||
### Tool filtering (allow/deny tools per server)
|
||||
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -465,7 +465,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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -149,5 +149,49 @@ 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 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');
|
||||
|
||||
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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -13,11 +17,35 @@ 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<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
}),
|
||||
action: async (
|
||||
_context,
|
||||
args = '',
|
||||
): Promise<MessageActionReturn | OpenDialogActionReturn> => {
|
||||
const [subcommand, serverName] = args.trim().split(/\s+/);
|
||||
|
||||
if (subcommand === 'auth' || subcommand === 'noauth') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'warning',
|
||||
content: serverName
|
||||
? 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.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 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',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,12 +6,40 @@
|
|||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { BaseTextInput, defaultRenderLine } from './BaseTextInput.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
import {
|
||||
BaseTextInput,
|
||||
defaultRenderLine,
|
||||
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';
|
||||
import { renderSoftwareCursor } from '../utils/software-cursor.js';
|
||||
|
||||
const mockSetCursorPosition = vi.hoisted(() => 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<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
useBoxMetrics: mockUseBoxMetrics,
|
||||
useCursor: () => ({
|
||||
setCursorPosition: mockSetCursorPosition,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
|
@ -48,6 +76,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) {
|
||||
|
|
@ -59,6 +100,13 @@ function captureKeypressHandler(): (key: Key) => void {
|
|||
describe('BaseTextInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseBoxMetrics.mockReturnValue({
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
hasMeasured: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not type the render-mode shortcut into the buffer', () => {
|
||||
|
|
@ -96,6 +144,83 @@ describe('BaseTextInput', () => {
|
|||
expect(buffer.handleInput).toHaveBeenCalledWith(typedKey);
|
||||
});
|
||||
|
||||
it('clears the physical cursor position on unmount', () => {
|
||||
const buffer = createBuffer();
|
||||
const { unmount } = render(
|
||||
<BaseTextInput buffer={buffer} onSubmit={vi.fn()} />,
|
||||
);
|
||||
|
||||
mockSetCursorPosition.mockClear();
|
||||
unmount();
|
||||
|
||||
expect(mockSetCursorPosition).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('hides the physical cursor when showCursor is false', () => {
|
||||
const buffer = createBuffer();
|
||||
|
||||
render(
|
||||
<BaseTextInput buffer={buffer} onSubmit={vi.fn()} showCursor={false} />,
|
||||
);
|
||||
|
||||
expect(mockSetCursorPosition).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
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(
|
||||
getPhysicalCursorPosition(child, {
|
||||
hasMeasured: true,
|
||||
showCursor: true,
|
||||
cursorVisualRow: 2,
|
||||
cursorVisualCol: 3,
|
||||
scrollVisualRow: 1,
|
||||
linesToRender: ['', 'ab😀cd'],
|
||||
prefixWidth: 2,
|
||||
}),
|
||||
).toEqual({ x: 29, y: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('renders the software cursor on the current character', () => {
|
||||
const { lastFrame } = render(
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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, useInsertionEffect, useRef } from 'react';
|
||||
import { Box, Text, type DOMElement, useBoxMetrics, useCursor } from 'ink';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
|
@ -128,15 +126,61 @@ 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<string, unknown> & { parentNode?: unknown }) | null,
|
||||
): DOMElement | undefined {
|
||||
export 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 {
|
||||
if (!node) return undefined;
|
||||
if (!node.parentNode)
|
||||
return node['nodeName'] === 'ink-root' ? (node as DOMElement) : undefined;
|
||||
return findRootNode(node.parentNode as Record<string, unknown>);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function getPhysicalCursorPosition(
|
||||
node: DOMElement | null,
|
||||
{
|
||||
hasMeasured,
|
||||
showCursor,
|
||||
cursorVisualRow,
|
||||
cursorVisualCol,
|
||||
scrollVisualRow,
|
||||
linesToRender,
|
||||
prefixWidth,
|
||||
}: PhysicalCursorState,
|
||||
): { x: number; y: number } | undefined {
|
||||
if (!showCursor || !hasMeasured) 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 ──────────────────────────────────────────────
|
||||
|
|
@ -268,16 +312,11 @@ 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({
|
||||
const boxRef = useRef<DOMElement | null>(null);
|
||||
const { hasMeasured } = useBoxMetrics(boxRef);
|
||||
const { setCursorPosition } = useCursor();
|
||||
const cursorPosition = getPhysicalCursorPosition(boxRef.current, {
|
||||
hasMeasured,
|
||||
showCursor,
|
||||
cursorVisualRow,
|
||||
cursorVisualCol,
|
||||
|
|
@ -285,62 +324,11 @@ export const BaseTextInput = ({
|
|||
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]);
|
||||
useInsertionEffect(() => {
|
||||
setCursorPosition(cursorPosition);
|
||||
return () => setCursorPosition(undefined);
|
||||
}, [setCursorPosition, cursorPosition]);
|
||||
|
||||
const resolvedBorderColor = borderColor ?? theme.border.focused;
|
||||
const resolvedPrefix = prefix ?? (
|
||||
|
|
@ -357,7 +345,7 @@ export const BaseTextInput = ({
|
|||
: '─'.repeat(columns);
|
||||
|
||||
return (
|
||||
<Box ref={rootRef} flexDirection="column">
|
||||
<Box ref={boxRef} flexDirection="column">
|
||||
<Text color={resolvedBorderColor} wrap="truncate-end">
|
||||
{topBorderLine}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -283,8 +283,7 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
|||
{t('to hide descriptions')}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}- {t('Use')}{' '}
|
||||
<Text color={theme.text.accent}>/mcp auth <server-name></Text>{' '}
|
||||
{' '}- {t('Use')} <Text color={theme.text.accent}>/mcp</Text>{' '}
|
||||
{t('to authenticate with OAuth-enabled servers')}
|
||||
</Text>
|
||||
<Text>
|
||||
|
|
|
|||
|
|
@ -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 <server-name> to authenticate with OAuth-enabled servers
|
||||
- Use /mcp to authenticate with OAuth-enabled servers
|
||||
- Press Ctrl+T to toggle tool descriptions on/off"
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<typeof process.stderr.write>;
|
||||
let tokenStorage: MCPOAuthTokenStorageType;
|
||||
|
||||
async function loadStorageModule(): Promise<void> {
|
||||
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();
|
||||
|
||||
|
|
@ -144,6 +166,24 @@ 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),
|
||||
);
|
||||
expect(stderrWriteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(stderrWriteSpy).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);
|
||||
|
|
@ -207,6 +247,42 @@ 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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
|
@ -261,6 +337,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 () => {
|
||||
|
|
@ -378,7 +460,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();
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,24 @@ import {
|
|||
|
||||
const debugLogger = createDebugLogger('MCP_OAUTH');
|
||||
|
||||
let didWarnPlaintextTokenStorage = false;
|
||||
|
||||
function warnPlaintextTokenStorage(tokenFile: string): void {
|
||||
if (didWarnPlaintextTokenStorage) {
|
||||
return;
|
||||
}
|
||||
didWarnPlaintextTokenStorage = true;
|
||||
const message =
|
||||
`MCP OAuth tokens are stored unencrypted at ${tokenFile}. ` +
|
||||
`Set ${FORCE_ENCRYPTED_FILE_ENV_VAR}=true to require encrypted file storage.`;
|
||||
debugLogger.warn(message);
|
||||
try {
|
||||
process.stderr.write(`Warning: ${message}\n`);
|
||||
} catch {
|
||||
// Stderr is best-effort; token persistence has already succeeded.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for managing MCP OAuth token storage and retrieval.
|
||||
*/
|
||||
|
|
@ -105,6 +123,7 @@ export class MCPOAuthTokenStorage implements TokenStorage {
|
|||
forceMode: true,
|
||||
noFollow: true,
|
||||
});
|
||||
warnPlaintextTokenStorage(tokenFile);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Failed to save MCP OAuth token: ${getErrorMessage(error)}`,
|
||||
|
|
@ -185,6 +204,7 @@ export class MCPOAuthTokenStorage implements TokenStorage {
|
|||
JSON.stringify(tokenArray, null, 2),
|
||||
{ mode: 0o600, forceMode: true, noFollow: true },
|
||||
);
|
||||
warnPlaintextTokenStorage(tokenFile);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
|
|
|
|||
|
|
@ -17,16 +17,20 @@ 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 { 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';
|
||||
import {
|
||||
addMCPStatusChangeListener,
|
||||
connectToMcpServer,
|
||||
createStreamableHttpCompatibilityFetch,
|
||||
createTransport,
|
||||
discoverPrompts,
|
||||
discoverResources,
|
||||
getAllMCPServerStatuses,
|
||||
getMcpOAuthDialogInstruction,
|
||||
getMCPServerStatus,
|
||||
hasNetworkTransport,
|
||||
isEnabled,
|
||||
|
|
@ -88,6 +92,583 @@ describe('mcp-client', () => {
|
|||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
describe('getMcpOAuthDialogInstruction', () => {
|
||||
it('builds an authenticate instruction for the named MCP server', () => {
|
||||
expect(getMcpOAuthDialogInstruction('authenticate', 'foo')).toBe(
|
||||
"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(
|
||||
"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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectToMcpServer', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(MCPOAuthProvider).mockReset();
|
||||
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,
|
||||
getCredentials,
|
||||
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')),
|
||||
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. " +
|
||||
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'),
|
||||
);
|
||||
});
|
||||
|
||||
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(),
|
||||
setRequestHandler: vi.fn(),
|
||||
notification: vi.fn(),
|
||||
} as unknown as ClientLib.Client);
|
||||
vi.mocked(MCPOAuthTokenStorage).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getCredentials,
|
||||
}) 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(
|
||||
"Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " +
|
||||
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'),
|
||||
);
|
||||
});
|
||||
|
||||
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()
|
||||
.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. " +
|
||||
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'),
|
||||
);
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
"Stored OAuth tokens for SSE server 'sse-server' are expired or could not be refreshed. " +
|
||||
getMcpOAuthDialogInstruction('re-authenticate', 'sse-server'),
|
||||
);
|
||||
});
|
||||
|
||||
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')),
|
||||
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',
|
||||
{ url: 'http://test-server/sse' },
|
||||
false,
|
||||
workspaceContext,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"401 error received for SSE server 'sse-server' without OAuth configuration. " +
|
||||
getMcpOAuthDialogInstruction('authenticate', 'sse-server'),
|
||||
);
|
||||
});
|
||||
|
||||
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(),
|
||||
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,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
"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('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<string, string> };
|
||||
};
|
||||
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('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')),
|
||||
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', () => {
|
||||
it('should discover tools', async () => {
|
||||
const mockedClient = {
|
||||
|
|
@ -1466,6 +2047,81 @@ 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(
|
||||
() =>
|
||||
({
|
||||
getValidToken,
|
||||
}) as unknown as MCPOAuthProvider,
|
||||
);
|
||||
|
||||
await expect(
|
||||
createTransport(
|
||||
'oauth-test-server',
|
||||
{
|
||||
httpUrl: 'http://test-server',
|
||||
oauth: {
|
||||
enabled: true,
|
||||
clientId: 'client-id',
|
||||
},
|
||||
},
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
getMcpOAuthDialogInstruction('authenticate', 'oauth-test-server'),
|
||||
);
|
||||
expect(getValidToken).toHaveBeenCalledWith('oauth-test-server', {
|
||||
enabled: true,
|
||||
clientId: 'client-id',
|
||||
});
|
||||
});
|
||||
|
||||
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. " +
|
||||
getMcpOAuthDialogInstruction(
|
||||
're-authenticate',
|
||||
'oauth-test-server',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('wires the compatibility fetch for OAuth httpUrl transports', async () => {
|
||||
const getValidToken = vi.fn().mockResolvedValue('oauth-token');
|
||||
vi.mocked(MCPOAuthProvider).mockImplementation(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -78,6 +79,44 @@ const debugLogger = createDebugLogger('MCP');
|
|||
const STREAMABLE_HTTP_GET_SSE_FALLBACK_STATUSES = new Set([400]);
|
||||
const STREAMABLE_HTTP_GET_SSE_ERROR_BODY_LIMIT = 512;
|
||||
|
||||
export function getMcpOAuthDialogInstruction(
|
||||
action: 'authenticate' | 're-authenticate',
|
||||
mcpServerName: string,
|
||||
): string {
|
||||
return [
|
||||
`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(' ');
|
||||
}
|
||||
|
||||
type SseOAuth401TokenState = 'accepted-token-rejected' | 'unusable' | 'missing';
|
||||
|
||||
function getSseOAuth401Message(
|
||||
mcpServerName: string,
|
||||
tokenState: SseOAuth401TokenState,
|
||||
): string {
|
||||
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(
|
||||
response: Response,
|
||||
): Promise<string | undefined> {
|
||||
|
|
@ -665,7 +704,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 server '${mcpServerName}'. ` +
|
||||
getMcpOAuthDialogInstruction('authenticate', mcpServerName),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1282,6 +1322,29 @@ 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;
|
||||
|
||||
if (
|
||||
mcpServerConfig.url &&
|
||||
!mcpServerConfig.httpUrl &&
|
||||
!mcpServerConfig.oauth?.enabled
|
||||
) {
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
try {
|
||||
hadStoredSseOAuthCredentials = Boolean(
|
||||
await tokenStorage.getCredentials(mcpServerName),
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to pre-read stored OAuth credentials for SSE server '${mcpServerName}': ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const transport = await createTransport(
|
||||
mcpServerName,
|
||||
|
|
@ -1313,34 +1376,38 @@ 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 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) {
|
||||
const authProvider = new MCPOAuthProvider(tokenStorage);
|
||||
const hasStoredTokens = await authProvider.getValidToken(
|
||||
mcpServerName,
|
||||
{
|
||||
try {
|
||||
tokenState = (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. ` +
|
||||
`Please re-authenticate using: /mcp auth ${mcpServerName}`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` +
|
||||
`Please authenticate using: /mcp auth ${mcpServerName}`,
|
||||
}))
|
||||
? 'accepted-token-rejected'
|
||||
: 'unusable';
|
||||
} catch (tokenError) {
|
||||
debugLogger.error(
|
||||
`Failed to validate stored OAuth token for SSE server '${mcpServerName}': ${getErrorMessage(tokenError)}`,
|
||||
);
|
||||
tokenState = 'unusable';
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` +
|
||||
`Please authenticate using: /mcp auth ${mcpServerName}`,
|
||||
);
|
||||
const oauthMessage = getSseOAuth401Message(mcpServerName, tokenState);
|
||||
debugLogger.warn(oauthMessage);
|
||||
throw new Error(oauthMessage);
|
||||
}
|
||||
|
||||
// Try to extract www-authenticate header from the error
|
||||
|
|
@ -1459,51 +1526,17 @@ 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
|
||||
// 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);
|
||||
if (credentials) {
|
||||
const authProvider = new MCPOAuthProvider(tokenStorage);
|
||||
const hasStoredTokens = 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. ` +
|
||||
`Please re-authenticate using: /mcp auth ${mcpServerName}`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` +
|
||||
`Please authenticate using: /mcp auth ${mcpServerName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`401 error received for SSE server '${mcpServerName}' without OAuth configuration. ` +
|
||||
`Please authenticate using: /mcp auth ${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}'...`,
|
||||
);
|
||||
|
|
@ -1514,118 +1547,124 @@ export async function connectToMcpServer(
|
|||
);
|
||||
const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`;
|
||||
|
||||
let oauthConfig: Awaited<
|
||||
ReturnType<typeof OAuthUtils.discoverOAuthConfig>
|
||||
>;
|
||||
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 || [],
|
||||
};
|
||||
if (oauthConfig) {
|
||||
debugLogger.info(
|
||||
`Discovered OAuth configuration from base URL for server '${mcpServerName}'`,
|
||||
);
|
||||
|
||||
// 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(
|
||||
// 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}'`,
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to get OAuth token 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 stored credentials for server '${mcpServerName}'`,
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to get stored credentials 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(
|
||||
`Could not configure OAuth for '${mcpServerName}' - please authenticate manually with /mcp auth ${mcpServerName}`,
|
||||
);
|
||||
throw new Error(
|
||||
`OAuth configuration failed for '${mcpServerName}'. Please authenticate manually with /mcp auth ${mcpServerName}`,
|
||||
);
|
||||
const oauthMessage =
|
||||
`Failed to get stored credentials for server '${mcpServerName}'. ` +
|
||||
getMcpOAuthDialogInstruction('authenticate', mcpServerName);
|
||||
debugLogger.error(oauthMessage);
|
||||
throw new Error(oauthMessage);
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
} else {
|
||||
debugLogger.error(
|
||||
`OAuth discovery failed for '${mcpServerName}' - please authenticate manually with /mcp auth ${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(
|
||||
`'${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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1742,12 +1781,12 @@ export async function createTransport(
|
|||
|
||||
if (!accessToken) {
|
||||
debugLogger.error(
|
||||
`MCP server '${mcpServerName}' requires OAuth authentication. ` +
|
||||
`Please authenticate using the /mcp auth command.`,
|
||||
`The MCP server '${mcpServerName}' requires OAuth authentication. ` +
|
||||
getMcpOAuthDialogInstruction('authenticate', mcpServerName),
|
||||
);
|
||||
throw new Error(
|
||||
`MCP server '${mcpServerName}' requires OAuth authentication. ` +
|
||||
`Please authenticate using the /mcp auth command.`,
|
||||
`The MCP server '${mcpServerName}' requires OAuth authentication. ` +
|
||||
getMcpOAuthDialogInstruction('authenticate', mcpServerName),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1766,6 +1805,11 @@ 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. ` +
|
||||
getMcpOAuthDialogInstruction('re-authenticate', mcpServerName),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -65,28 +65,28 @@ 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_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: `['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. Rule patterns such as `Bash(git *)`, `Read(.env)`, and `Edit(/src/**)` apply to `excludeTools` and `allowedTools`; `coreTools` accepts aliases but strips invocation specifiers.
|
||||
|
||||
### Timeouts
|
||||
|
||||
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();
|
||||
```
|
||||
|
|
@ -503,4 +508,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.
|
||||
|
|
|
|||
|
|
@ -367,15 +367,19 @@ 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.
|
||||
* 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']
|
||||
*/
|
||||
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 +388,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 +409,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 +427,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;
|
||||
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ describe('slashCompletionSource', () => {
|
|||
{
|
||||
name: 'mcp',
|
||||
description: 'Manage MCP servers',
|
||||
argumentHint: 'desc|nodesc|schema|auth|noauth',
|
||||
argumentHint: 'desc|nodesc|schema',
|
||||
},
|
||||
];
|
||||
const source = slashCompletionSource(() => commands);
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue