Skip to Content
DesignF2: Gemeinsamer MCP-Transport-Pool – Design v2.2

F2: Gemeinsamer MCP-Transport-Pool – Design v2.2

Zielt auf daemon_mode_b_main (gemäß #4175 Branching-Strategie). Ersetzt #4175 Wave 5 PR 23. Single-PR-Lieferung gemäß der Anleitung des Maintainers zur funktionskohärenten Batch-Bereitstellung (2026-05-19). Autor: doudouOUC. Datum: 2026-05-20. Überarbeitet: 2026-05-20 (v2.2 – Überarbeitungsfaltungen aus der Implementierungsprüfung).


0. Änderungsprotokoll

v2.2 (2026-05-20) – PR #4336 Implementierung + 32 Überprüfungs-Faltungen

PR #4336 lieferte F2 in 6 atomaren Commits + 6 Fix-Commits über ~4 Stunden. Wenshao überprüfte kumulativ in 3 Batches; jeder Batch produzierte Inline- und kritische Korrekturen, die zurückgefaltet wurden. Die folgende Tabelle dokumentiert die Änderungen gegenüber v2.1, geordnet nach Überprüfungsbatch.

v2.1 → erster Überprüfungsbatch (Commits 1-4, wenshao C1-C7 + S1-S4)

#StelleWas falsch warFold-in-Commit
C1acpAgent.ts:269 – IDE-close-PfadPool-Drain lief nur im SIGTERM-Handler; normaler IDE-Schließvorgang ließ Einträge auslaufen, bis das Betriebssystem sie bereinigte. Spiegele den Pool-Drain von SIGTERM bei await connection.closedae0b296c4
C2mcp-pool-entry.ts:cancelDrainTimercancelDrainTimer setzte bei jedem Flap den maxIdleTimer zurück, was die harte Obergrenze aus §6.3 außer Kraft setzte. Löscht jetzt nur drainTimer; max-idle überlebt die gesamte Entry-Lebensdauerae0b296c4
C3mcp-pool-entry.ts:doRestartWiederherstellfehler hinterließ Entry im Zombie-Zustand (localStatus=CONNECTED, state='active', veralteter Snapshot). Try/Catch + Übergang zu 'failed' bei Fehlerae0b296c4
C4mcp-pool-entry.ts:forceShutdownstate='closed' wurde NACH awaits gesetzt, sodass ein gleichzeitiges acquire den Zustand 'active' sehen und eine veraltete Verbindung ausgeben konnte. Wird jetzt synchron ganz oben gesetztae0b296c4
C5mcp-transport-pool.ts:drainAllGleichzeitiges acquire konnte mitten im Drain eine neue Entry erzeugen. draining-Mutex-Flag + await Promise.allSettled(spawnInFlight) vor dem Löschen hinzugefügtae0b296c4
C6mcp-pool-entry.ts:statusChangeListenerListener wurde nicht nach serverName gefiltert; jede Entry erhielt jede Statusbenachrichtigung jedes Servers + das eigene markActive-Write wurde zurückgespiegeltae0b296c4
C7mcp-client-manager.ts:discoverAllMcpToolsIncrementalPool-Mode-Gate wurde zu discoverAllMcpTools hinzugefügt, fehlte aber bei Incremental/mcp refresh umging den Pool und erzeugte einen pro-Sitzung-Clientae0b296c4
S1session-mcp-view.ts:passesSessionFilterDokumentation wies nicht darauf hin, dass excludeTools direkte Gleichheit verwendet (keine Klammer-Form-Unterstützung); Abweichung von mcp-client.ts:isEnabledae0b296c4
S2pid-descendants.ts DocstringBehauptete einen Windows-spezifischen taskkill /F-Zweig, der nicht existierte – Node polyfillt process.kill('SIGTERM') zu TerminateProcessae0b296c4
S3session-mcp-view.ts:applyTools Debug-LogZeichenkette enthielt buchstäblich "N" anstelle einer Interpolation – Betreiber sahen applied 12 tools (filtered to N registered)ae0b296c4
S4mcp-transport-pool.ts:createUnpooledConnection Status-CBHartcodiert auf () => CONNECTED, sodass aggregateStatusByName nach einer Trennung log. Jetzt () => client.getStatus()ae0b296c4

Commit-5 Selbstüberprüfungsbatch (R1-R3 klein)

#StelleWas falsch warFold-in-Commit
R1server.test.ts:918 /capabilities-HülleTest behauptete getAdvertisedServeFeatures() (keine Toggles), aber server.ts übergibt mcpPoolActive: opts.mcpPoolActive !== false (standardmäßig aktiv). Toggle verankern3e68c00bc
R2server.test.ts Coverage für standardmäßige AktivierungKein Test startete mit Standardoptionen, um zu überprüfen, ob Pool-Tags angezeigt werden. Expliziten Test mit mcpPoolActive: false hinzugefügt3e68c00bc
R3events.ts:DaemonMcpServerRestartRefusedDataDokumentation sagte, Pre-PR-SDKs würden “neuen Wert als unbekannt sehen und generisch anzeigen” – tatsächlich lehnt MCP_RESTART_REFUSED_REASONS.has(...) ab → stiller Drop3e68c00bc

Zweiter Überprüfungsbatch (Commits 1-5, wenshao R1-R10)

#StelleWas falsch warFold-in-Commit
WR1mcp-pool-entry.ts:maxIdleTimerC2-Fix bewahrte maxIdleTimer korrekt über den Flap, aber die Feueraktion schloss unabhängig von refs.size. Aktive Sitzung mit erneutem Anhängen innerhalb der Gnadenfrist verlor Tools nach 5min72399f109
WR2mcp-client-manager.ts:discoverAllMcpToolsViaPoolreleaseAllPooledConnections + erneutes ERWERBEN ALLER bei jedem Durchlauf hinterließ kurzzeitiges Fenster ohne registrierte MCP-Tools UND setzte jeden Drain-Timer zurück. Abweichung vom gewünschten (name, fingerprint)72399f109
WR3mcp-pool-entry.ts:doRestart Snapshot-FanoutNeustart aktualisierte toolsSnapshot/promptsSnapshot und emittierte typisierte Events – aber keine SessionMcpView-Instanz abonnierte diesen Stream. Iteriere subscribers direkt nach Snapshot72399f109
WR4mcp-transport-pool.ts:getSnapshot subprocessCountZählte WebSocket zu subprocessCount – WebSocket verbindet sich entfernt, kein lokaler Kindprozess. Auf 'stdio' beschränkt72399f109
WR5pid-descendants.ts PowerShell -FilterInterpolierte ${pid} direkt in den -Filter-String. Einstiegspunkt Number.isInteger Guard verhindert heute eine Einschleusung; zur Verteidigung gegen zukünftige Lockerungen an $p binden72399f109
WR6mcp-pool-entry.ts ctor cfg-Feldreadonly cfg: MCPServerConfig war implizit öffentlich, gab API-Schlüssel/Header-Auth/OAuth-Felder preis. Auf private gesetzt; neuer Getter transportKind für den einzigen externen Leser72399f109
WR7mcp-pool-events.ts verfrühte Exporte5 PoolEvent-Typwächter + Prompt-Reexport + PoolEntryConnectionStatus hatten null Aufrufer. Entfernt; MCPCallInterruptedError (Design §13.4 Vorgabe) beibehalten72399f109
WR8acpAgent.ts:269,300 Pool-Drain-DuplizierungSIGTERM + IDE-close hatten identische if (agentInstance) { try { await shutdownMcpPool(8_000) } catch... }-Blöcke. drainPoolBeforeExit(label)-Helfer extrahiert72399f109

Commit-6 Selbstüberprüfungsbatch (R1-R3 kritischer Wettlauf)

#StelleWas falsch warFold-in-Commit
6R1mcp-transport-pool.ts:onClosedSlot-Freigabe-Wettlauf: A beendet Spawn, B (anderer Fingerabdruck, gleicher Name) startet Spawn, A drainiert. Close-CB prüfte nur entries (B noch nicht registriert) → verfrühte Freigabe0e58a098f
6R2events.ts:mcpBudgetWarningCount JSDocWorkspace-bezogene Events fächern zu N Sitzungen auf → N Reducer-Inkremente; Verbraucher, die über Sitzungen hinweg aggregieren, zählen doppelt. Docstring aktualisiert, um den Multiplikator zu erwähnen0e58a098f
6R3acpAgent.ts:broadcastBudgetEventIterierte this.sessions.keys() direkt während des asynchronen Fanouts; gleichzeitiges killSession konnte den Iterator beschädigen. Snapshot über Array.from(...)0e58a098f

Dritter Überprüfungsbatch (Commits 1-6, wenshao W1-W15)

#StelleWas falsch warFold-in-Commit
W1mcp-transport-pool.ts:spawnEntry catchSpawn-Fehler ließ statusChangeListener dauerhaft hängen – nur forceShutdown entfernt ihn. entry.forceShutdown('manual') im catch hinzugefügt4a3c5cd90
W2mcp-pool-entry.ts:statusChangeListener QuerprüfungModulweite serverStatuses-Map wird von Einträgen mit mehreren Fingerabdrücken geteilt. A’s Transportfehler schrieb DISCONNECTED, B’s Listener beschädigte B’s localStatus. client.getStatus()-Prüfung hinzugefügt4a3c5cd90
W3mcp-pool-entry.ts:doRestart PID-SweepNeustart übersprang listDescendantPids + sigtermPids – jeder Neustart eines mit npx/uvx umschlossenen stdio hinterließ den tatsächlichen MCP-Enkel als Waise. Sweep vor Trennung hinzugefügt4a3c5cd90
W4mcp-pool-entry.ts:doRestart Drain-Timer-WettlaufDrain-Timer konnte mitten im Neustart-Yield auslösen → forceShutdown entfernt Entry → client.connect erzeugt Waise. cancelDrainTimer + state→active am Anfang von doRestart hinzugefügt4a3c5cd90
W5mcp-client-manager.ts:pooledConnections tote HandlesWenn Entry zu 'failed' wechselte, behielt Manager tote PooledConnection für immer. Subscribe auf Entry-Events; Räumung bei 'failed' (idempotent über get(name) === conn-Guard)4a3c5cd90
W6mcp-client-manager.ts:discoverAllMcpToolsViaPool WiedereintrittZwei Durchläufe, die sich überlappen, konnten beide set(name, conn) aufrufen → erste Verbindung ausgelaufen. discoveryInFlight-Mutex hinzugefügt; zweiter Aufrufer wartet auf dasselbe Promise. Neuer Regressionstest4a3c5cd90
W9acpAgent.ts:parsePoolDrainMs StrengeNumber.parseInt akzeptierte '30000ms' / '30000abc'. Strenger ^\d+$-Regex; Zurückweisung mit stderr-Warnung + Standard-Fallback4a3c5cd90
W10mcp-transport-pool.ts:acquire indexAttach-ReihenfolgeindexAttach mutierte sessionToEntries VOR entry.attach(). Wenn attach einen Fehler warf, veraltete Rückwärtsindexierung. indexAttach nach erfolgreichem attach verschoben (sowohl schneller als auch in-Flight-Pfad)4a3c5cd90
W13mcp-transport-pool.ts:subprocessCount JSDocDoc behauptete nach WR4 immer noch stdio + websocket, obwohl auf stdio beschränkt. Aktualisiert4a3c5cd90
W14mcp-transport-pool.ts:createUnpooledConnection catchGleicher statusChangeListener-Leck wie W1 im ungepoolten Pfad. Gleicher Spiegel: forceShutdown vor Trennung4a3c5cd90
W15bridge.ts:restartMcpServer Antwortas PoolEntries-Cast war unsicher – untypisiertes JSON vom ACP-Kind. Array.isArray-Prüfung + Formwächter pro Entry; fehlerhafte Einträge mit stderr-Brotkrümel übersprungen4a3c5cd90

Abgelehnt mit Antwort (als F2-Folgeaufgaben eingereicht)

#StelleGrund für Ablehnung
W7Lücken in der Testabdeckung (4 ungetestete kritische Pfade)1/4 hinzugefügt (W6-Regressionstest); Rest auf fokussierten Testabdeckungs-PR nach Zusammenführung der F2-Serie verschoben
W8maxReconnectAttempts / reconnectStrategy ungenutztVorwärtskompatibilitäts-Platzhalter für den verschobenen Health-Monitor-gesteuerten Wiederherstellungsvorgang (Design §6.6); Entfernen + erneutes Hinzufügen verursacht öffentlichen Typ-Churn
W11Doppelte schnelle Pfad-/in-Flight-Pfad-Anhänge-Blöcke✅ In PR A erledigt: attachPooledSession + rollbackReservationOnSpawnFailure private Helfer (Commit 2d546efca)
W12passesSessionFilter O(M×N) pro applyTools✅ In PR A erledigt: applyTools / applyPrompts berechnen Filter-Sets einmal pro Durchlauf vor; Prädikat wird O(1) pro Tool (Commit a4a855ab3)
R9McpClientManager ctor 7-Positions-Wächter✅ In PR A erledigt: Options-Objekt ctor + mkManager-Testfabrik (Commit 0cb1eaa27)
R10pgrep -P <pid> pro-PID-pro-Ebene-Kosten✅ In PR A erledigt: einzelner ps -A -o pid=,ppid= Snapshot + In-Memory-BFS-Durchlauf; pgrep BFS als Fallback für BusyBox <v1.28 / Distroless beibehalten (Commit landet als letztes PR-A-Stück)

Fehleranzahl

  • 3 Batches × 27 kritische / wichtige Korrekturen + 5 Dokumentations-/Vorschlagsfaltungen = 32 Überprüfungs-Faltungen insgesamt
  • 2 kritische Wettläufe nur beim zweiten Hinsehen entdeckt (6R1 Slot-Freigabe-während-Spawn-Wettlauf; W6 Discovery-Wiedereintritt)
  • 0 stille Fehler ausgeliefert — jede Korrektur trägt einen Inline-Krümel // F2 (#4175 Commit X Überprüfung Fix — wenshao YN):, der auf die ursprüngliche Überprüfung verweist

v2.1 (2026-05-20) – Single-PR-Strategie + 12 Überprüfungs-Faltungen

#WasWarum
V21-1Wechsel von 6-Sub-PR-Plan zu einem funktionskohärenten PR mit 6 atomaren CommitsGemäß Maintainer-Anleitung (#4175 Branching-Strategie); Prüfer kann Commit-für-Commit via git log -p lesen
V21-2sessionToEntries: Map<sid, Set<ConnectionId>> Reverse-Index im Pool hinzugefügt (§6)releaseSession O(N Einträge) → O(Refs der Sitzung); für 1000-Sitzungen-Skalierung benötigt
V21-3?fingerprint=-Abfrageparameter auf Neustart-Route (§13.1)Betreiber möchten möglicherweise nur einen Eintrag neu starten, wenn derselbe Name mehrere Fingerabdrücke hat; minimale Kosten jetzt hinzufügen
V21-4Fehlerpfad beim Spawn gibt reservierten Slot explizit frei (§6.1, §6.5)Andernfalls Slot-Leck bis zum nächsten Health-Monitor-Durchlauf; subtiler echter Fehler
V21-5Neuer §13.4: In-Flight-Tool-Aufruf während WiederherstellungssemantikMCPCallInterruptedError; Pool führt KEINE automatische Wiederholung aus (Schreibvorgänge unsicher)
V21-6Neuer §10.4: /mcp disable X löst SessionMcpView-Neuanwendung ausAndernfalls entfernt das Deaktivieren während der Sitzung nicht bereits registrierte Tools
V21-7Status-Route gibt entryIndex und nicht rohen Fingerabdruck preis (§8.3)Vermeidet Seitenkanal-Offenlegung der OAuth-Token-Rotation via Fingerabdruckänderung
V21-8Wiederherstellungs-Backoff spezifiziert: stdio fest 5s × 3, HTTP/SSE exponentiell 1/2/4/8/16s × 5 (§6.6)v2 sagte nichts; HTTP benötigt längeres Wiederholungsbudget für Netzwerk-Flaps
V21-9canonicalOAuth(o) normalisiert {enabled: false}undefinednull (§5.1)Andernfalls erzeugen funktional äquivalente Konfigurationen unterschiedliche Einträge
V21-10Pool-Fallback-Helfer von “Legacy-In-Prozess-Acquire” in createUnpooledConnection umbenannt (§5.3, §6.1)SDK-MCP-Umgehung ist dauerhaft, nicht Legacy
V21-11drainAll(opts?) gibt Promise<void> zurück mit timeoutMs Wanduhr-Budget (§17)Aufrufer muss wissen, wann Drain für die Abschaltreihenfolge beendet ist
V21-12SDK-Reducer-Feldnamen gesperrt (Q1 gelöst): mcpBudgetWarningCount usw. mit Geltungsbereich-Semantik in JSDoc beibehaltenKeine öffentliche API-Umbenennung mitten im PR
V21-13Q3 (Standard-Pool-aktiv, --no-mcp-pool-Kill-Switch), Q4 (HTTP/SSE Opt-in), Q6 (eifrige Konstruktion) gesperrtSingle-PR-Lieferung; keine Flag-Gating erforderlich
V21-14R9/R10/R11 Single-PR-Risiken hinzugefügt (§23)Überprüfungsermüdung, daemon_mode_b_main Merge-Konflikt, CI-Zeit
V21-15Verwaiste Einträge bei Extension-Deinstallation auf natürliches Bereinigen durch MAX_IDLE_MS verschoben (§16.3)Kein explizites invalidateByExtension; hält Modell einheitlich

v2 (2026-05-20) – erste Überprüfungs-Faltungen aus v1-Skizze

#WasWarum
C1Pool fächert Tools + Prompts auf (bisher nur Tools)McpClient-ctor nimmt beide Registrierungen; Prompts gehen sonst im Pool-Modus still verloren
C2Neuer Abschnitt zur Koexistenz globaler Zustände (serverStatuses / mcpServerRequiresOAuth Modul-Maps)Sitzungsübergreifende Teilung existiert bereits heute; Pool erbt + formalisiert
C3connectToMcpServer-Werkspfad vereinheitlicht mit der McpClient-Klasse in F2-1v1 refaktorierte nur die Klasse; würde einen parallelen, nicht gepoolten Pfad hinterlassen
C4Snapshot-Wiedergabe beim Anhängen (EarlyEvents-ähnlich) zu PoolEntry.attach() hinzugefügtNeuer Wettlauf: Sitzung-B hängt an → Server emittiert tools/list_changed bevor Subscription verdrahtet
C5spawnInFlight: Map<ConnectionId, Promise<PoolEntry>> für gleichzeitiges Acquire-Deduplizierungv1 in Testmatrix erwähnt, aber im Implementierungsvertrag vergessen
C6Plattformübergreifender Descendant-PID-Sweep (Linux/macOS pgrep, Windows wmic/PowerShell)v1 sagte “openodes pgrep -P kopieren” – das ist nur Unix
C7trust-Feld pro Sitzung als Kopie des Tool-Objektstrust lebt auf DiscoveredMCPTool; gemeinsam genutzte Instanz würde pro-Sitzung-Trust vermischen
C8HTTP/SSE-Transporte Opt-in zum Pooling (Standard: nur stdio + websocket)Einige MCP-HTTP-Server verwalten pro-Transport-Sitzungszustand; Teilen riskiert Zustandsverlust
C9Explizite Umgehung des SDK-MCP-Servers (isSdkMcpServerConfig)sendSdkMcpMessage ist per Design pro Sitzung
C10OAuth-Pfad explizit auf F3 verschobenOAuth-Flow benötigt PermissionMediator-artiges Routing; nicht F2-Bereich
C11Neustart-Route-Semantik spezifiziert (Name → alle passenden Einträge)PR 17’s POST /workspace/mcp/:server/restart war zuvor eindeutig (1 Eintrag); jetzt 1..N
C12Status-Route-Refaktor-Abschnitt (neuer Pfad: QwenAgent.getMcpPoolAccounting())httpAcpBridge.ts:733-770 liest derzeit den Bootstrap-Sitzungs-Manager – muss geändert werden
C13Generierungszähler auf PoolEntry für veralteten tools/list_changed-Handler-GuardOpencode-Muster: if (s.clients[name] !== client) return
C14Sub-PR-Aufteilung 4 → 6v1 unterschätzte; A2/B1/B3/C6 fügen jeweils echte Arbeit hinzu
C15Fauler Pool-Aufbau (nur wenn N≥2 Sitzungen gesehen) – optionalqwen serve --foreground mit einer Sitzung profitiert nicht; spart Initialisierungskosten

1. Ziele / Nicht-Ziele

Ziele

  • N Sitzungen in 1 Workspace, die sich 1 Prozess pro eindeutiger Server-Konfiguration teilen – fingerprint-basiert
  • Pro-Sitzung ToolRegistry / PromptRegistry Ansichten erhalten (Filterung, Vertrauensstufe)
  • Refcount + Grace-Drain-Lebenszyklus, widerstandsfähig gegen erneutes Anhängen
  • Plattformübergreifende Bereinigung von Kindprozessen
  • Budget-Guardrails wechseln von Pro-Sitzung zu Pro-Workspace (PR 14 hat dies versprochen)
  • Abwärtskompatibel mit Nicht-Daemon-Standalone-Qwen (Pool wird dort nicht aufgebaut)

Nicht-Ziele (F2-Umfang)

  • Workspace-übergreifendes Pooling (1 Daemon = 1 Workspace Invariante aus PR #4113 bleibt bestehen)
  • Daemon-übergreifendes Pooling (außerhalb des Rahmens – Multi-Prozess-Orchestrator-Territorium)
  • Überarbeitung des OAuth-Routings (F3 mit PermissionMediator)
  • Pool-Persistenz über Daemon-Neustart hinweg (nur In-Memory)
  • Automatische Erkennung von „Pool-sicheren“ HTTP-Servern (nur Opt-In-Flag)
  • Live-MCPServerConfig-Diff zur direkten Änderung von Einträgen (Konfigurationsänderung → neuer Eintrag, alter wird abgebaut)

2. Aktueller Stand (Ablöseziel)

acpAgent.newSession(sessionId) → newSessionConfig(cwd, mcpServers) // acpAgent.ts:1771 → loadCliConfig → new Config → config.initialize() → ToolRegistry ctor → new McpClientManager(config, ...) // tool-registry.ts:199 → for (name, cfg) in config.getMcpServers(): new McpClient(name, cfg, toolRegistry, promptRegistry, workspaceContext, ...) → client.connect() → client.discover(config)

Kopplungsdiagramm (was aufgebrochen oder durchgereicht werden muss):

KopplungOrtAktion in F2
McpClient-Konstruktor bindet 1 ToolRegistry + 1 PromptRegistrymcp-client.ts:106-119Pool besitzt Transport; SessionMcpView (pro Sitzung) besitzt die Sitzungs-Registries
McpClient.discover() ruft toolRegistry.registerTool() inline aufmcp-client.ts:178-198Aufteilung: discoverAndReturn() gibt Snapshot zurück; View registriert
ListRootsRequestSchema-Handler schließt über workspaceContext.getDirectories()mcp-client.ts:142-153 + connectToMcpServer.ts:893Pool-Kontext, der an einen einzelnen Workspace gebunden ist
workspaceContext.onDirectoriesChanged Listener wird pro Verbindung registriertmcp-client.ts:907Pool registriert einmal pro Eintrag
McpClientManager wird innerhalb von ToolRegistry erstellttool-registry.ts:199Optionalen pool?-Konstruktorparameter hinzufügen; Injektion aus Config
Budget-Durchsetzung pro Sitzungmcp-client-manager.ts:91-95 KommentarZustandsautomat in Pool verschieben
serverDiscoveryPromises deduplizieren laufende Anfragen pro Servermcp-client-manager.ts:350Pool hat spawnInFlight: Map<ConnectionId, Promise<PoolEntry>>
setMcpBudgetEventCallback Registrierung pro SitzungacpAgent.ts:1851-1899Pool sendet → QwenAgent broadcastet an alle Sitzungen

Bereits gemeinsam genutzter Zustand (Pool erbt, führt nichts Neues ein):

ZustandOrtHinweis
serverStatuses: Map<string, MCPServerStatus>mcp-client.ts:292 (module-level)Prozessweit heute; Pool-Schlüssel noch nach Name → „any-CONNECTED-gewinnt“
mcpServerRequiresOAuth: Map<string, boolean>mcp-client.ts:302 (module-level)Gleich
MCPOAuthTokenStorage Token auf Festplatte~/.qwen/mcp-oauth/<name>.jsonDaemon-geteilt; Pool nutzt nur effizienter

3. Referenzergebnisse

ProjektPool?SchlüsselLebenszyklusMuster zum Übernehmen
claude-codeNein, pro Prozessname + JSON.stringify(cfg) (lodash.memoize)clearServerCache + remote Backoff×5; stdio Crash → failedSortierter-Schlüssel SHA-256 hashMcpConfig für Invalidierung/Schlüsselbildung
opencodeJa, pro WorkspaceServername nur Name (kein Konfigurations-Hash)Kein Refcount / keine Räumung / kein Neustart; Effect-Finalizer + pgrep -P rekursives SIGTERMRekursive PID-Bereinigung, Stale-Handler-Guard (if (s.clients[name] !== client) return), tools/list_changed Fan-out über Event-Bus

Was F2 von jedem übernimmt: Konfigurations-Hash von claude-code (behandelt pro-Sitzung Env/Auth-Unterschiede, die opencode nicht handhabt), rekursive PID-Bereinigung von opencode (npx/uvx-Wrapper lecken). Was wir hinzufügen: Refcount + Drain (Multi-Client-Daemon), automatischer Neustart (lange laufender Daemon), Prompt-Fan-out, Generierungs-Guard.


4. Architektur

4.1 Prozesslayout

HTTP-Daemon (packages/cli/src/serve, qwen serve) │ startet ACP-Kind (qwen --acp, einzelner Prozess pro Workspace) QwenAgent (acpAgent.ts) ├── McpTransportPool ◄── neu, Workspace-bezogen, 1 Instanz │ ├── entries: Map<ConnectionId, PoolEntry> │ ├── spawnInFlight: Map<ConnectionId, Promise<PoolEntry>> │ ├── workspaceContext (an Daemon-Workspace gebunden) │ └── Budget-Guardrails (PR 14 Zustandsautomat, hochgestuft auf Workspace) └── sessions: Map<sessionId, Session> └── Session.Config → ToolRegistry → McpClientManager(pool?) ┌────────┴────────┐ │ Pool injiziert │ ▼ ▼ pool.acquire(name,cfg,sid) legacy im Prozess → SessionMcpView (Standalone-Qwen) .applyTools/Prompts (Filter + Registrierung in sitzungseigene Registries)

Pool lebt im ACP-Kind, nicht im HTTP-Daemon. Der HTTP-Daemon fragt den Pool-Zustand über die vorhandene bridge.client extMethod-Oberfläche ab (getMcpPoolAccounting, restartMcpServer). F2-Code liegt in packages/core/src/tools/ (neben mcp-client-manager.ts), nicht in packages/acp-bridge/.

4.2 Klassendiagramm

McpTransportPool ├─ acquire(name, cfg, sid) → PooledConnection ├─ release(connectionId, sid) → void ├─ releaseSession(sid) → void (Massenfreigabe für Sitzungsteardown) ├─ restartByName(name) → RestartResult[] ├─ getAccounting() → McpClientAccounting (Workspace-Bereich) ├─ getBudgetMode/Budget() ├─ drainAll() → Promise<void> (Herunterfahren) └─ onBudgetEvent: (event) => void (gesetzt von QwenAgent) PoolEntry (intern) ├─ refs: Set<sessionId> ├─ client: McpClient ├─ toolsSnapshot: DiscoveredMCPTool[] ├─ promptsSnapshot: Prompt[] ├─ generation: number (++ bei erneuter Verbindung; Schutz vor veralteten Events) ├─ state: 'spawning' | 'active' | 'draining' | 'closed' | 'failed' ├─ drainTimer?: NodeJS.Timeout ├─ healthMonitor: { intervalTimer, consecutiveFailures, isReconnecting } ├─ subscribers: Map<sid, SessionMcpView> ├─ attach(sid, view) → PooledConnection └─ detach(sid) → void PooledConnection (Handle, das an den Aufrufer zurückgegeben wird) ├─ id: ConnectionId ├─ on('toolsChanged' | 'promptsChanged' | 'disconnected' | 'reconnected' | 'failed', cb) ├─ callTool(name, args, { sessionId }) → CallToolResult ├─ readResource(uri, { sessionId, signal }) └─ release() SessionMcpView (pro Sitzung, pro Server) ├─ ctor(toolRegistry, promptRegistry, sessionId, serverName, cfg) ├─ applyTools(snapshot) → void (filtert nach include/exclude, dekoriert Vertrauensstufe) ├─ applyPrompts(snapshot) → void └─ teardown() → void (entfernt seine Registrierungen)

5. Pool-Schlüssel (Fingerprint)

5.1 Gehashte kanonische Felder

type PoolKey = string; // sha256 hex, erste 16 Zeichen ausreichend (kollisionsfrei für realistische N) type ConnectionId = `${serverName}::${PoolKey}`; function fingerprint(cfg: MCPServerConfig): PoolKey { const canonical = { transport: mcpTransportOf(cfg), command: cfg.command ?? null, args: cfg.args ?? [], cwd: cfg.cwd ?? null, env: sortedEntries(cfg.env ?? {}), // [[k,v],...] sortiert nach k url: cfg.url ?? null, httpUrl: cfg.httpUrl ?? null, headers: sortedEntries(cfg.headers ?? {}), timeout: cfg.timeout ?? null, oauth: canonicalOAuth(cfg.oauth), }; return sha256(JSON.stringify(canonical)).slice(0, 16); } /** * V21-9: Normalisiert funktional äquivalente OAuth-Konfigurationen, sodass sie * zum gleichen Fingerprint kollabieren. `{enabled: false}`, `undefined`, * `null` und `{}` bedeuten alle „kein OAuth“ → alle geben `null` zurück. */ function canonicalOAuth(o?: OAuthConfig | null): OAuthConfig | null { if (!o || !o.enabled) return null; return { enabled: true, clientId: o.clientId ?? null, scopes: o.scopes ? [...o.scopes].sort() : null, authorizationUrl: o.authorizationUrl ?? null, tokenUrl: o.tokenUrl ?? null, }; } // Ausgeschlossene Felder (Pro-Sitzungs-Filter, NICHT Transportebene): // includeTools, excludeTools, trust, description, extensionName

5.2 Transportklassen-Steuerung

const POOLED_TRANSPORTS_DEFAULT = new Set(['stdio', 'websocket']); function isPoolable(cfg: MCPServerConfig, opts: PoolOptions): boolean { if (isSdkMcpServerConfig(cfg)) return false; const transport = mcpTransportOf(cfg); return opts.pooledTransports.has(transport); }

Standardmäßig pooledTransports = {stdio, websocket}. Operatoren wählen HTTP/SSE über Folgendes ein:

  • CLI: --mcp-pool-transports=stdio,websocket,http,sse
  • Env: QWEN_SERVE_MCP_POOL_TRANSPORTS=stdio,websocket,http

Warum HTTP/SSE standardmäßig ausgeschlossen: Einige MCP-HTTP-Server-Implementierungen binden Zustand (Auth-Kontext, Gesprächsverlauf) an den TCP/SSE-Stream; mehrere ACP-Sitzungen, die ihn teilen, würden den Zustand vermischen. stdio + websocket sind echte Betriebssystemprozesse, deren Zustand beobachtbar und isolierbar ist.

5.3 SDK-MCP-Umgehung

isSdkMcpServerConfig(cfg) true → Pool gibt einen dünnen PooledConnection-Wrapper über createUnpooledConnection(name, cfg, sid) zurück, der sofort einen McpClient erstellt, keine gemeinsame Nutzung, kein Eintrag im Pool. Grund: sendSdkMcpMessage ist von Natur aus pro Sitzung (Routing über ACP-Steuerebene zurück zur ursprünglichen Sitzung). Gleicher Pfad für HTTP/SSE, wenn der Transport nicht in pooledTransports ist (§10.3).

V21-10: Name ist createUnpooledConnection, nicht legacyInProcessAcquire – SDK-MCP und HTTP-Opt-out sind dauerhafte Designentscheidungen, kein Legacy-Code.


6. Lebenszyklus

6.1 acquire / release

class McpTransportPool { private entries = new Map<ConnectionId, PoolEntry>(); private spawnInFlight = new Map<ConnectionId, Promise<PoolEntry>>(); /** V21-2: Rückwärtsindex, O(refs) releaseSession statt O(entries). */ private sessionToEntries = new Map<string, Set<ConnectionId>>(); async acquire( name: string, cfg: MCPServerConfig, sid: string, ): Promise<PooledConnection> { if (!isPoolable(cfg, this.opts)) { return this.createUnpooledConnection(name, cfg, sid); } const id: ConnectionId = `${name}::${fingerprint(cfg)}`; if (this.entries.has(id)) { this.indexAttach(sid, id); return this.entries.get(id)!.attach(sid); } let inFlight = this.spawnInFlight.get(id); if (!inFlight) { const slot = this.tryReserveSlot(name); if (slot === 'refused') { throw new BudgetExhaustedError( name, this.clientBudget!, this.reservedSlots.size, ); } inFlight = this.spawnEntry(name, cfg, id) .catch((err) => { // V21-4: Reservierten Slot bei Fehlschlag des Startens freigeben. Ohne // dies würde der Slot lecken, bis der Health-Monitor-Pfad zur Freigabe // ausgeführt wird (der nicht ausgeführt wird, da kein Eintrag zu überwachen ist). if (slot === 'reserved') this.releaseSlotName(name); throw err; }) .finally(() => this.spawnInFlight.delete(id)); this.spawnInFlight.set(id, inFlight); } const entry = await inFlight; this.indexAttach(sid, id); return entry.attach(sid); } release(id: ConnectionId, sid: string): void { const entry = this.entries.get(id); if (!entry) return; entry.detach(sid); this.indexDetach(sid, id); if (entry.refs.size === 0) entry.startDrainTimer(this.opts.drainDelayMs); } /** V21-2: O(refs dieser Sitzung), nicht O(alle Einträge). */ releaseSession(sid: string): void { const ids = this.sessionToEntries.get(sid); if (!ids) return; for (const id of ids) { const entry = this.entries.get(id); if (!entry) continue; entry.detach(sid); if (entry.refs.size === 0) entry.startDrainTimer(this.opts.drainDelayMs); } this.sessionToEntries.delete(sid); } private indexAttach(sid: string, id: ConnectionId): void { let ids = this.sessionToEntries.get(sid); if (!ids) { ids = new Set(); this.sessionToEntries.set(sid, ids); } ids.add(id); } private indexDetach(sid: string, id: ConnectionId): void { const ids = this.sessionToEntries.get(sid); if (!ids) return; ids.delete(id); if (ids.size === 0) this.sessionToEntries.delete(sid); } }

6.2 Gleichzeitiges Acquire deduplizieren (spawnInFlight)

Spiegelt McpClientManager.serverDiscoveryPromises (mcp-client-manager.ts:350) wider. Ohne dies würden 5 Sitzungen, die beim Start aufsetzen, alle entries.has(id) === false sehen und sich darum kümmern, 5 Kindprozesse zu starten.

6.3 Drain-Gnadenfrist + Leerlaufobergrenze

const DRAIN_DELAY_MS_DEFAULT = 30_000; // Gnadenfrist nach letzter Freigabe const MAX_IDLE_MS_DEFAULT = 5 * 60_000; // harte Obergrenze (Schutz gegen Drain-Abbruchschleife)

Zustandsautomat in PoolEntry:

spawning ──Spawn ok────► active ──letztes detach──► draining ──Timeout──► closed │ │ │ │ │ └──attach──► active (Timer abbrechen) Spawn fehlgeschlagen──►failed └──manueller Neustart──► spawning

Harte Leerlaufobergrenze: Drain-Timer kann unbegrenzt abgebrochen und neu gestartet werden (acquire/release-Flatter). MAX_IDLE_MS ist ein separater Timer, der beim ersten Leerlauf gestartet und nie zurückgesetzt wird; wenn er auslöst, wird der Eintrag zwangsweise geschlossen, selbst wenn der Drain gerade in der aktiven Gnadenfrist ist. Verhindert Zombie-Pool-Einträge durch fehlerhafte Clients, die acquire/release ständig ausführen.

6.4 Plattformübergreifende Bereinigung von Kindprozessen

R10 / R23 T7 / PR A Update (22.05.2026): Umstellung von BFS pro PID (jeweils ein pgrep -P <pid> / Get-CimInstance -Filter Subprozess pro Knoten) auf eine einzige Momentaufnahme der Prozesstabelle, gefolgt von einem In-Memory-Baumdurchlauf. Zwei Gründe: (1) ein Fork statt viele Forks auf dem heißen Pool-Herunterfahrpfad; (2) Konsistenz der Momentaufnahme – vor dem Fix konnte BFS Nachfahren übersehen haben, die zwischen benachbarten BFS-Ebenen abgezweigt sind. Der Pfad pro PID bleibt als Fallback für BusyBox ps <v1.28 (keine -o-Unterstützung) und Distroless-Container ohne ps erhalten.

// packages/core/src/tools/pid-descendants.ts export async function listDescendantPids(rootPid: number): Promise<number[]> { if (!Number.isInteger(rootPid) || rootPid <= 0) return []; try { if (process.platform === 'win32') return await listDescendantPidsWin(rootPid); return await listDescendantPidsUnix(rootPid); } catch { return []; // OS kümmert sich um Waisenkinder; Pool-Herunterfahren wird trotzdem fortgesetzt. } } async function listDescendantPidsUnix(root: number): Promise<number[]> { let tree: Map<number, number[]> | undefined; try { tree = await snapshotProcessTreeUnix(); // ps -A -o pid=,ppid= } catch { /* Fallback */ } if (tree) return walkDescendants(tree, root); // O(Nachfahren), 1 Fork return await listDescendantPidsUnixPgrepFallback(root); // Legacy-BFS } async function snapshotProcessTreeUnix(): Promise<Map<number, number[]>> { // -A: alle Prozesse (POSIX, äquivalent zu -e aber eindeutig auf BSD). // -o pid=,ppid=: pid + ppid Spalten, das nachgestellte `=` unterdrückt Kopfzeilen. const { stdout } = await execFile('ps', ['-A', '-o', 'pid=,ppid='], { timeout: 2000, maxBuffer: 8 * 1024 * 1024, // deckt >250k-Prozess pathologische Hosts ab }); const childrenByPpid = new Map<number, number[]>(); for (const line of stdout.split('\n')) { const m = line.trim().match(/^(\d+)\s+(\d+)$/); if (!m) continue; /* parsen, in childrenByPpid einfügen */ } return childrenByPpid; } // Windows: Einzige Get-CimInstance Win32_Process | ConvertTo-Csv Momentaufnahme // aller (ProcessId, ParentProcessId) Zeilen + In-Memory-Durchlauf; pro PID // `Get-CimInstance -Filter "ParentProcessId=$p"` als Fallback erhalten.

Wird von PoolEntry.shutdown() vor client.disconnect() aufgerufen. Behandelt npx @modelcontextprotocol/server-X, uvx ..., pnpm dlx ... Wrapper-Leaks. MAX_DESCENDANTS=256 / MAX_DEPTH=8 Schutzgrenzen bleiben erhalten.

6.5 Behandlung von Startfehlern

Wenn spawnEntry nachdem mehrere Abonnenten (via spawnInFlight) angehängt wurden, ablehnt:

  • Alle Wartenden erhalten die Ablehnung
  • tryReserveSlot wird über expliziten .catch-Zweig in acquire freigegeben (V21-4); ohne diesen Fix blieb der Slot bis zum nächsten Health-Monitor-Durchlauf undicht, der nie stattfand, da kein Eintrag zu überwachen war.
  • Fehlgeschlagener Eintrag NICHT in entries gespeichert
  • Die Codepfade der Abonnenten behandeln den Fall, als ob acquire ursprünglich fehlgeschlagen wäre (vorhandene Catch-Logik von discoverMcpToolsForServer pro Sitzung bleibt gültig)

6.6 Wiederherstellungs-Backoff (V21-8)

Wenn ein PoolEntry nach einem Transportabbruch in die Wiederherstellung eintritt:

TransportfamilieStrategieObergrenze
stdioFest 5s × 3 VersucheEntspricht vorhandenem DEFAULT_HEALTH_CONFIG.reconnectDelayMs
websocketFest 5s × 3 VersucheGleich wie stdio
http (Opt-In)Exponentiell 1s, 2s, 4s, 8s, 16s × 5 VersucheRemote-Endpunkte flattern bei vorübergehenden Netzwerkproblemen; größeres Budget
sse (Opt-In)Exponentiell 1s, 2s, 4s, 8s, 16s × 5 VersucheGleich wie http

Nach Erschöpfung der Obergrenze: Eintrag wechselt in den Zustand failed; Abonnenten erhalten das failed-Event; ein neuer acquire für dieselbe ConnectionId versucht einmal einen Neustart, dann wird ausgelöst. Operatorenstart (§13) setzt den Zustand zurück.

7. Discovery / SessionMcpView

7.1 Tools + Prompts Dual-Fan-Out

// packages/core/src/tools/mcp-client.ts — split discover into pure async discoverAndReturn(cliConfig: Config): Promise<{ tools: DiscoveredMCPTool[]; prompts: Prompt[]; }> { if (this.status !== MCPServerStatus.CONNECTED) throw new Error('Client is not connected.'); try { const [prompts, tools] = await Promise.all([ discoverPrompts(this.serverName, this.client, /* no registry */), discoverTools(this.client, this.serverConfig, this.serverName, this.debugMode, this.workspaceContext), ]); if (prompts.length === 0 && tools.length === 0) { throw new Error('No prompts or tools found on the server.'); } return { tools, prompts }; } catch (e) { this.updateStatus(MCPServerStatus.DISCONNECTED); throw e; } } // Legacy discover() retained, delegates to discoverAndReturn + registers (for standalone qwen) async discover(cliConfig: Config): Promise<void> { const { tools, prompts } = await this.discoverAndReturn(cliConfig); for (const t of tools) this.toolRegistry.registerTool(t); for (const p of prompts) this.promptRegistry.registerPrompt(p); }
class SessionMcpView { applyTools(snapshot: DiscoveredMCPTool[]) { this.sessionToolRegistry.removeToolsByServer(this.serverName); for (const tool of snapshot) { if (!this.passesFilter(tool)) continue; // C7: per-session copy of trust (don't mutate shared snapshot) const localTool = tool.withTrust(this.cfg.trust); this.sessionToolRegistry.registerTool(localTool); } } applyPrompts(snapshot: Prompt[]) { this.sessionPromptRegistry.removePromptsByServer(this.serverName); for (const p of snapshot) this.sessionPromptRegistry.registerPrompt(p); } }

7.2 Snapshot-Replay bei Attach (earlyEvents-Stil)

class PoolEntry { attach(sid: string): PooledConnection { this.refs.add(sid); this.cancelDrainTimer(); const view = new SessionMcpView(...); this.subscribers.set(sid, view); // Immediately replay current snapshot so subscriber doesn't miss // updates that landed between in-flight discover completion and // attach. if (this.state === 'active') { view.applyTools(this.toolsSnapshot); view.applyPrompts(this.promptsSnapshot); } return this.makeHandle(sid, view); } }

Spiegelt das PR-14b-Fix#1-BridgeClient.earlyEvents-Muster wider – löst das analoge Race-Condition beim Pool-Attach.

7.3 Stale-Handler-Guard (Generation Counter)

class PoolEntry { private generation = 0; private async reconnect(): Promise<void> { this.generation += 1; const myGen = this.generation; await this.client.disconnect(); await this.client.connect(); if (myGen !== this.generation) return; // superseded by another reconnect const snap = await this.client.discoverAndReturn(this.cfg); if (myGen !== this.generation) return; this.toolsSnapshot = snap.tools; this.promptsSnapshot = snap.prompts; this.fanOut('toolsChanged'); this.fanOut('promptsChanged'); } private onServerToolsListChanged = () => { const myGen = this.generation; this.client .discoverAndReturn(this.cfg) .then((snap) => { if (myGen !== this.generation) return; this.toolsSnapshot = snap.tools; this.fanOut('toolsChanged'); }) .catch(/* swallow + log */); }; }

Ohne diese Absicherung könnte ein Stale-Handler einer vor dem Reconnect erstellten Client-Instanz den Snapshot nach dem Reconnect mit veralteten Daten überschreiben.

Monotonie-Invariante (V21-Präzisierung): generation inkrementiert nur, wird nie zurückgesetzt. Jede laufende Operation erfasst zu Beginn myGen und prüft nach await, ob myGen === this.generation. Entspricht „Seit meinem Start ist kein neueres Ereignis eingetreten”. Begrenzt auf Number.MAX_SAFE_INTEGER (~285k Jahre bei 1Hz Reconnect), kein Überlaufproblem.

7.4 Pfadvereinheitlichung (F2-1 Scope-Erweiterung)

packages/core/src/tools/mcp-client.ts hat ZWEI Connect-to-Server-Pfade:

  1. McpClient-Klasse (mcp-client.ts:100) – verwendet von McpClientManager
  2. connectToMcpServer-Factory-Funktion (mcp-client.ts:875) – verwendet von discoverMcpTools (Zeile 560) und connectAndDiscover (Zeile 607)

F2-1 muss beide hinter McpClient.discoverAndReturn zusammenführen (entweder wird connectToMcpServer zu einem privaten Helfer von McpClient oder beide rufen eine gemeinsame establishConnection()-Primitive auf). Andernfalls deckt der Pool nur den Klassenpfad ab; der Factory-Pfad bleibt pro Session und untergräbt die gesamte Bemühung.


8. Koexistenz globaler Zustände

8.1 serverStatuses (mcp-client.ts:292) – kollisionstolerante Schreibvorgänge

Modulweite Map<serverName, MCPServerStatus>. Die ConnectionId des Pools ist name::hash, aber updateMCPServerStatus(name, status) schreibt nach Name. Mehrere Pool-Einträge für denselben Namen (unterschiedliche Fingerabdrücke, z. B. Token-Divergenz) würden sich gegenseitig den Status überschreiben.

Lösung: Pool fängt Status-Schreibvorgänge ab:

class PoolEntry { updateStatus(s: MCPServerStatus) { this.localStatus = s; const aggregated = this.pool.aggregateStatusByName(this.serverName); updateMCPServerStatus(this.serverName, aggregated); } } class McpTransportPool { aggregateStatusByName(name: string): MCPServerStatus { // Any CONNECTED ⇒ CONNECTED // Else any CONNECTING ⇒ CONNECTING // Else DISCONNECTED const entries = [...this.entries.values()].filter( (e) => e.serverName === name, ); if (entries.some((e) => e.localStatus === CONNECTED)) return CONNECTED; if (entries.some((e) => e.localStatus === CONNECTING)) return CONNECTING; return DISCONNECTED; } }

Die Status-Route zeigt entryCount: number an, sodass Bediener sehen, wenn ein Name → mehrere Einträge hat.

8.2 OAuth-Token-Speicher

MCPOAuthTokenStorage schreibt nach ~/.qwen/mcp-oauth/<serverName>.json – bereits daemon-host-shared. Der Pool profitiert beiläufig (OAuth der ersten Session wird abgeschlossen → Token auf Platte → Pool-Eintrag holt Token beim Reconnect → alle anderen Sessions hängen sich mit dran).

Einschränkung – Multi-Fingerprint-Fall: 2 Einträge für denselben Namen (unterschiedliche Header/Env) aber derselbe OAuth-Provider → beide lesen dieselbe Token-Datei. Wenn Token server-scoped sind (OAuth typisch), funktioniert das. Wenn Token env-scoped sind (selten), ist eine explizite Erweiterung des Speicherschlüssels nötig. Auf F3 verschoben mit dokumentiertem Known-Limitation.

8.3 entryCount im Snapshot

GET /workspace/mcp-Zelle pro Server fügt hinzu:

{ kind: 'mcp_server', name: 'github', status: 'ok', mcpStatus: 'connected', entryCount: 2, // NEU — N Pool-Einträge für diesen Namen entrySummary?: [ // NEU — undurchsichtige Aufschlüsselung pro Eintrag { entryIndex: 0, refs: 2, status: 'connected' }, { entryIndex: 1, refs: 1, status: 'connecting' }, ], ... }

V21-7: entrySummary[].entryIndex ist ein stabiler, undurchsichtiger Integer, der bei der Eintragserstellung zugewiesen wird (Einfügereihenfolge innerhalb der Namensgruppe), NICHT der rohe Fingerprint. Begründung: Fingerprints ändern sich, wenn OAuth-Tokens oder Umgebungsvariablen rotieren, was diese Information über Snapshot-Diffs durchsickern lassen würde (Operator könnte auf „Token rotiert bei T+5min” schließen aus dem Übergang 'a3b1' → 'f972'). entryIndex ist innerhalb der Namensgruppe monoton, bleibt aber über Rotationen hinweg stabil, weil der alte Eintrag drainiert und der neue Eintrag den nächsten Index bekommt.

Alte SDK-Clients ignorieren unbekannte Felder laut PR-14-Vertrag; neue Clients verwenden entryCount für Badges. Der interne Restart-by-Fingerprint-Pfad verwendet ein undurchsichtiges Token, das nur über privilegierte extMethod zurückgegeben wird, nicht im HTTP-Snapshot.


9. WorkspaceContext / ListRoots

9.1 Einmalige Registrierung

Die McpClient-Instanzen des Pools teilen sich einen WorkspaceContext – den gebundenen Workspace-Context des Daemons (PR-#4113-Invariante). Der ListRootsRequestSchema-Handler von connectToMcpServer schließt über diesen einzigen Context.

Der onDirectoriesChanged-Listener wird einmal pro Eintrag registriert, nicht einmal pro acquire. Bei Shutdown des Eintrags wird er abgemeldet.

9.2 roots/list_changed Fan-Up

Server benachrichtigt Client über neue Roots → Pool verteilt:

  • Pool erkennt neu (Server könnte unter neuen Roots andere Toolmengen melden) → toolsChanged-Event → alle Subscriber-Views wenden erneut an

9.3 Pro-Session updateWorkspaceDirectories

Vertrag: In Modus B sind pro-Session-Verzeichniserweiterungen nur ein weicher Hinweis, nicht autoritativ. Der WorkspaceContext des Pools liegt auf Daemon-Ebene.

Zwei Implementierungsoptionen:

  • v1 einfach: Pro-Session-Hinzufügungen ignorieren, Warnung loggen wenn erkannt
  • v2 Union: Pool pflegt extraRoots: Map<sessionId, Set<dir>>, ListRoots-Handler gibt Union aus gebundenem Workspace + allen Extras zurück. Entfernen pro Session löst roots/list_changed aus. Erhöht die Code-Komplexität um 50-80 LOC.

Für F2 v1 einfach wählen; v2 Union als Follow-up, falls Nutzerprobleme auftreten.


10. Pro-Session-Injektion

10.1 mcpServers aus newSession({mcpServers})

newSessionConfig(cwd, mcpServers, ...) merged die injizierte Liste mit settings.merged.mcpServers (acpAgent.ts:1778-1831). Der Pool konsumiert die pro-Session-gemerge Ansicht:

async newSessionConfig(...) { const config = await loadCliConfig(...); if (this.mcpPool) config.setMcpTransportPool(this.mcpPool); // ...existing setMcpBudgetEventCallback REMOVED — pool handles broadcast directly }

Wenn zwei Sessions einen gleichnamigen Server mit unterschiedlichem Env/Headers injizieren → unterschiedliche Fingerprints → zwei Pool-Einträge. Pool-Sharing greift nur, wenn Sessions exakt übereinstimmen.

10.2 Auth-Divergenz

Statische ~/.qwen/settings.json-mcpServers sind über Sessions hinweg identisch → alle teilen → 80%-Fall. Pro-Session injizierte mcpServers mit nutzerspezifischen Tokens → eindeutige Fingerprints → kein Sharing. Beides sicher.

10.3 HTTP-Transport-Opt-In (Zusammenfassung aus §5.2)

Standardmäßig pooledTransports = {stdio, websocket}. HTTP/SSE-Server durchlaufen den createUnpooledConnection-Pfad (ein McpClient pro Session), es sei denn, der Bediener optiert ein.

10.4 /mcp disable X während der Session (V21-6)

Wenn der Bediener /mcp disable github gegen eine laufende Session ausführt:

  1. Config.disableMcpServer('github') fügt dem pro-Config-Set disabledMcpServers hinzu
  2. F2-Hook: Config.onDisabledMcpServersChanged feuert; SessionMcpView für diesen Namen ruft teardown() auf (entfernt seine Tool-/Prompt-Registrierungen aus den Session-Registries)
  3. Pool-Eintrag kann bestehen bleiben, falls andere Sessions ihn noch referenzieren (refcount > 0) – nur die deaktivierende Session trennt ihre View ab
  4. Wenn alle Sessions deaktivieren → refcount → 0 → Drain-Timer startet

Ohne Schritt 2 würde eine Deaktivierung während der Session bereits registrierte Tools im ToolRegistry der Session belassen, bis zum nächsten Session-Neustart. Test 21.4 deckt dies ab.

/mcp enable github ist die Umkehrung: löst ein erneutes pool.acquire für die Session aus, hängt eine neue View an und wendet den Snapshot erneut an.


11. Budget-Guardrails-Graduierung

11.1 Zustandsmaschine wandert in den Pool

tryReserveSlot / releaseSlotName / 75%-Hysterese / refused_batch-Coalescing / bulkPassDepth / pendingRefusalNames – all das migriert von McpClientManager zu McpTransportPool. McpClientManager behält den Zustand nur, wenn er standalone läuft (kein Pool injiziert).

11.2 Snapshot-Zellen-Scope

{ kind: 'mcp_budget', scope: 'workspace', // NEUER Wert (PR 14 v1 gab 'session' zurück) liveCount: 5, clientBudget: 10, budgetMode: 'enforce', status: 'ok', }

Laut PR-14-Vertrag: „Consumers MUST tolerate additional entries with unrecognized scope values (drop, don’t fail).” Alte SDK-Clients sehen scope: 'workspace', rendern als unbekannt (oder fallen zurück auf Top-Level-Zahlen). Neues SDK fügt den Helper isWorkspaceScopedBudget(cell) hinzu.

11.3 Event-Fan-Out

class QwenAgent { constructor() { this.mcpPool = new McpTransportPool({ onBudgetEvent: (event) => this.broadcastBudgetEvent(event), }); } private broadcastBudgetEvent(event: McpBudgetEvent) { for (const [sid, session] of this.sessions) { const enriched = { ...event, scope: 'workspace' as const, sessionId: sid, }; session.connection .extNotification('qwen/notify/session/mcp-budget-event', enriched) .catch((err) => debugLogger.debug('budget event delivery failed', { sid, err }), ); } } }

11.4 SDK-Typvertragsänderungen

PR 14b exportierte diese (müssen additiv erweitert werden):

  • DaemonMcpBudgetWarningDatascope?: 'workspace' | 'session' hinzufügen (optional für Rückwärtskompatibilität; fehlend = ‘session’)
  • DaemonMcpChildRefusedBatchData – gleiche scope?-Erweiterung
  • DaemonMcpGuardrailEvent – Diskriminator unverändert

Neue SDK-Helper:

export function isWorkspaceScopedBudgetEvent( e: DaemonMcpGuardrailEvent, ): boolean;

Reducer-Zustand auf DaemonSessionViewState:

  • Keine neuen FeldermcpBudgetWarningCount / mcpChildRefusedBatchCount inkrementieren unabhängig vom Scope (Scope ist eine Eigenschaft jedes Events, kein separater Stream)
  • Dokumentieren, dass diese Zähler unter F2 Workspace-Level-Events widerspiegeln, die an jede Session weitergeleitet werden – sie werden bei Budgetdruck gleichzeitig über alle angehängten Sessions inkrementieren

V21-12 (Q1 gelöst, in v2.1 eingefroren): bestehende Feldnamen behalten (mcpBudgetWarningCount, mcpChildRefusedBatchCount, lastMcpBudgetWarning, lastMcpChildRefusedBatch) mit erweiterter Scope-Semantik, dokumentiert im JSDoc:

/** * Count of `mcp_budget_warning` events the session has observed. * Under F2 (`scope: 'workspace'`), this increments simultaneously * across all attached sessions because budget events fan out at * workspace level. Use `isWorkspaceScopedBudgetEvent(lastMcpBudgetWarning)` * to inspect scope of the most recent event. */ mcpBudgetWarningCount: number;

Begründung: PR 14b hat diese Namen bereits als öffentliche SDK-Oberfläche ausgeliefert; eine Umbenennung wäre ein Breaking Change, der schlimmer ist als die leicht ungenaue Semantik.


12. OAuth – Explizites F3-Deferral

Der OAuth-401-Fallback in connectToMcpServer (mcp-client.ts:950-1010) benötigt interaktive Auflösung (Browser öffnen oder Device-Flow). Mode-B-Daemon darf keinen Browser öffnen (laut PR-21-Design – statischer Source-Grep-Test schlägt bei open/xdg-open/shell.openExternal im Build fehl).

F2-Verhalten bei OAuth-erforderndem Server:

  1. Erstes acquire löst connectToMcpServer aus → 401 erkannt
  2. Pool fängt die OAuth-erfordernde Exception, markiert Eintrag als failed_auth_required
  3. Status-Route zeigt errorKind: 'auth_env_error' (bestehender PR-13-errorKind)
  4. Pool wiederholt nicht automatisch
  5. Bediener führt /mcp auth <name> aus (bestehende CLI) ODER verwendet PR-21-Device-Flow-Route, um ein Token auf die Platte zu bekommen → nächstes Session-acquire wiederholt und hat Erfolg

F3 wird Schritte 4-5 durch PermissionMediator ersetzen, der die OAuth-Completion-Anfrage an angehängte Sessions zur Erstantwort weiterleitet.

Dies vermeidet, dass F2 in die Auth-State-Machine-Arbeit hineingezogen wird.


13. Neustart-Routen-Semantik

13.1 POST /workspace/mcp/:server/restart unter Pool

Heute (PR 17): Restart im Bootstrap-Session-Manager = Neustart des einzelnen Eintrags für diesen Namen.

Unter Pool: Name → möglicherweise mehrere Einträge (unterschiedliche Fingerprints für denselben Namen = unterschiedliche Sessions mit unterschiedlichen Konfigurationen).

Spezifiziertes Verhalten:

RequestVerhalten
POST /workspace/mcp/:server/restartAlle Einträge, die zu serverName passen, neu starten (parallel via Promise.allSettled)
POST /workspace/mcp/:server/restart?entryIndex=0V21-3: nur Eintrag #0 neu starten (der undurchsichtige Index aus Snapshot §8.3); 404 falls nicht gefunden
POST /workspace/mcp/:server/restart?entryIndex=*Explizit „alle” (wie ohne Parameter)

Antwortstruktur:

type RestartResult = { entryIndex: number; // V21-7: undurchsichtiger Index, nicht roher Fingerprint restarted: boolean; durationMs?: number; reason?: string; // 'budget_would_exceed' | 'not_connected' | 'in_flight' }; POST /workspace/mcp/:server/restart → { entries: RestartResult[] }

Alte Form {restarted: true, durationMs} bleibt erhalten, wenn entries.length === 1 UND kein entryIndex-Query-Parameter angegeben ist (Rückwärtskompatibilität); Clients können die neue Form erkennen, indem sie auf 'entries' in response prüfen.

13.2 In-Flight-Restart-Deduplizierung

class PoolEntry { private restartInFlight?: Promise<void>; async restart(): Promise<void> { if (this.restartInFlight) return this.restartInFlight; this.restartInFlight = this.doRestart().finally(() => { this.restartInFlight = undefined; }); return this.restartInFlight; } }

13.3 Budget-Prüfung (erhält PR-17-Verhalten)

Vor dem Neustart prüft der Pool das Budget: wenn Disconnect+Reconnect noch passen, OK. Die aktuelle PR-17-Semantik {restarted:false, skipped:true, reason:'budget_would_exceed'} bleibt erhalten (wird jetzt pro Eintrag angewendet).

13.4 In-Flight-Tool-Aufruf während Reconnect (V21-5, neu)

Session A ruft pool.callTool('git.commit', args) auf → Anfrage trifft auf stdin des unterliegenden Child-Prozesses → Child-Prozess stürzt während des Schreibens ab → Eintrag geht in Reconnect:

class MCPCallInterruptedError extends Error { readonly serverName: string; readonly entryIndex: number; readonly clientGeneration: number; // Generation vor dem Reconnect readonly args: unknown; // ursprüngliche Argumente, zum erneuten Versuch durch Aufrufer, falls sicher constructor(serverName, entryIndex, clientGeneration, args) { ... } }

Spezifikation:

  • Das Promise des laufenden Aufrufs wird mit MCPCallInterruptedError abgelehnt, sobald der Transportabbruch erkannt wird (nicht auf Reconnect warten)
  • Der Pool wiederholt den Aufruf NICHT automatisch; Semantik ist unsicher für Schreibvorgänge (Commit, Dateibearbeitung usw.) und der Pool kann Lesen von Schreiben nicht unterscheiden
  • Der Aufrufer (typischerweise Tool-Ausführungsschicht in der Agentenschleife) fängt diesen Fehler ab und entscheidet: erneut versuchen / dem Benutzer anzeigen / abbrechen
  • Nach dem Reconnect: Session A kann erneut aufrufen (gleicher PooledConnection.callTool); Pool leitet transparent zur neuen Transportinstanz weiter
  • MCPCallInterruptedError.clientGeneration ermöglicht es dem Aufrufer, bei Bedarf mit einem folgenden reconnected-Event zu korrelieren

Test 21.6 muss abdecken: Einen langlebigen stdio-MCP starten, Tool-Aufruf senden, das Child während des Aufrufs töten, Ablehnung mit MCPCallInterruptedError und nicht-null clientGeneration bestätigen.


14. Status-Route-Refactoring

14.1 Neuer Abfragepfad

// httpAcpBridge.ts:733 buildWorkspaceMcpStatus — replace data source let accounting: McpClientAccounting | undefined; try { // NEU: Pool direkt via Bridge-extMethod abfragen, nicht Bootstrap-Session accounting = await this.bridge.client.getMcpPoolAccounting(); } catch (err) { // Fallback auf Legacy-Bootstrap-Session-Pfad für Non-Pool-Daemon const manager = config.getToolRegistry()?.getMcpClientManager(); if (manager) accounting = manager.getMcpClientAccounting(); }

QwenAgent macht getMcpPoolAccounting() verfügbar:

class QwenAgent { getMcpPoolAccounting(): McpClientAccounting | undefined { return this.mcpPool?.getAccounting(); } }

ACP-Child-Bridges leiten über extMethod weiter, damit der Daemon aufrufen kann.

14.2 entryCount + entrySummary

Siehe §8.3.

14.3 Fall ohne Bootstrap-Session

Heute (PR 12) gibt GET /workspace/mcp initialized: false zurück, wenn der Daemon im Leerlauf ist (noch keine Sessions), weil keine Bootstrap-Session zum Abfragen existiert.

Unter Pool: Pool existiert ab dem QwenAgent-Konstruktor → Status-Route kann Live-Accounting zurückgeben selbst bei null Sessions. Zelle initialized: true sogar vor der ersten Session. Dokumentierte Verhaltensänderung in der PR-Beschreibung; kein Regression.


15. loadSession / resume-Interaktion (PR 6 #4222)

15.1 Drain-Abbruch bei Resume

session-A aktiv, hält Referenz auf entry-X session-A disconnect (kein explizites Close) → irgendwann killSession → pool.releaseSession(A) → entry-X.refs.size === 0 → Drain-Timer startet (30s) session-A resume innerhalb von 30s → neues newSessionConfig → pool.acquire gibt entry-X zurück → attach bricht Drain ab session-A resume nach 30s → entry-X bereits geschlossen → Pool erzeugt neuen Eintrag (Kaltstart)

15.2 restoreState-Cache-Fenster (5 Min., aus PR 6)

acpAgent.restoreState wird 5 Min. nach Trennung gehalten. Pool-Drain (30s Standard) < Wiederherstellungsfenster (5 Min.) → Wiederaufnahme zwischen 30s und 5 Min. verursacht MCP-Kaltstart. Akzeptabler Kompromiss (Wiederaufnahme selbst ist seltener Pfad).

Alternative: Pool liest die Restore-Window-Konfiguration des Daemons und verlängert den Drain entsprechend. Erhöht Kopplung zwischen Pool und Session-Zustandsmaschine; auf Folgearbeit verschieben, es sei denn, Benutzer melden Kaltstart-Schmerzen.

15.3 pendingRestoreIds-Interaktion

acpAgent.killSession() MUSS pool.releaseSession(sid) NACH der Bereinigung von pendingRestoreIds aufrufen. Reihenfolge:

  1. Session als wiederherstellbar markiert (pendingRestoreIds.add(sid))
  2. Session.close() — aber Pool-Referenz bleibt erhalten
  3. Nach Ablauf von RESTORE_WINDOW_MS ohne Wiederaufnahme: killSession bereinigt endgültig → pool.releaseSession(sid) löst Drain aus

Vermeidet, dass Drain während eines Wiederherstellungsfensters ausgelöst wird.


16. Hot Config Reload

16.1 Implizites Neuladen durch Fingerprint-Änderung

Benutzer bearbeitet ~/.qwen/settings.json während des Betriebs, ändert die Umgebung eines Servers:

  1. Alte Sessions behalten alten Config/McpServers-Snapshot → behalten alten Fingerprint → Referenz auf Eintrag-Alt bleibt bestehen
  2. Neue Session liest neue Einstellungen → neuer Fingerprint → Eintrag-Neu erstellt → koexistiert mit Eintrag-Alt
  3. Alte Sessions schließen natürlich → Eintrag-Alt drain → schließlich geschlossen
  4. Gleichgewichtszustand: nur Eintrag-Neu bleibt

Keine Live-Mutation laufender Verbindungen — saubere Trennung zwischen Sessions mit unterschiedlichen Konfigurationsversionen.

16.2 Erzwungener Neuladepfad (optional)

POST /workspace/mcp/reload-all → für jede Session: Einstellungen neu laden, Config.mcpServers austauschen → für jeden nicht mehr referenzierten Eintrag: Löschung einplanen

Nützlich für „Ich habe Umgebungsvariablen geändert und möchte sofortige Wirkung auf alle Sessions.” Auf F2-Folgearbeit verschieben (nicht blockierend).

16.3 Erweiterungs-Deinstallation verwaiste Einträge (V21-15)

Szenario: Erweiterung foo-ext registriert MCP-Server foo-server. Operator führt /extension uninstall foo-ext aus. Der Erweiterungslebenszyklus entfernt foo-server aus extensionMcpServers, sodass zukünftige loadCliConfig-Aufrufe ihn nicht enthalten. Aber:

  • Live-Sessions halten Config-Snapshots, die noch foo-server enthalten → diese Sessions nutzen den Eintrag weiter
  • Neue Sessions nach Deinstallation erwerben ihn nicht (Server nicht mehr in ihrem gemergten mcpServers) → kein Referenzzähleranstieg

Lösung: Auf natürlichen Drain verlassen. Wenn alte Sessions schließen, sinkt der Referenzzähler; schließlich erreicht der Eintrag MAX_IDLE_MS = 5min und wird zwangsgeschlossen. Keine explizite pool.invalidateByExtension(name)-API — hält das Modell einheitlich mit Hot Config Reload (§16.1).

Kompromiss: Der Server der Erweiterung kann bis zu 5 Min. nach Deinstallation laufen, wenn eine lange Session ihn am Leben hält. Akzeptabel; Operatoren können /mcp restart foo-server und dann die Session beenden, wenn Dringlichkeit besteht.


17. Abschalt-Reihenfolge

QwenAgent.close()-Sequenz (muss erzwungen werden):

1. Setze acceptingNewSessions = false; lehne neue POST /session ab 2. Für jede laufende Eingabeaufforderung: Signalisiere Abbruch, warte auf Abschluss (bestehender PR 11 Lebenszyklus) 3. Für jede Session: Trigger close → pool.releaseSession(sid) 4. Await pool.drainAll({ force: true, timeoutMs: 10_000 }) ← umgeht 30s Gnadenfrist ├── Für jeden Eintrag: Brich Drain + Health-Timer ab, markiere als drainend ├── Für jeden Eintrag parallel: listDescendantPids → SIGTERM an Kindprozesse ├── Für jeden Eintrag parallel: client.disconnect() └── Promise.race gegen timeoutMs; verlassene Einträge erhalten SIGKILL 5. Bridge-Kanal schließen 6. Prozess beenden

V21-11: drainAll-Signatur:

async drainAll(opts?: { force?: boolean; // Standard false; true umgeht 30s Gnadenfrist-Timer timeoutMs?: number; // Standard 10_000; Wanduhr-Budget; SIGKILL für Nachzügler danach }): Promise<DrainResult>; type DrainResult = { drained: number; // Einträge, die sauber getrennt wurden forced: number; // Einträge, die nach Timeout SIGKILL erhalten haben errors: Array<{ entryIndex: number; serverName: string; error: string }>; };

Aufrufer verwendet DrainResult für Abschaltprotokollierung; bei forced > 0 eine Warnung loggen, damit der Operator weiß, dass ein Server nicht sauber heruntergefahren wurde.


18. Dateistruktur

Neue Dateien:

packages/core/src/tools/ mcp-transport-pool.ts # McpTransportPool Hauptdatei (~700 LOC) mcp-pool-key.ts # Fingerprint + canonicalize Helfer (~150 LOC) mcp-pool-entry.ts # PoolEntry: refcount + drain + health + generation (~500 LOC) session-mcp-view.ts # SessionMcpView: filter + register tools/prompts (~200 LOC) mcp-pool-events.ts # PoolEvent diskriminierte Union (~80 LOC) pid-descendants.ts # listDescendantPids plattformübergreifend (~150 LOC, inkl. Tests) packages/core/src/tools/ mcp-transport-pool.test.ts # ~900 LOC mcp-pool-entry.test.ts # ~400 LOC session-mcp-view.test.ts # ~250 LOC mcp-pool-key.test.ts # ~150 LOC pid-descendants.test.ts # ~200 LOC (Unix + Windows skip-gated)

Geänderte Dateien:

packages/core/src/tools/mcp-client.ts # discoverAndReturn() aufgeteilt; connectToMcpServer vereinheitlicht packages/core/src/tools/mcp-client-manager.ts # optionaler Pool-Parameter; Budget-Zustand konditional packages/core/src/tools/tool-registry.ts # fädelt Pool von Config in McpClientManager ein packages/core/src/config/config.ts # setMcpTransportPool / getMcpTransportPool packages/cli/src/acp-integration/acpAgent.ts # QwenAgent.mcpPool-Konstruktion; broadcastBudgetEvent; # newSessionConfig verdrahtet Pool in Config; # killSession ruft pool.releaseSession auf packages/cli/src/serve/run-qwen-serve.ts # übergibt --mcp-pool-transports + Budget-Umgebung an ACP-Kind packages/cli/src/serve/httpAcpBridge.ts # buildWorkspaceMcpStatus liest Pool; # restartMcpServer extMethod gibt RestartResult[] zurück packages/cli/src/serve/capabilities.ts # wirbt mcp_workspace_pool an packages/sdk/src/daemon/mcpEvents.ts # scope?: optionales Feld; isWorkspaceScopedBudgetEvent Helfer

19. Ein-PR-Auslieferung — Commit-Aufteilung (V21-1)

Gemäß der Richtlinie des Maintainers für funktionskohärente Batches (#4175 Branching-Strategie 2026-05-19) wird F2 als ein PR mit 6 atomaren Commits ausgeliefert. Der Reviewer kann mit git log -p HEAD~6..HEAD Schritt für Schritt vorgehen und Commit für Commit prüfen.

Commit #TitelUmfangBetrifft
1refactor(core): McpClient.discover in reine Tool-/Prompt-Liste aufteilen und Connect-Pfade vereinheitlichendiscoverAndReturn() hinzugefügt; gemeinsames establishConnection() extrahiert, das sowohl von McpClient.connect() als auch von der connectToMcpServer()-Factory verwendet wird; Legacy discover() wird dünner Wrapper, der registriert (bewahrt eigenständiges qwen-Verhalten). Keine beobachtbare Verhaltensänderung.mcp-client.ts, mcp-client.test.ts
2feat(core): McpTransportPool + SessionMcpViewPool-Kern: fingerprint, refcount, spawnInFlight-Deduplizierung, sessionToEntries-Rückwärtsindex, Drain-Zustandsmaschine, Snapshot-Wiedergabe bei Anhang, Generation-Guard, Tool+Prompt duales Fan-out, pro-Session-Kopieren von trust. McpClient für Unit-Tests gemockt. Keine Produktionsverdrahtung.neue mcp-transport-pool.ts, mcp-pool-key.ts, mcp-pool-entry.ts, session-mcp-view.ts, mcp-pool-events.ts + Tests
3feat(core): plattformübergreifender Abstiegs-PID-Sweep + Pool-Health-MonitorlistDescendantPids (Unix pgrep -P rekursiv, Windows PowerShell CIM); vereinheitlichter Health-Monitor innerhalb von PoolEntry (Intervallprüfung + Fehlerzähler + Wiederverbindungs-Backoff gemäß §6.6); Subprozess-Spawn-Integrationstests gated auf QWEN_INTEGRATION === '1'.neue pid-descendants.ts + Tests; mcp-pool-entry.ts
4feat(serve): McpTransportPool in QwenAgent-Daemon-Modus einbindenConfig.setMcpTransportPool + getMcpTransportPool; ToolRegistry fädelt Pool in McpClientManager ein; McpClientManager optionaler pool?-Konstruktorparameter; acpAgent.QwenAgent konstruiert Pool bei Initialisierung; newSessionConfig-Injektion; killSession ruft pool.releaseSession auf; SDK MCP + HTTP/SSE umgehen via createUnpooledConnection; CLI-Flags --mcp-pool-transports, --mcp-pool-drain-ms, --no-mcp-pool.config.ts, tool-registry.ts, mcp-client-manager.ts, acpAgent.ts, run-qwen-serve.ts
5feat(serve): Pool-bewusste Status- und Neustart-RoutenQwenAgent.getMcpPoolAccounting extMethod; httpAcpBridge.buildWorkspaceMcpStatus Pool-zuerst + Bootstrap-Session-Fallback; restartMcpServer akzeptiert ?entryIndex= und gibt RestartResult[] zurück; entryCount + entrySummary[].entryIndex auf Zelle; Capability-Tags mcp_workspace_pool + mcp_pool_restart.httpAcpBridge.ts, capabilities.ts, SDK-Typen
6feat(serve): MCP-Budget-Schutzmechanismen auf Workspace-Bereich hebentryReserveSlot/releaseSlotName/Hysterese-Zustandsmaschine von McpClientManager in den Pool verschieben; pro-Session setMcpBudgetEventCallback-Verdrahtung in acpAgent.newSessionConfig entfernen; QwenAgent.broadcastBudgetEvent Fan-out; Snapshot-Zelle scope: 'workspace'; SDK scope?-additives Feld; isWorkspaceScopedBudgetEvent-Helfer; Inline-Dokumentationsaktualisierungen.mcp-transport-pool.ts, mcp-client-manager.ts, acpAgent.ts, httpAcpBridge.ts, SDK

Gesamte LOC-Schätzung: ~4100 Produktion + ~1900 Tests = ~6000 LOC (v2-Schätzung ~3850; Wachstum absorbiert V21-Korrekturen).

Merge-Ziel: einzelner PR in daemon_mode_b_main. Periodischer Batch-Merge nach main gemäß #4175-Strategie.

Selbstreview-Prozess vor PR-Eröffnung:

  1. Nach jedem Commit code-reviewer-Agent auf dem Commit-Diff ausführen; übernommene Erkenntnisse in denselben Commit einarbeiten
  2. Für Commit 2/4/6 (höchstes Designrisiko) zusätzlich silent-failure-hunter + type-design-analyzer ausführen
  3. Nachdem alle 6 Commits eingespielt sind: 3 vollständige Review-Durchgänge durch verschiedene Agent-Kombinationen auf dem gesamten PR-Diff
  4. Vollständige Test-Suite + Typcheck + Lint über alle betroffenen Pakete ausführen

Spiegelt das spezialisierte Prereview-Muster von PR 21 wider.


20. Capability-Tags + SDK-Vertragsänderungen

20.1 Neue Capability-Tags (atomar in v0.16 beworben, V21-1)

Da F2 als ein PR ausgeliefert wird, werden alle drei Tags zusammen beworben. Pool-Konsumenten können davon ausgehen: mcp_workspace_pool beworben ⇒ Felder entryCount/entrySummary/scope? alle vorhanden; keine pro-Feld-Capability-Prüfung erforderlich.

TagWann beworbenBedeutung
mcp_workspace_poolWenn QwenAgent.mcpPool !== undefined (immer wahr im Daemon-Modus, außer --no-mcp-pool-Killswitch)GET /workspace/mcp spiegelt Pool-Zustand wider; Felder entryCount + entrySummary vorhanden
mcp_pool_restartImmer wenn mcp_workspace_pool aktiv istPOST /workspace/mcp/:server/restart akzeptiert ?entryIndex= und kann entries: RestartResult[] zurückgeben
(erweitert mcp_guardrails)unverändertGleicher Tag, Payload um scope erweitert ('workspace' unter F2)

20.2 SDK-additive Oberfläche

// @qwen-code/sdk — nur additiv export interface DaemonMcpBudgetWarningData { // bestehende Felder... scope?: 'workspace' | 'session'; // NEU — fehlt auf alten Daemons (bedeutet 'session') } export interface DaemonMcpChildRefusedBatchData { // bestehende Felder... scope?: 'workspace' | 'session'; } export interface ServeWorkspaceMcpServerStatus { // bestehende Felder... entryCount?: number; entrySummary?: Array<{ fingerprint: string; refs: number; status: MCPServerStatus; }>; } export function isWorkspaceScopedBudgetEvent( e: DaemonMcpGuardrailEvent, ): boolean;

EVENT_SCHEMA_VERSION bleibt bei 1 (additiv).


21. Testmatrix

21.1 Pool-Schlüssel (F2-2)

  • Gleiche Konfiguration → gleicher Schlüssel (Env-Key-Permutation stabil, Header-Key-Permutation stabil)
  • Env-Wert um 1 Byte unterschiedlich → anderer Schlüssel
  • Header Authorization-Wert unterschiedlich → anderer Schlüssel
  • includeTools/excludeTools/trust mutiert → GLEICHER Schlüssel (pro-Session-Filter)
  • Zwei new MCPServerConfig(...) mit identischem Inhalt → gleicher Schlüssel (kanonischer Hash, nicht Identität)

21.2 Lebenszyklus (F2-2)

  • 3 Sessions erwerben denselben Schlüssel → 1 Spawn (überprüfen via Spy auf client.connect)
  • Freigabesequenz n,n-1,…,1 → Drain-Timer startet nur bei 1→0
  • 30s Drain: Erwerb bei 25s bricht Timer ab; Erwerb bei 35s erzeugt neuen Eintrag
  • MAX_IDLE_MS (5 Min.) harter Schließen auch bei flappendem Drain
  • Spawn schlägt während „in flight” fehl: alle Wartenden erhalten Fehler; Slot freigegeben; kein Eintrag gespeichert

21.3 Gleichzeitiger Erwerb (F2-2)

  • 5 gleichzeitige acquire(sameKey) während kein Eintrag existiert → genau 1 spawnEntry-Aufruf, alle 5 erhalten denselben Eintrag
  • Spawn lehnt ab → alle 5 Wartenden erhalten denselben Fehler; nachfolgender Erwerb erneut spawn

21.4 Pro-Session-Isolation (F2-2)

  • Session A excludeTools: ['foo'], Session B keine Ausschließung → A’s ToolRegistry enthält foo nicht, B schon; beide aus derselben toolsSnapshot
  • Session A trust: true, Session B trust: false → Session A’s DiscoveredMCPTool.trust === true, B’s false; prüfen, dass KEINE gemeinsame Referenz vorliegt (Mutation einer beeinflusst nicht die andere)
  • Session A erwirbt Prompt-only-Server → A’s PromptRegistry befüllt, ToolRegistry für diesen Server leer

21.5 Tool-/Prompt-Liste geändert (F2-2)

  • Server sendet notifications/tools/list_changedapplyTools aller Abonnenten mit neuem Snapshot aufgerufen
  • Veralteter Handler aus vorheriger Wiederverbindungsgeneration überschreibt Snapshot NICHT
  • notifications/prompts/list_changed analog

21.6 Absturz + Wiederverbindung (F2-2)

  • Subprozess via process.kill töten → Abonnenten erhalten disconnected-Ereignis
  • 3 Wiederverbindungsversuche (unter Verwendung der bestehenden MCPHealthMonitorConfig) → Erfolg → reconnected + frischer Snapshot
  • Erschöpfte Wiederholungen → alle Abonnenten erhalten failed; Eintrag wechselt in failed-Zustand; neue Erwerbe versuchen es einmal erneut, werfen dann Fehler

21.7 Abstiegs-PID-Sweep (F2-2b)

  • Linux/macOS: bash -c "sleep 60 & sleep 60" als stdio-Befehl spawnen → Root töten → überprüfen, dass beide Nachkommen bereinigt sind (/proc/<pid>/status Poll, oder kill(0, pid) === false)
  • Windows: cmd /c "ping -t localhost" Wrapper spawnen → töten → überprüfen, dass ping-Subprozess weg
  • pgrep nicht verfügbar (PATH fehlt) → sanfte Degradierung: Warnung loggen, nur SIGTERM an Root, nicht abstürzen

21.8 Budget auf Workspace-Bereich (F2-4)

  • 4 Sessions × --mcp-client-budget=2 mit 3 statischen MCP-Servern → Workspace-Gesamtsumme = 3 (nicht 12); Snapshot-Zelle scope: 'workspace', liveCount: 3
  • Budget-Warnung feuert einmal pro 75%-Aufwärtsüberschreitung über den gesamten Workspace; sendet gleichzeitig an alle 4 Sessions
  • Hysterese-Wiederbewaffnung: Abfall auf 37,5% → nächste Überschreitung feuert erneut

21.9 Rückwärtskompatibilität (F2-3)

  • Eigenständiges qwen (kein Daemon) → mcpPool === undefined → alle bestehenden mcp-client-manager.test.ts-Tests bestehen unverändert
  • --no-mcp-pool-Daemon-Flag → fällt auf pro-Session zurück, alle bestehenden Daemon-E2E-Tests bestehen

21.10 Credential-Isolation (F2-3)

  • Session A injiziert {name: 'github', headers: {Authorization: 'Bearer tokenA'}}, Session B tokenB → 2 separate Prozesse; überprüfen durch Snapshot entryCount: 2; überprüfen, dass A’s Tool-Aufrufe durch A’s Transport gehen (durch Header-Inspektion in stdin/log)

21.11 LoadSession / Wiederaufnahme (F2-3)

  • Session schließt → Drain startet → Wiederaufnahme innerhalb von 30s → Pool-Eintrag wiederverwendet (kein Kaltstart, bestätigt via client.connect Spy-Zähler)
  • Wiederaufnahme nach 30s aber vor Ablauf des Restore-Window → Pool-Kaltstart; restoreState-Inhalt bleibt erhalten

21.12 Restart-Route (F2-3b)

  • 1 Eintrag für Name → POST /workspace/mcp/foo/restart gibt Legacy-Form {restarted: true, durationMs} zurück
  • 2 Einträge für Name (verschiedene Fingerprints) → gibt {entries: [{fingerprint, restarted, ...}, ...]} zurück
  • Neustart während ein anderer Neustart läuft → zweiter Aufruf gibt dasselbe Promise zurück (dedupliziert)
  • Neustart, wenn Budget überschritten würde → {restarted: false, skipped: true, reason: 'budget_would_exceed'} pro Eintrag

21.13 Status-Route (F2-3b)

  • Leerlauf-Daemon (keine Sessions), aber Pool hat zwischengespeicherte Einträge von vorheriger Session → GET /workspace/mcp gibt initialized: true mit Live-Erfassung zurück
  • Bootstrap-Session existiert nicht → Fallback auf Pool-direkten Pfad; kein Fehler
  • Pool-Abfrage schlägt fehl → Fallback auf Bootstrap-Session-Pfad; Snapshot stürzt nie ab

21.14 SDK-Reducer (F2-4)

  • mcpBudgetWarningCount erhöht sich gleichzeitig bei allen abonnierenden Sessions, wenn Workspace-Ereignis gesendet wird
  • isWorkspaceScopedBudgetEvent(e) identifiziert Bereich korrekt aus Payload
  • Alter Daemon (kein scope-Feld) → standardmäßig auf ‘session’-Interpretation

21.15 Hot Config Reload (F2-3)

  • Während des Betriebs Änderung an settings.json → alte Session behält alten Eintrag, neue Session erstellt neuen Eintrag, beide koexistieren; alter drainet natürlich, wenn letzte alte Session schließt
  • 0 Sessions nachdem alte Session geschlossen hat → Drain-Timer feuert → alter Eintrag GC’d → nur neuer Eintrag bleibt

21.16 Abschalt-Reihenfolge (F2-3)

  • QwenAgent.close() löst in Reihenfolge aus: Annehmen stoppen → Eingabeaufforderungen drainen → Sessions schließen → pool.drainAll → keine Zombie-PIDs in pgrep -P <acpChildPid> nach Beenden

22. Offene Fragen

V21 hat Q1/Q3/Q4/Q6 in den Designvorgaben festgelegt (Einzel-PR-Auslieferung). Q2/Q5/Q7/Q8/Q9 bleiben offen.

#FrageF2-DesignvorgabeEntscheidung erforderlich vor
Q1 ✅SDK-Reducer-Feldnamen – umbenennen oder beibehalten?FESTGELEGT v2.1: mcpBudgetWarningCount etc. mit erweiterter Scope-Semantik im JSDoc beibehaltenerledigt
Q2mcp_workspace_pool-Fähigkeit – protocolVersions erhöhen (‘v1’ → ‘v1.1’) oder additiv bei ‘v1’ bleiben?Additiv bei ‘v1’ bleiben (konsistent mit PR-14b-Präzedenzfall)Commit 5
Q3 ✅--no-mcp-pool-Flag – standardmäßig aktiv oder opt-in?FESTGELEGT v2.1: standardmäßig aktiv; --no-mcp-pool ist Kill-Schaltererledigt
Q4 ✅HTTP/SSE-Standard – Pool aus oder an?FESTGELEGT v2.1: Pool aus; opt-in über --mcp-pool-transportserledigt
Q5POST /workspace/mcp/reload-all – in F2 aufnehmen oder als Folge?Als Folgen/a (zurückgestellt)
Q6 ✅Lazy Pool-Konstruktion – lohnt sich die Bedingung?FESTGELEGT v2.1: eager (immer im QwenAgent-Konstruktor konstruieren)erledigt
Q7restoreState-Fenster vs. Pool-Drain – getrennt lassen, angleichen oder aus den Einstellungen lesen?Getrennt mit 30s Standard + Konfigurationsknopf --mcp-pool-drain-msCommit 4
Q8OAuth-Handling – F3-Verschiebung bestätigen, Workaround dokumentieren?Auf F3 verschoben, Workaround /mcp auth <name> dokumentierenCommit 4
Q9entrySummary-Offenlegung – immer einbinden oder hinter einem verbose-Flag?Immer einbinden (kleines Payload, nützlich für Betrieb)Commit 5
Q10codeagents/qwen-code-daemon-design/02-architectural-decisions.md Entscheidung #3 – mit @wenshao abstimmen?F2-PR-Beschreibung verlinkt codeagents-PR; zwei PRs werden unabhängig reviewedPR offen

23. Risiken

Hoch

  • R1 (A2 globaler Zustand): Kollision von serverStatuses bei mehreren Einträgen mit gleichem Namen. Abgemildert durch Aggregat-Status-Funktion; Restrisiko: SDK-Konsumenten lesen die rohe globale Map (unwahrscheinlich – nur über Accessor getMCPServerStatus(name) genutzt).
  • R2 (PromptRegistry-Symmetrie): Auslassen des Prompt-Fan-Outs in einem Codepfad lässt stumm Prompts verschwinden. Abgemildert durch F2-2-Test 21.4 dritter Aufzählungspunkt + Integrationstest, der Prompt-Parität gegenüber vor F2 bestätigt.
  • R3 (HTTP-Transport-Zustandsübertragung): Bei opt-in HTTP-Pool für einen Server, der pro-Transport-Zustand verwaltet, werden Sitzungskontexte beschädigt. Abgemildert durch Standard-aus + Dokumentation; automatische Erkennung nicht möglich.

Mittel

  • R4 (Pfadvereinheitlichung F2-1): Fabrikmethode connectToMcpServer und Klasse McpClient haben subtile Verhaltensunterschiede (z.B. Fähigkeiten, die zum Konstruktionszeitpunkt vs. Verbindungszeitpunkt angekündigt werden). Abgemildert dadurch, dass F2-1 ein reiner Refactor-PR mit vollständiger Regressionsabdeckung vor Beginn der Pool-Arbeit ist.
  • R5 (Windows-Child-PID): PowerShell Get-CimInstance kann langsam sein (Spawn-Kosten) oder durch AppLocker blockiert werden. Abgemildert durch 2s Timeout + Graceful Degradation.
  • R6 (Pool-Event-Broadcast-Verstärkung): Fan-Out einer Budget-Warnung an 100 Sitzungen führt zu 100 extNotification-Aufrufen in enger Schleife. Abgemildert durch Promise.all-Parallelisierung + pro-Sitzung-catch (bestehendes PR-14b-Muster).

Niedrig

  • R7 (Fingerabdruck-Stabilität über MCPServerConfig-Versionen): Zukünftige Felder in MCPServerConfig, die nicht im Fingerabdruck enthalten sind, würden stillschweigend falsches Sharing erlauben. Abgemildert durch explizite Kanonikalisierungsfunktion + Test, der alle MCPServerConfig-Felder aufzählt und Abdeckung bestätigt.
  • R8 (Generationenzähler-Rennbedingungen): Schnelle Neustart-Zyklen könnten die JS-Zahlenpräzision erschöpfen (≈ 2^53 = ~285k Jahre bei 1/Sek.). Kein praktisches Problem.

Einzel-PR-spezifisch (V21-14)

  • R9 (Review-Ermüdung bei ~6000 LOC Einzel-PR): Reviewer-Bandbreite wird zum Engpass. F3 ist blockiert bis F2 gemerged → blockiert andere Mitwirkende. Abmilderung: (a) Pre-Review mit 3 Spezialagenten und Zusammenlegen von P0/P1 vor Öffnung, analog zum Muster von PR 21; (b) Strukturierung in 6 atomare Commits, damit der Reviewer schrittweise vorgehen kann; (c) Koordination des Review-Fensters mit @wenshao im Voraus via #4175-Kommentar.
  • R10 (daemon_mode_b_main-Mergekonflikt-Akkumulation): F2 berührt acpAgent.ts, httpAcpBridge.ts, capabilities.ts, mcp-client*.ts – alles Hot Paths. F3/F4-Mitwirkende, die gleichzeitig landen, riskieren Konflikte während des 1–2-wöchigen Review-Fensters von F2. Abmilderung: tägliches git rebase origin/daemon_mode_b_main; Koordination via #4175-Update, dass F2 in Bearbeitung ist + Bitte an F3/F4, Hot-File-Änderungen bis zum F2-Merge zurückzustellen.
  • R11 (CI-Ausführungszeit): ~1900 LOC neue Tests inklusive Subprozess-Spawn + plattformübergreifender PID-Sweep könnten CI von 30min auf 50min erhöhen. Abmilderung: (a) Subprozess-Tests hinter process.env.QWEN_INTEGRATION === '1' verbergen, Teilmenge in PR-CI + vollständiger Satz in nächtlichen CI; (b) Vitest-Parallelität ≥ 4; (c) Windows-PID-Sweep-Tests überspringen, nur auf GHA-Windows-Runner.

24. Dokumentationsaktualisierungen

DokumentAktualisierungZeitpunkt
codeagents/qwen-code-daemon-design/02-architectural-decisions.mdEntscheidung #3 „MCP-Server-Lebensdauer”: aktuell „pro Sitzung”; aktualisieren auf „im Workspace-Pool mit Config-Hash-Key unter Daemon-Modus; pro Sitzung eigenständig”F2-3 merged (Koordination mit @wenshao codeagents-PR)
codeagents/qwen-code-daemon-design/06-roadmap.mdWave 5 PR 23 → als F2-Serie markieren; zu PRs verlinkenF2-3 merged
packages/cli/src/serve/README.md (falls vorhanden) oder neues docs/serve/mcp-pool.mdNeuer Abschnitt: Pool-Semantik, Fingerabdruck-Schlüssel, Transport-Opt-in, Neustartsemantik, Status-Snapshot-InterpretationF2-3b
packages/sdk/README.mdFeld scope? bei Guardrail-Events, entryCount bei Server-Status, Hilfsfunktion isWorkspaceScopedBudgetEventF2-4
Issue #4175 BodyF2-Eintrag mit Sub-PR-Tabelle aktualisieren, Link zu Design v2 (dieses Dokument)Vor F2-1-Öffnung
Issue #3803 BodyZeile Entscheidung #3: „Aktuell pro Sitzung” → „Workspace-Pooled unter Daemon (F2)“Nach F2-3-Merge
acpAgent.ts:869-936 Inline-KommentarVorwärtsreferenz „Wave 5 PR 23” entfernen; aktualisieren auf „durch F2 zu scope: 'workspace' hochgestuft”F2-4-PR
CHANGELOG / Release Notes (Wave 6 / F5)Schlagzeile: „MCP-Prozesse werden jetzt über Sitzungen in einem Workspace geteilt”F5-Release

25. PR-Beschreibungsvorlage (Einzel-PR-Auslieferung)

## feat(serve): shared MCP transport pool (workspace-scoped) [F2] Single feature-cohesive PR per #4175 branching strategy (2026-05-19). Replaces what was originally planned as Wave 5 PR 23 + sub-PRs F2-1..F2-4. ### Scope ~4100 LOC production + ~1900 LOC tests across 6 atomic commits. Step through with `git log -p HEAD~6..HEAD` for commit-by-commit review. ### Design doc See `docs/design/f2-mcp-transport-pool.md` (v2.1). ### Pre-review specialist agents (per PR 21 pattern) Folded into first commit before opening: - code-reviewer: N findings, all adopted - silent-failure-hunter: N findings, all adopted - type-design-analyzer: N findings, all adopted ### Closes (none — F2 entry in #4175 stays open until PR merges into main batch) ### Related - #3803 decision #3 update (codeagents PR <link>) - PR 14b (#4271 merged) — budget guardrail base; F2 graduates scope to workspace - F1 (#4319 merged) — acp-bridge package; F2 depends on injection seams ### Backward compatibility - Standalone `qwen` (non-daemon): pool not constructed; existing behavior preserved - Daemon `qwen serve --no-mcp-pool`: kill switch falls back to per-session - SDK: all new fields additive (`entryCount`, `scope?`); EVENT_SCHEMA_VERSION stays at 1 - Old SDK clients: unknown `scope: 'workspace'` ignored per PR 14 contract - Old daemons: SDK consumers can detect absence of `mcp_workspace_pool` capability and fall back ### Test plan - [ ] Pool key: env permutation stability, header divergence, per-session filter exclusion - [ ] Lifecycle: 3-session sharing, drain grace, concurrent acquire dedupe, spawn failure slot release - [ ] Tools + Prompts dual fan-out, per-session trust copy, snapshot replay on attach - [ ] Generation guard: pre-reconnect handler doesn't overwrite post-reconnect snapshot - [ ] Crash + reconnect with stdio backoff (5s × 3) and HTTP backoff (1/2/4/8/16s × 5) - [ ] Descendant pid sweep: Linux/macOS pgrep recursion, Windows PowerShell CIM - [ ] Budget at workspace scope: 4 sessions × budget=2 → 3 max (not 12); fan-out to all attached - [ ] LoadSession resume within drain window: pool entry reused, no cold start - [ ] Hot config reload: old/new entries coexist; old drains naturally - [ ] Restart route: `?entryIndex=` selectivity; legacy single-entry response shape preserved - [ ] In-flight tool call during reconnect: `MCPCallInterruptedError` rejection - [ ] Standalone qwen: all existing mcp-client-manager tests pass unchanged

Zusammenfassung

F2 v2.1 = einzelner PR mit 6 atomaren Commits (~6000 LOC), Ziel daemon_mode_b_main. Zentrale Designpfeiler:

  1. McpTransportPool in packages/core (ACP-Child-Seite), workspace-bezogen, Referenzzählung + 30s Drain
  2. Fingerabdruck-Schlüssel SHA-256 über kanonische Konfiguration inklusive Umgebungsvariablen/Header (claude-code-Muster), ohne pro-Sitzung-Filter (includeTools/trust)
  3. SessionMcpView pro-Sitzung Tool+Prompt-Registry-Projektion mit Trust-Kopie
  4. Snapshot-Reply + Generationenschutz für Attach-Race-Zustand und veraltete Benachrichtigungen
  5. Plattformübergreifender Descendant-PID-Sweep (opencode-Muster + Windows-Port)
  6. HTTP/SSE-Opt-in, SDK-MCP-Umgehung, OAuth auf F3 verschoben
  7. Budget-Statusmaschine wird auf Workspace-Scope hochgestuft; Snapshot-Zelle + Push-Events werden additiv erweitert (scope?)
  8. Status + Neustart-Routen umgestaltet: Pool-first mit Bootstrap-Sitzung-Fallback; entryCount + RestartResult[]

Offene Fragen Q1–Q10 in §22 benötigen Maintainer-Entscheidungen, bevor die entsprechenden Sub-PRs geöffnet werden. Empfehlung: Q1–Q4 vor F2-3-Beginn zu klären (diese geben die grobe Richtung vor); Q5–Q10 können schrittweise gelöst werden.

Last updated on