Compare commits

..
Author SHA1 Message Date
Saoud Rizwan fdcc5367dc chore(vscode): bump to 4.1.1 for release 2026-07-30 21:08:32 -07:00
Dominic CooneyandCline Agent 901fdbc5cb Remove vestigial MCP server-key machinery from McpHub (#12773)
The uid/mcpServerKeys registry existed to encode server names into
native tool-call function names and decode them back at dispatch.
That encode/decode path was removed with the extension host
(c4c126bee): tool names are now built by the SDK's deterministic
defaultMcpToolNameTransform and execution closes over the server
name directly, so getMcpServerByKey has no callers and the keys are
write-only state. Delete the registry, the uid field, and the
deleteServerKey callback plumbing.

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-30 21:05:58 -07:00
Saoud Rizwan 69e3149f5f ci(vscode): add tag, GitHub Release, and Slack bookkeeping to combined publish workflow 2026-07-30 20:59:08 -07:00
Saoud Rizwan 3a3d0c1bc3 chore(vscode): bump to 4.1.0 and backport legacy 4.0.x changelog to main 2026-07-30 20:59:08 -07:00
Tomás BarreiroandSaoud Rizwan 0746ea72bf Improve ACP agent errors (#12766)
* handle finish reasons

* describe agent error

* use SDK functions

* Add isLikelyAuthError to the check

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-30 19:12:38 -07:00
Sebastien Tardif f0d5ede555 fix: replace flaky setTimeout waits with drainForTesting in BannerService tests (#10530)
BannerService tests still used 10ms sleeps for background fetch completion.
On slow CI runners that races mocha timeouts. drainForTesting() already
exists and awaits the in-flight fetch promise deterministically.

Rebased onto monorepo main (apps/vscode path).

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-07-30 18:24:00 -07:00
Saoud Rizwan ed821a6456 chore(cli): release v3.0.48 2026-07-30 18:17:43 -07:00
16 changed files with 349 additions and 89 deletions
+5 -3
View File
@@ -107,15 +107,17 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
### Post-publish
1. Verify the marketplace serves the new version (query from rule 1).
2. This workflow does **not** tag or create a GitHub releasedo it manually:
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs.
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refsno grantable permission fixes it). Manual fallback:
```bash
git tag v<VERSION> <main-sha-built> # ask before pushing
git push origin v<VERSION>
gh release create v<VERSION> --title "v<VERSION>" --notes "<curated notes from CHANGELOG>"
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
```
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
+104
View File
@@ -126,6 +126,10 @@ jobs:
needs: [test-next, test-legacy]
runs-on: ubuntu-latest
environment: publish
# contents: write is required by the post-publish bookkeeping (tag +
# GitHub Release), mirroring the standalone publish workflows.
permissions:
contents: write
steps:
# Refuse to publish a next bundle the test-next gate did not cover.
# The reusable suite tests the dispatch revision (main), so publishing
@@ -148,6 +152,21 @@ jobs:
path: next-src
lfs: true
# Fail fast (before the ~20-min build) if a real publish is missing
# its changelog entry — same contract the standalone publish
# workflows enforce. Build-only rehearsals are exempt.
- name: Verify changelog entry
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: next-src
run: |
EXPECTED_HEADING="## [${{ github.event.inputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing (found '$FIRST_HEADING')."
exit 1
fi
echo "Found changelog entry for ${{ github.event.inputs.version }}"
# Pin to the revision test-legacy actually tested (see that job's
# outputs comment) — never re-resolve the mutable branch name here.
- name: Checkout legacy source
@@ -304,3 +323,88 @@ jobs:
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
# ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ----
# Mirrors the standalone publish workflows. Every step here is
# continue-on-error: the Marketplace publish above already happened,
# and a red run after a successful publish is exactly the confusion
# the nightly workflow taught us to avoid (tag pushes fail whenever
# the built commit touches .github/workflows/** — no grantable
# permission fixes that; push the tag manually in that case, see
# the publish-extension skill).
- name: Extract changelog entry
id: changelog
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
working-directory: next-src
run: |
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
{
echo "content<<CHANGELOG_EOF"
echo "$CONTENT"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve previous release tag
id: prev_tag
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
working-directory: next-src
run: |
# ls-remote needs no local tag objects; take the highest v* tag
# below the one being released.
PREV=$(git ls-remote --tags origin 'v*' \
| awk -F/ '{print $NF}' | grep -v '\^{}' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -vx "v${{ github.event.inputs.version }}" \
| sort -V | tail -1)
echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT"
- name: Create and push release tag
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
working-directory: next-src
run: |
TAG="v${{ github.event.inputs.version }}"
git tag "$TAG" HEAD
git push origin "refs/tags/$TAG"
echo "Pushed $TAG at $(git rev-parse HEAD)"
- name: Create GitHub Release
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ github.event.inputs.version }}
files: staging/claude-dev-${{ github.event.inputs.version }}.vsix
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
if: ${{ github.event.inputs.publish == 'true' }}
continue-on-error: true
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline v${{ github.event.inputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline v${{ github.event.inputs.version }}*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}"
+122
View File
@@ -1,5 +1,127 @@
# Changelog
## [4.1.1]
### Changed
- Remove vestigial MCP server-key machinery from McpHub — native MCP tool calls now route by server name instead of a random in-memory uid, so routing survives restarts and server list changes.
## [4.1.0]
### Changed
- Convert the stable extension to a combined A/B package: one VSIX containing both the current (legacy) extension and the new SDK-based extension, plus a loader that activates exactly one per window via a staged remote rollout. For nearly all users nothing changes — the loader activates the same extension as 4.0.12; a small percentage (starting at 1%) is gradually opted into the SDK-based extension. If the new extension fails to activate, the loader falls back to the current one in the same window. Settings and credentials are shared between the two.
## [4.0.12]
### Added
- Add support for free Cline models, shown as "(free)" in the model picker, with a dedicated error card that includes the reset time when the free limit is reached.
### Fixed
- Keep Claude Code responses that were already streamed when the CLI exits with a max-turns error, instead of discarding a valid response.
## [4.0.11]
### Added
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
- Add Moonshot Kimi K3 support.
- Include the host plugin version in telemetry events.
### Fixed
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
- Enable native tool calling for Kimi K3 models, fixing empty responses.
## [4.0.10]
### Added
- Add telemetry to track when Cline reaches the consecutive mistake limit.
## [4.0.9]
### Added
- Add GPT-5.6 ChatGPT subscription models.
### Changed
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
### Fixed
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
## [4.0.8]
### Added
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
## [4.0.7]
### Added
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
### Changed
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
- Remove the Cline model picker recommendation copy.
### Removed
- Remove all references to GLM 5.1.
## [4.0.6]
### Fixed
- Generalize the model capability warning so it applies more broadly.
## [4.0.5]
### Added
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
## [4.0.4]
### Changed
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### Fixed
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [4.0.0]
### Added
+17
View File
@@ -1,5 +1,22 @@
# Cline CLI Changelog
## 3.0.48
- `cline history` now opens inside the existing TUI, with resume and delete actions, instead of rendering a second view in the same process
- Connector threads (Slack, Discord, Telegram, Linear, Google Chat, WhatsApp) now recover when the session they were bound to is gone — the stale binding is dropped and the turn replays against a new session, instead of failing with "session not found" until `threads.json` is edited by hand
- `cline --help` now reports the real default `--config` and `--data-dir` paths
- The per-server `timeout` in `cline_mcp_settings.json` is now honored for `initialize`, `tools/list`, and `tools/call`, so slow MCP servers no longer fail against a hardcoded 5s limit (from SDK v0.0.67)
- Reasoning controls are now routed from the models.dev catalog across providers, with clamped budgets and correct per-provider encoding (from SDK v0.0.67)
- OpenRouter now defaults to `anthropic/claude-sonnet-5` (from SDK v0.0.67)
- Fixed the China and international endpoint toggles being ignored for Qwen, Moonshot, and Z AI (from SDK v0.0.67)
- Legacy API keys are now migrated for every secret-backed provider (from SDK v0.0.67)
- Legacy OpenAI Compatible model-info overrides now survive into the seeded `models.json` (from SDK v0.0.67)
- Fixed auto-compaction state being rejected as stale, which added a redundant summarizer call on every turn past the compaction trigger (from SDK v0.0.67)
- Fixed checkpoint restores across session resumes (from SDK v0.0.67)
- Tool calls that pass line numbers as strings (`insert_line`, `read_files` bounds) are now accepted instead of erroring (from SDK v0.0.67)
- A legacy single-file `.clinerules` no longer aborts the config scan (from SDK v0.0.67)
- Plugins can now emit telemetry through `ctx.telemetry` (from SDK v0.0.67)
## 3.0.47
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.47",
"version": "3.0.48",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+57 -1
View File
@@ -28,11 +28,15 @@ import {
ProviderSettingsManager,
SessionSource,
} from "@cline/core";
import type { Message } from "@cline/shared";
import { isLikelyAuthError, type Message } from "@cline/shared";
import { getPersistedProviderApiKey } from "../commands/auth";
import { resolveSystemPrompt } from "../runtime/prompt";
import { subscribeToAgentEvents } from "../runtime/session-events";
import { createCliCore } from "../session/session";
import {
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../utils/cline-pass-errors";
import { getCliBuildInfo } from "../utils/common";
import { randomSessionId, resolveWorkspaceRoot } from "../utils/helpers";
import type { Config } from "../utils/types";
@@ -45,6 +49,7 @@ import {
} from "./auth";
import { requestAcpToolApproval } from "./permissions";
import {
describeAgentError,
forwardAgentEvent,
sendConfigOptionUpdate,
sendCurrentModeUpdate,
@@ -69,6 +74,15 @@ interface SessionState {
abortController?: AbortController;
/** Unsubscribe function for the agent event listener. */
unsubscribe?: () => void;
/**
* Most recent unrecoverable agent error for the in-flight turn.
*
* The runtime reports fatal failures (bad credentials, subscription
* restrictions, provider outages) as an `error` event and still resolves
* `send()` normally, so the message has to be stashed here for `prompt()` to
* turn into an error response.
*/
fatalError?: Error;
/** Messages to inject into the next session manager for conversation continuity. */
pendingInitialMessages?: Message[];
}
@@ -193,6 +207,7 @@ export class AcpAgent implements Agent {
const abortController = new AbortController();
session.abortController = abortController;
session.fatalError = undefined;
// If cancel() was already called before prompt() started, bail early.
if (abortController.signal.aborted) {
@@ -242,6 +257,17 @@ export class AcpAgent implements Agent {
updatedAt: new Date().toISOString(),
});
// A cancelled turn always reports `cancelled`: the ACP spec
// requires agents to convert abort failures into the cancelled stop reason
// so clients don't show cancellations as errors.
if (stopReason !== "cancelled") {
const fatalError = session.fatalError;
session.fatalError = undefined;
if (fatalError) {
throw toAcpPromptError(fatalError);
}
}
return { stopReason };
}
@@ -491,6 +517,13 @@ export class AcpAgent implements Agent {
session.unsubscribe = subscribeToAgentEvents(
sessionManager,
(event: AgentEvent) => {
// Remember unrecoverable failures so prompt() can fail the turn.
if (event.type === "error" && !event.recoverable) {
session.fatalError =
event.error instanceof Error
? event.error
: new Error(describeAgentError(event.error));
}
forwardAgentEvent(this.conn, acpSessionId, event);
},
);
@@ -560,6 +593,29 @@ export class AcpAgent implements Agent {
}
}
/**
* Convert a fatal agent error into a JSON-RPC error for the prompt response.
*
* Credential/subscription problems map to `auth_required` (-32000) so clients
* can offer a re-auth affordance rather than just printing text; everything
* else is an internal error.
*
* Classification goes through the shared CLI helpers, which check the error's
* type *and* its name/message. That matters because the runtime re-wraps errors
* as it forwards them across the event boundary, so `instanceof` alone fails on
* the object ACP actually receives.
*/
function toAcpPromptError(error: Error): RequestError {
const message = describeAgentError(error);
const isAuthProblem =
isLikelyAuthError(error) ||
isClinePassSubscriptionError(error) ||
isClineOrgIndividualInferenceSubscriptionErrorMessage(error);
return isAuthProblem
? RequestError.authRequired({ message }, message)
: RequestError.internalError({ message }, message);
}
async function buildProviderConfigOption(
currentProviderId: string,
): Promise<SessionConfigOption> {
+6
View File
@@ -4,6 +4,7 @@ import type {
SessionUpdate,
} from "@agentclientprotocol/sdk";
import type { AgentEvent } from "@cline/core";
import { getErrorMessage } from "@cline/shared";
import { buildToolTitle, mapToolKind } from "./tool-utils";
/**
@@ -81,6 +82,11 @@ function translateContentStart(
}
}
export function describeAgentError(error: unknown): string {
const message = getErrorMessage(error).trim();
return message || "The agent reported an unknown error.";
}
function translateContentEnd(
event: AgentEvent & { type: "content_end" },
): SessionUpdate[] {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.0.0",
"version": "4.1.1",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.101.0"
@@ -162,7 +162,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners() // Triggers background fetch
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(mockFetch.calledOnce).to.be.true
const banners = bannerService.getActiveBanners() // Get banners after fetch completes
@@ -181,7 +181,7 @@ describe("BannerService", () => {
const banners = bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(banners).to.have.lengthOf(0)
})
@@ -318,7 +318,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
@@ -352,7 +352,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
@@ -386,7 +386,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
@@ -419,7 +419,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
@@ -453,7 +453,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
@@ -485,7 +485,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
@@ -515,7 +515,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
@@ -548,14 +548,14 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(mockFetch.calledOnce).to.be.true
bannerService.clearCache()
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(mockFetch.calledTwice).to.be.true
})
})
@@ -585,7 +585,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(mockFetch.calledOnce).to.be.true
const call = mockFetch.getCall(0)
@@ -624,7 +624,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
@@ -665,7 +665,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
@@ -713,7 +713,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(2)
@@ -745,7 +745,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
@@ -778,7 +778,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(1)
@@ -811,7 +811,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(banners).to.have.lengthOf(0)
@@ -854,7 +854,7 @@ describe("BannerService", () => {
bannerService.getActiveBanners()
// Wait for background fetch to complete
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
const banners = bannerService.getActiveBanners()
expect(mockedPostStateToWebview.called).to.be.true
@@ -892,7 +892,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("vscode")
})
@@ -905,7 +905,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("vscode")
})
@@ -918,7 +918,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
})
@@ -931,7 +931,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
})
@@ -944,7 +944,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("cli")
})
@@ -957,7 +957,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("vscode")
})
@@ -970,7 +970,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("vscode")
})
@@ -983,7 +983,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("unknown")
})
@@ -996,7 +996,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("unknown")
})
@@ -1009,7 +1009,7 @@ describe("BannerService", () => {
await mockFetchForTesting(mockFetch, async () => {
const bannerService = BannerService.initialize(mockController)
bannerService.getActiveBanners()
await new Promise((resolve) => setTimeout(resolve, 10))
await bannerService.drainForTesting()
expect(await getIdeParam(mockFetch)).to.equal("jetbrains")
})
-40
View File
@@ -28,7 +28,6 @@ import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mc
import chokidar, { type FSWatcher } from "chokidar"
import deepEqual from "fast-deep-equal"
import * as fs from "fs/promises"
import { nanoid } from "nanoid"
import ReconnectingEventSource from "reconnecting-eventsource"
import { z } from "zod"
import { HostProvider } from "@/hosts/host-provider"
@@ -94,11 +93,6 @@ export class McpHub {
*/
private lastConnectionFingerprint?: string
/**
* Map of unique keys to each connected server names
*/
private static mcpServerKeys = new Map<string, string>()
// Store notifications for display in chat
private pendingNotifications: Array<{
serverName: string
@@ -143,33 +137,6 @@ export class McpHub {
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
}
/**
* Get the MCP server name from its unique key.
* If the key is not found, return the key itself.
*/
public static getMcpServerByKey(key: string): string {
return McpHub.mcpServerKeys.get(key) || key
}
/**
* Create a unique key for an MCP server based on its name.
* This avoids making a tool name too long while still ensuring uniqueness.
*/
private getMcpServerKey(server: string): string {
// Reuse existing key if server is already registered
for (const [existingKey, existingServer] of McpHub.mcpServerKeys.entries()) {
if (existingServer === server) {
return existingKey
}
}
// Generate a short 6-character unique ID for the server
// Add c prefix to ensure it starts with a letter (for compatibility with Gemini)
// Only use the first 5 characters of nanoid to keep it short
const uid = "c" + nanoid(5)
McpHub.mcpServerKeys.set(uid, server)
return uid
}
/**
* Gets the path to the MCP settings file
* @returns Path to the MCP settings file
@@ -497,7 +464,6 @@ export class McpHub {
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
McpHub.mcpServerKeys.delete(connection.server.uid || name)
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
@@ -507,7 +473,6 @@ export class McpHub {
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
McpHub.mcpServerKeys.delete(connection.server.uid || name)
}
await this.notifyWebviewOfServerChanges()
}
@@ -579,7 +544,6 @@ export class McpHub {
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
McpHub.mcpServerKeys.delete(connection.server.uid || name)
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
@@ -622,7 +586,6 @@ export class McpHub {
connectToServer: () => this.connectToServer(name, config, source),
notifyWebviewOfServerChanges: () => this.notifyWebviewOfServerChanges(),
appendErrorMessage: (conn, msg) => this.appendErrorMessage(conn as McpConnection, msg),
deleteServerKey: (uid) => McpHub.mcpServerKeys.delete(uid),
delay: (ms) => setTimeoutPromise(ms),
})
@@ -639,7 +602,6 @@ export class McpHub {
config: configForStorage,
status: "connecting",
disabled: config.disabled,
uid: this.getMcpServerKey(name),
oauthRequired: false,
oauthAuthStatus: "authenticated",
},
@@ -666,7 +628,6 @@ export class McpHub {
oauthRequired: true,
oauthAuthStatus: "unauthenticated",
error: "This MCP server requires authentication to get started.",
uid: this.getMcpServerKey(name),
},
client,
transport,
@@ -771,7 +732,6 @@ export class McpHub {
config: JSON.stringify(config),
status: "disconnected",
disabled: config.disabled,
uid: this.getMcpServerKey(name),
},
client: null as unknown as Client,
transport: null as unknown as Transport,
@@ -6,7 +6,7 @@ import { Logger } from "@/shared/services/Logger"
*/
export interface ReconnectCallbacks {
/** Returns the current connection object, or undefined if it no longer exists */
findConnection: () => { server: { status: string; disabled?: boolean; uid?: string } } | undefined
findConnection: () => { server: { status: string; disabled?: boolean } } | undefined
/** Tears down the existing connection */
deleteConnection: () => Promise<void>
/** Establishes a new connection */
@@ -15,8 +15,6 @@ export interface ReconnectCallbacks {
notifyWebviewOfServerChanges: () => Promise<void>
/** Appends an error message to the connection's server object */
appendErrorMessage: (connection: { server: { status: string } }, message: string) => void
/** Removes the server key from the global registry */
deleteServerKey: (uid: string) => void
/** Awaitable delay — injected so tests can substitute a zero-delay or fake timer */
delay: (ms: number) => Promise<void>
}
@@ -94,7 +92,6 @@ export class StreamableHttpReconnectHandler {
`exhausted for "${this.serverName}". Server marked as disconnected.`,
)
connection.server.status = "disconnected"
this.callbacks.deleteServerKey(connection.server.uid || this.serverName)
this.callbacks.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
await this.callbacks.notifyWebviewOfServerChanges()
return
@@ -157,7 +154,6 @@ export class StreamableHttpReconnectHandler {
const exhaustedConnection = this.callbacks.findConnection()
if (exhaustedConnection) {
exhaustedConnection.server.status = "disconnected"
this.callbacks.deleteServerKey(exhaustedConnection.server.uid || this.serverName)
this.callbacks.appendErrorMessage(exhaustedConnection, error instanceof Error ? error.message : `${error}`)
}
await this.callbacks.notifyWebviewOfServerChanges()
@@ -9,12 +9,11 @@ import {
} from "../StreamableHttpReconnectHandler"
/** Build a mock connection object whose status can be inspected. */
function makeConnection(overrides: Partial<{ status: string; disabled: boolean; uid: string }> = {}) {
function makeConnection(overrides: Partial<{ status: string; disabled: boolean }> = {}) {
return {
server: {
status: overrides.status ?? "connected",
disabled: overrides.disabled ?? false,
uid: overrides.uid ?? "uid-123",
},
}
}
@@ -31,7 +30,6 @@ function makeCallbacks(connection?: ReturnType<typeof makeConnection>): Reconnec
connectToServer: sinon.stub().resolves(),
notifyWebviewOfServerChanges: sinon.stub().resolves(),
appendErrorMessage: sinon.stub(),
deleteServerKey: sinon.stub(),
delay: sinon.stub().resolves(), // instant — no real waiting in tests
}
return { ...(stubs as unknown as ReconnectCallbacks), stubs }
@@ -148,7 +146,7 @@ describe("StreamableHttpReconnectHandler", () => {
// After deleteConnection, findConnection returns undefined (old conn deleted)
// but connectToServer may leave a partial connection, so simulate that
const partialConn = makeConnection({ uid: "uid-partial" })
const partialConn = makeConnection()
let deleted = false
cbs.stubs.findConnection.callsFake(() => {
if (!deleted) return conn
@@ -170,7 +168,6 @@ describe("StreamableHttpReconnectHandler", () => {
// The partial connection should be marked disconnected
partialConn.server.status.should.equal("disconnected")
cbs.stubs.deleteServerKey.calledWith("uid-partial").should.be.true()
cbs.stubs.appendErrorMessage.calledOnce.should.be.true()
cbs.stubs.appendErrorMessage.firstCall.args[1].should.equal("transport error")
})
@@ -207,7 +204,6 @@ describe("StreamableHttpReconnectHandler", () => {
await handler.handleError(new Error("final error"))
conn.server.status.should.equal("disconnected")
cbs.stubs.deleteServerKey.calledWith("uid-123").should.be.true()
cbs.stubs.appendErrorMessage.calledOnce.should.be.true()
cbs.stubs.connectToServer.called.should.be.false()
})
@@ -240,7 +236,7 @@ describe("StreamableHttpReconnectHandler", () => {
it("should abort reconnect if connection was replaced during delay", async () => {
const conn = makeConnection()
const differentConn = makeConnection({ uid: "uid-replaced" })
const differentConn = makeConnection()
const cbs = makeCallbacks(conn)
// After the delay, findConnection returns a different object
cbs.stubs.findConnection.onFirstCall().returns(conn)
-1
View File
@@ -24,7 +24,6 @@ export type McpServer = {
prompts?: McpPrompt[]
disabled?: boolean
timeout?: number
uid?: string
oauthRequired?: boolean
oauthAuthStatus?: McpOAuthAuthStatus
}
+1 -1
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.47",
"version": "3.0.48",
"bin": {
"cline": "src/index.ts",
},
+1
View File
@@ -222,6 +222,7 @@ export {
noopBasicLogger,
} from "./logging/logger";
export * from "./mcp";
export { getErrorCode, getErrorMessage } from "./parse/error";
export {
normalizeJsonLikeStringsForSchema,
parseJsonStream,
+1
View File
@@ -238,6 +238,7 @@ export {
noopBasicLogger,
} from "./logging/logger";
export * from "./mcp";
export { getErrorCode, getErrorMessage } from "./parse/error";
export {
normalizeJsonLikeStringsForSchema,
parseJsonStream,