From 8a09ae301716a806ed024a9d37565a31d6c0f0cb Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 5 Nov 2026 10:20:30 +1300 Subject: [PATCH 1/2] fix(extensions): expose actionable connector connection diagnostics --- agent_host/adapters/pi.py | 4 +- agent_host/mcp_connections.py | 10 +- .../gui-runtime-0.0.8-live-2026-11-05.md | 107 ++++++++++++++++++ .../renderer/components/ExtensionSettings.tsx | 32 +++++- .../src/renderer/components/SettingsPage.tsx | 18 ++- electron/src/renderer/locales/zh-CN.json | 4 + electron/tests/extensionSettings.smoke.mjs | 21 ++++ electron/tests/fixtures/extensionSettings.tsx | 24 +++- server/handlers/mcp_connection_handler.py | 19 +++- tests/test_mcp_connections.py | 66 +++++++++++ tests/test_pi_provider.py | 6 +- 11 files changed, 296 insertions(+), 15 deletions(-) create mode 100644 docs/verification/extensions/gui-runtime-0.0.8-live-2026-11-05.md diff --git a/agent_host/adapters/pi.py b/agent_host/adapters/pi.py index 3bf10726..4e05fa2d 100644 --- a/agent_host/adapters/pi.py +++ b/agent_host/adapters/pi.py @@ -393,7 +393,9 @@ async def _event(self, run_id, run, event): elif event.get("method") == "notify" and str(event.get("message") or "").strip(): notice = redact_mcp_text(str(event["message"]), self.mcp_connections) notice = notice.replace("Run /mcp to fix.", - "Review connections in Settings -> Providers -> MCP, then restart the backend.") + "Review installed package connections in Plugins, or manual connections in " + "Settings -> Providers -> MCP. Use Test connection to diagnose the service, " + "then restart the backend after changing saved connection settings.") await self._emit(run_id, run, "runtime.notice", { "message": notice, "level": str(event.get("notifyType") or "info"), "source": "pi_native_notification"}) diff --git a/agent_host/mcp_connections.py b/agent_host/mcp_connections.py index fc718839..e8bff433 100644 --- a/agent_host/mcp_connections.py +++ b/agent_host/mcp_connections.py @@ -173,6 +173,7 @@ def public_dict(self) -> dict[str, Any]: "bearer_token_env_var": self.bearer_token_env_var, "environment_keys": sorted(self.environment), "main_chat_access": False, + **({"extension_package_id": self.extension_package_id} if self.extension_package_id else {}), } @@ -361,7 +362,14 @@ async def open_mcp_connection( if not token: raise RuntimeError("configured bearer token is unavailable") headers["Authorization"] = f"Bearer {token}" - async with AsyncClient(headers=headers) as http_client: + async def preserve_authentication_error(response): + # The SDK maps non-JSON-RPC failures to a generic protocol error. Keep + # HTTP authentication facts at the transport boundary; negotiation's + # other error statuses must still reach the native MCP client. + if response.status_code in (401, 403): + response.raise_for_status() + + async with AsyncClient(headers=headers, event_hooks={"response": [preserve_authentication_error]}) as http_client: transport = streamable_http_client(connection.url, http_client=http_client) async with Client(transport, raise_exceptions=False) as client: yield client diff --git a/docs/verification/extensions/gui-runtime-0.0.8-live-2026-11-05.md b/docs/verification/extensions/gui-runtime-0.0.8-live-2026-11-05.md new file mode 100644 index 00000000..3e05e09e --- /dev/null +++ b/docs/verification/extensions/gui-runtime-0.0.8-live-2026-11-05.md @@ -0,0 +1,107 @@ +# Windows GUI Runtime Connector 0.0.8 independent live acceptance + +The bounded installed-Pi GUI journey passed with connector **0.0.8** and Runtime +**0.1.11** on 2026-11-05 (Pacific/Auckland). This closes the outstanding four-step +GUI, original-image review, same-session observe/continue and maintenance-settlement +checks. It is evidence for the exact combination, not catalog approval or publication. + +Host connection diagnostics were tested with the local fixes accompanying this +record, based on `ffb1037b6a4cc8bcbf768d51111b108361952cb0`. Those fixes must be +reviewed and integrated before describing the diagnostic experience as shipped. + +## Tested bytes and environment + +- Submission: [Amadeus-Extensions #11](https://github.com/Code-Amadeus/Amadeus-Extensions/pull/11), + head `8705a3f04e602d145d74782c310f6866cbc20f24`, still open when checked. +- Wheel: `amadeus_agent_gui_runtime-0.0.8-py3-none-any.whl`, 27,990 bytes, + SHA-256 `2f532faf152faf2e1e3ab3140e1097487689be34d638e4ee86539e06886cbcaf`. +- Runtime: the existing installation was reused at unmodified tracked source + `323099100b04beeb6ee67a846f0e530998c19bbc`; no second installation was created. +- Windows, isolated Host profile and test directories, software-rendered Electron. + Pi used the configured `openai-codex/gpt-6-luna` subscription. Runtime grounding + and decision calls used the configured external Luna API; those are a separate, + potentially billable connection. No provider or input gate was bypassed. +- The previously completed 2026-10-29 distribution/static and HTTP-owner lifecycle + review remains supporting evidence at its original scope. This run does not + reclassify those checks as GUI evidence or qualify arbitrary Runtime versions. + +## Observed live results + +| Check | Observed result | +| --- | --- | +| Four-step GUI task | Documents → Reports → Up once → Receipts completed in one plan, with four original navigation executions. The original final image shows Receipts and its marker file. | +| Original-image review | The first step deliberately required Main's post-action judgment. Pi retrieved the bound original after image, made one read-only `observe`, reviewed the current evidence packet and continued the original plan once. No input replay or recovery tool was used. | +| Evidence integrity | All 28 file references in the four-step history and all four original after-image digests matched retained bytes. Both fixture marker hashes and the file inventory were unchanged. | +| Operator-owned window | The original 7-Zip window and process remained open after Runtime cleanup, at Receipts. A restricted process lookup initially hid it; native window inspection and an unrestricted process-identity check confirmed preservation. | +| Session cleanup | The session report records `phase=stopped`, `host_phase=stopped`, no cleanup errors and a stopped sampler. Pi's final status reported no pending requests and verified cleanup. | +| Official maintenance settle | The unmodified official updater returned `settled=true`, `registered_sessions=5`, `cleared_sessions=5`. After the remaining diagnostic/install lifecycle, a second check returned 0/0. Coverage is registered sessions only; legacy sessions were not proven. | +| Missing service | The real Plugins → Test connection action displayed the service/URL/port diagnosis. A fresh Pi Work launched through the actual chat UI showed a native `fetch failed` warning with the Plugins / Settings → Providers → MCP and Test connection directions. | +| Wrong token | The real Plugins connection test displayed HTTP 401 and a token configuration hint. The earlier independent Pi attempt exposed native authentication-required failure; the final warning wording and credential redaction also passed regression tests. | +| Disable while service runs | Native disable removed the projected connection after Host restart; the separately running Runtime retained the same process identity. | +| Uninstall/reinstall | After the Host/backend stopped, the real installer removed the exact connector with `deleteData=false`, then installed/enabled the same wheel again using native secret storage. The independent Runtime remained running across removal. | + +The first verification used a supplied, valid four-step v3 plan and a deliberate +manual post-action wait. The other three fixture conditions used the documented +decision-profile configuration. This proves the review/observe/continue contract; +it is not a sample of spontaneous double-rejection frequency or general GUI accuracy. + +Earlier unsuccessful attempts are retained: screen occlusion, a Computer Use cursor +overlay, interrupted test processes, and an incorrect test-plan review setting. +The latter was corrected in the test input, not Runtime code. The successful run +is not presented as an error-free first attempt. Per-tool approvals were resolved +through the Host's normal permission API; status alone received a Work-scoped grant. + +The execution host completed its native cleanup. The external HTTP daemon was +then terminated by the test controller, with exit code 1; this is not evidence of +graceful daemon shutdown. The isolated Electron Host later closed normally with +exit code 0. Test processes were stopped; the operator's 7-Zip window was retained. + +## Host repairs and validation + +The installed-plugin card now tests its actual Host-issued MCP connection ID. +The existing public connection projection includes its package owner so the UI +does not reconstruct IDs. HTTP authentication failures retain their transport +status through the MCP client; 401, 403 and unreachable-service diagnoses give +actionable, credential-free guidance. Other statuses retain native MCP negotiation. +Pending configuration cannot probe an obsolete connection. A cancelled settings +refresh cannot leave a stale reconnecting error after a successful restart. + +- `python -m pytest -q tests/test_mcp_connections.py tests/test_extension_mcp.py tests/test_pi_provider.py`: + **45 passed**. Includes native HTTP negotiation, authentication/unreachable + diagnostics, projection isolation and native Pi notification redaction. +- `npm --prefix electron run build`: passed. +- `electron tests/extensionSettings.smoke.mjs --disable-gpu`: **14 checks passed**, + including connection results, unapplied settings and stale-refresh recovery. +- Ruff on the five changed Python files and `git diff --check`: passed. +- Full repository CI remains a requirement for merging the Host changes; this + record does not substitute the targeted checks for that CI. + +Raw evidence remains local because it includes screenshots, process metadata and +test-profile details. The ignored audit directory +`.amadeus-test/gui-runtime-008-live-20261105/` retains `final-gui-evidence.json`, +`official-settle-final.json`, `official-settle-post-lifecycle.json`, the original +Runtime receipts/images, UI text observations and native installer receipts. +The GUI evidence collector verifies hashes without executing the package. + +## Remaining publication gates + +No new connector byte defect was established by this run; these results do not +justify requesting another connector version. The next owner is the maintainer: + +1. Integrate the tested Host diagnostic fixes and complete their required CI. +2. Complete the full curated-v1 artifact/ownership/rights review record, then add + the exact 0.0.8 `catalog/packages/` input to the same submission PR. +3. Obtain eligible non-author approval on the final head before merging inputs. +4. Resolve the repository-governance and release gates, then use a separate index + publication PR and verify the official served bytes. + +Candidate-only admission still does not establish approval. This execution added +no catalog approval, changed no repository protections and published no index. + +## 中文结论 + +0.0.8 + 0.1.11 的四步 GUI、原图审核、observe/continue、窗口保留、文件不变、 +收尾和官方 settle 已通过独立实机验收;禁用、卸载和重装也确认了外部 Runtime +由用户独立管理的边界。连接诊断在带本轮修复的 Host 上通过,不能提前称为已发布功能。 +不需要因这轮结果要求作者再发一个版本。正式收录、最终非作者批准、治理检查和独立 +发布 PR 仍须完成,实机通过不等于已经上架。 diff --git a/electron/src/renderer/components/ExtensionSettings.tsx b/electron/src/renderer/components/ExtensionSettings.tsx index 1fd9f55a..827df410 100644 --- a/electron/src/renderer/components/ExtensionSettings.tsx +++ b/electron/src/renderer/components/ExtensionSettings.tsx @@ -17,6 +17,8 @@ import { compatibleExtensionProviders, extensionDraft, extensionLanguageSupporte const ATTENTION_STATES = new Set(['error', 'degraded', 'needs_config', 'incompatible']) interface Props { + mcpConnections: Array<{ id: string; name: string; extension_package_id?: string }> + send: (method: string, params?: Record) => Promise> platform?: string extensions: ExtensionSummary[] runtime: ExtensionRuntimeStatus[] @@ -34,7 +36,7 @@ interface Props { onEditorStateChange?: (state: SettingsEditorState) => void } -export default function ExtensionSettings({ platform, extensions, runtime, providers, connected, available, encryptionAvailable, selectedVoiceBackend, selectedTheme, appliedThemeVersion, onOpenAppearance, +export default function ExtensionSettings({ platform, extensions, runtime, mcpConnections, send, providers, connected, available, encryptionAvailable, selectedVoiceBackend, selectedTheme, appliedThemeVersion, onOpenAppearance, onSettingsChanged, onRestartRequired, onOpenVoice, onEditorStateChange }: Props) { const { locale, t } = useI18n() const scheduledUpdates = usesScheduledExtensionUpdates(platform) @@ -441,6 +443,9 @@ export default function ExtensionSettings({ platform, extensions, runtime, provi )} + {connected && !pending ? mcpConnections.filter(connection => connection.extension_package_id === extension.id).map(connection => + + ) : null}
{t('Plugin details')} @@ -495,3 +500,28 @@ export default function ExtensionSettings({ platform, extensions, runtime, provi
} + +function ExtensionConnectionTest({ connection, send, disabled }: { + connection: { id: string; name: string }; send: Props['send']; disabled: boolean +}) { + const { t } = useI18n() + const [running, setRunning] = useState(false) + const [result, setResult] = useState<{ message: string; ok: boolean } | null>(null) + const test = async () => { + setRunning(true); setResult(null) + try { + const response = await send('mcp.connection.test', { connection_id: connection.id }) + setResult({ ok: response.status === 'connected', message: response.status === 'connected' + ? t('Connected · {count} tools discovered', { count: Number(response.tool_count || 0) }) + : String(response.detail || response.code || 'Connection failed').split('\n').map(line => t(line)).join('\n') }) + } catch (reason) { + setResult({ ok: false, message: reason instanceof Error ? reason.message : t('Connection failed') }) + } finally { setRunning(false) } + } + return
+
{connection.name} + void test()}>{t(running ? 'Connecting…' : 'Test connection')} +
+ {result ?
{result.message}
: null} +
+} diff --git a/electron/src/renderer/components/SettingsPage.tsx b/electron/src/renderer/components/SettingsPage.tsx index ad6feb2e..c26e811a 100644 --- a/electron/src/renderer/components/SettingsPage.tsx +++ b/electron/src/renderer/components/SettingsPage.tsx @@ -676,6 +676,7 @@ export default function SettingsPage({ send, subscribe, connected, reconnectBack const [acpAgents, setAcpAgents] = useState('[]') const [acpConfigurations, setAcpConfigurations] = useState([]) const [capabilityPackages, setCapabilityPackages] = useState([]) + const [runtimeMcpConnections, setRuntimeMcpConnections] = useState>([]) const [desktop, setDesktop] = useState(null) const [companionPortraits, setCompanionPortraits] = useState(null) const [saving, setSaving] = useState(null) @@ -701,10 +702,11 @@ export default function SettingsPage({ send, subscribe, connected, reconnectBack }, []) const refreshBackend = useCallback(async () => { - const [configResponse, providerResponse, capabilityResponse] = await Promise.all([ + const [configResponse, providerResponse, capabilityResponse, mcpResponse] = await Promise.all([ send('system.get_config', {}), send('provider.list', {}), send('capability.list', { include_disabled: true }), + send('mcp.connection.list', {}), ]) setConfig((configResponse.values as Record) ?? configResponse) setAcpAgents(JSON.stringify(providerResponse.acp_agents || [])) @@ -713,18 +715,21 @@ export default function SettingsPage({ send, subscribe, connected, reconnectBack setProviderManifests(Array.isArray(providerResponse.provider_manifests) ? providerResponse.provider_manifests as unknown as ProviderManifest[] : []) setProviderRoleCandidates(providerResponse.role_candidates as Record | undefined) setCapabilityPackages(Array.isArray(capabilityResponse.packages) ? capabilityResponse.packages as unknown as CapabilityPackage[] : []) + setRuntimeMcpConnections(Array.isArray(mcpResponse.connections) ? mcpResponse.connections as Array<{ id: string; name: string; extension_package_id?: string }> : []) }, [send]) useEffect(() => { + let active = true + if (restarting) return undefined void Promise.all([refreshDesktop(), refreshCompanionPortraits()]) - .catch(reason => setError(reason instanceof Error ? reason.message : 'Could not load desktop settings')) - if (!connected) return undefined + .catch(reason => { if (active) setError(reason instanceof Error ? reason.message : 'Could not load desktop settings') }) + if (!connected) { setRuntimeMcpConnections([]); return () => { active = false } } const unsubscribe = subscribe('system.config', payload => { setConfig((payload.values as Record) ?? payload) }) - void refreshBackend().catch(reason => setError(reason instanceof Error ? reason.message : 'Could not load runtime settings')) - return unsubscribe - }, [connected, subscribe, refreshBackend, refreshDesktop, refreshCompanionPortraits]) + void refreshBackend().catch(reason => { if (active) setError(reason instanceof Error ? reason.message : 'Could not load runtime settings') }) + return () => { active = false; unsubscribe() } + }, [connected, restarting, subscribe, refreshBackend, refreshDesktop, refreshCompanionPortraits]) useEffect(() => { setRestartPending(Boolean(desktop?.restartRequired)) @@ -1313,6 +1318,7 @@ export default function SettingsPage({ send, subscribe, connected, reconnectBack {section === 'extensions' ? <> {(desktop?.extensionErrors || []).map((detail, index) =>
{t(detail)}
)} presentation === 'plugins' ? onOpenSettings?.('general') : setSection('general')} diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index aa1bd3d4..98918930 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -314,6 +314,10 @@ "Connected · {count} tools discovered": "已连接 · 发现 {count} 个工具", "Connecting to game…": "正在连接游戏…", "Connecting…": "正在连接…", + "Test connection": "测试连接", + "Authentication failed (HTTP 401). Check the connection token in the extension or MCP settings.": "身份验证失败(HTTP 401)。请检查扩展或 MCP 设置中的连接令牌。", + "Access denied (HTTP 403). Check the server access policy and connection credentials.": "访问被拒绝(HTTP 403)。请检查服务端的访问策略和连接凭据。", + "Could not reach the MCP server. Check that it is running and that the URL and port are correct.": "无法连接 MCP 服务。请确认服务正在运行,并检查地址与端口是否正确。", "Connection details": "连接设置", "Connection details and diagnostics": "连接详情与诊断", "Connection failed": "连接失败", diff --git a/electron/tests/extensionSettings.smoke.mjs b/electron/tests/extensionSettings.smoke.mjs index 4d90ddb3..7d2f0790 100644 --- a/electron/tests/extensionSettings.smoke.mjs +++ b/electron/tests/extensionSettings.smoke.mjs @@ -113,8 +113,22 @@ try { await ui("document.querySelector('[id=\"extension-enabled-org.example.skill\"]').click()") await until("window.extensionFixture.snapshot().extensions.find(item=>item.id==='org.example.skill').enabled", 'Card switch did not enable the Skill package') report.checks.push('The package card switch enables a package without reopening its editor') + await ui("document.querySelector('[id=\"extension-enabled-org.example.tools\"]').click()") + await until("window.extensionFixture.snapshot().extensions.find(item=>item.id==='org.example.tools').enabled", 'MCP package did not enable') + assert.equal(await ui("document.body.textContent.includes('Test connection')"), false, 'Pending configuration cannot probe an old connection') await click('Restart backend to apply') await until("window.extensionFixture.snapshot().restartRequired === false", 'Synthetic restart did not apply snapshot') + await until("document.body.textContent.includes('Test connection')", 'Loaded extension MCP connection lacks a test action') + await click('Test connection') + await until("document.body.textContent.includes('Connected · 9 tools discovered')", 'Connection success was not displayed') + assert.deepEqual(await ui("window.extensionFixture.calls.find(call=>call.method==='mcp.connection.test').params"), { connection_id: 'loaded-extension-connection' }) + for (const detail of ['Authentication failed (HTTP 401). Check the connection token in the extension or MCP settings.', 'Could not reach the MCP server. Check that it is running and that the URL and port are correct.']) { + await ui(`window.extensionFixture.setProbeResponse({status:'error', detail:${JSON.stringify(detail)}})`) + await click('Test connection') + await until(`document.body.textContent.includes(${JSON.stringify(detail)})`, 'Connection diagnosis was not displayed') + assert.equal(await ui("document.body.textContent.includes('Connected · 9 tools discovered')"), false) + } + report.checks.push('Loaded extension connections probe their Host-issued ID and show success, authentication and unreachable diagnostics; pending configuration cannot be tested') await click('Open voice settings') await until("[...document.querySelectorAll('select')].some(node => [...node.options].some(option=>option.value==='org.example.voice/speech'))", 'Shared voice selector did not reappear') await ui("(() => { const selector=[...document.querySelectorAll('select')].find(node=>[...node.options].some(option=>option.value==='org.example.voice/speech')); selector.value='org.example.voice/speech'; selector.dispatchEvent(new Event('change',{bubbles:true})); })()") @@ -184,6 +198,13 @@ try { await capture('security-recovery-and-mirror.png'); report.screenshots.push('security-recovery-and-mirror.png') assert.deepEqual(await ui('window.extensionFixture.errors'), []) report.checks.push('Damaged security has an explicit official recovery action and the manager exposes the mirror setting') + await window.loadURL(url + '?stale-refresh') + await until("document.body.textContent.includes('Restart backend to apply')", 'Pending backend settings did not render during delayed refresh') + await click('Restart backend to apply') + await until("document.body.textContent.includes('Backend restarted and saved settings are now active.')", 'Restart did not confirm the new backend state') + assert.equal(await ui("[...document.querySelectorAll('[role=alert]')].some(node => node.textContent.includes('reconnecting'))"), false) + assert.deepEqual(await ui('window.extensionFixture.errors'), []) + report.checks.push('A cancelled old backend refresh cannot leave a reconnecting error after a successful deliberate restart') report.passed = true console.log(JSON.stringify(report, null, 2)) } catch (error) { diff --git a/electron/tests/fixtures/extensionSettings.tsx b/electron/tests/fixtures/extensionSettings.tsx index 01c73610..c98eff19 100644 --- a/electron/tests/fixtures/extensionSettings.tsx +++ b/electron/tests/fixtures/extensionSettings.tsx @@ -71,6 +71,10 @@ let runtime = desktop.extensions.map(extension => ({ id: extension.id, version: })) let failSave = false let failCatalog = false +let probeResponse: Record = { status: 'connected', tool_count: 9 } +let holdInitialRefresh = query.has('stale-refresh') +let rejectInitialRefresh: ((error: Error) => void) | undefined +if (holdInitialRefresh) desktop.restartRequired = true if (palette) { desktop.values.AMADEUS_UI_THEME = `${themeManifest.id}/${palette.id}` desktop.extensions.push({ id: themeManifest.id, version: themeManifest.version, revision: 2, appliedRevision: 2, appliedVersion: themeManifest.version, @@ -84,8 +88,10 @@ const pending = () => { desktop.restartRequired = true; desktop.pendingKeys = [' const subscribe = (method: string, listener: (payload: Record) => void) => { (listeners[method] ||= new Set()).add(listener); return () => { listeners[method].delete(listener) } } -const send = async (method: string): Promise> => { - calls.push({ method, params: {} }) +const send = async (method: string, params: Record = {}): Promise> => { + calls.push({ method, params }) + if (method === 'mcp.connection.list') return { connections: runtime.filter(item => item.id === tools.id && item.enabled).map(item => ({ id: 'loaded-extension-connection', name: 'Example MCP service', extension_package_id: item.id })) } + if (method === 'mcp.connection.test') return probeResponse if (method === 'system.get_config') return { values: config() } if (method === 'provider.list') return { provider_availability: [], provider_manifests: [ { provider_id: 'codex', display_name: 'Codex', runtime_kind: 'native', capabilities: { capability_projections: ['mcp_connection', 'agent_skill'] } }, @@ -93,7 +99,13 @@ const send = async (method: string): Promise> => { { provider_id: 'pi', display_name: 'Pi', runtime_kind: 'native', capabilities: { capability_projections: ['agent_skill'] } }, { provider_id: 'unsupported', display_name: 'Incompatible agent', runtime_kind: 'native', capabilities: { capability_projections: [] } }, ], acp_agents: [], provider_configurations: [] } - if (method === 'capability.list') return { packages: [] } + if (method === 'capability.list') { + if (holdInitialRefresh) { + holdInitialRefresh = false + return new Promise((_resolve, reject) => { rejectInitialRefresh = reject }) + } + return { packages: [] } + } throw new Error('Unexpected synthetic RPC: ' + method) } host.amadeus = { @@ -142,6 +154,11 @@ host.amadeus = { }, restartBackend: async () => { calls.push({ method: 'restartBackend', params: {} }) + if (rejectInitialRefresh) { + await new Promise(resolve => setTimeout(resolve, 20)) + rejectInitialRefresh(new Error('reconnecting')) + rejectInitialRefresh = undefined + } desktop.extensions = desktop.extensions.filter(extension => !extension.pendingRemoval) desktop.extensions.forEach(extension => { extension.restartRequired = false; extension.appliedRevision = extension.revision; extension.appliedVersion = extension.version }) runtime = runtime.filter(observed => desktop.extensions.some(extension => extension.id === observed.id)).map(observed => { @@ -154,6 +171,7 @@ host.amadeus = { } if (query.has('real')) Object.assign(host.amadeus, host.extensionStoreFixture) host.extensionFixture = { calls, errors, snapshot, setFailCatalog: (value: boolean) => { failCatalog = value }, setFailSave: (value: boolean) => { failSave = value }, + setProbeResponse: (value: Record) => { probeResponse = value }, refreshRuntime: () => { emit() }, setContributionHealth: (health: string, detail: string) => { Object.assign(runtime[0].contributions[0], { health, detail }); emit() } } document.documentElement.style.height = '100%' diff --git a/server/handlers/mcp_connection_handler.py b/server/handlers/mcp_connection_handler.py index 2cb2c08e..7075aa04 100644 --- a/server/handlers/mcp_connection_handler.py +++ b/server/handlers/mcp_connection_handler.py @@ -68,11 +68,12 @@ async def _test(self, params: dict[str, Any]) -> dict[str, Any]: connection_id, type(exc).__name__, ) + code, detail = _probe_error(exc, connection) return { "connection_id": connection_id, "status": "error", - "code": "connection_failed", - "detail": _safe_error(exc, connection), + "code": code, + "detail": detail, "main_chat_access": False, } names = sorted( @@ -124,3 +125,17 @@ def _safe_error(exc: Exception, connection: McpConnectionSpec) -> str: 'environment values, then save and restart the backend.' if connection.transport == 'stdio' else '') return text[:500 - len(hint)] + hint + + +def _probe_error(exc: Exception, connection: McpConnectionSpec) -> tuple[str, str]: + from httpx2 import ConnectError, HTTPStatusError + + for error in _error_leaves(exc): + if isinstance(error, HTTPStatusError): + if error.response.status_code == 401: + return "authentication_failed", "Authentication failed (HTTP 401). Check the connection token in the extension or MCP settings." + if error.response.status_code == 403: + return "access_denied", "Access denied (HTTP 403). Check the server access policy and connection credentials." + if isinstance(error, ConnectError): + return "connection_unreachable", "Could not reach the MCP server. Check that it is running and that the URL and port are correct." + return "connection_failed", _safe_error(exc, connection) diff --git a/tests/test_mcp_connections.py b/tests/test_mcp_connections.py index 77699721..f98e5eff 100644 --- a/tests/test_mcp_connections.py +++ b/tests/test_mcp_connections.py @@ -56,6 +56,10 @@ def test_registry_validation_and_public_projection_never_return_secret_values() assert connection.public_dict()["environment_keys"] == ["FIXTURE_TOKEN"] assert "keep-spaces" not in str(connection.public_dict()) assert connection.public_dict()["main_chat_access"] is False + assert "extension_package_id" not in connection.public_dict() + projected = _stdio_connection(extension_package_id="org.example.connector") + assert projected.public_dict()["extension_package_id"] == "org.example.connector" + assert "not-public" not in str(projected.public_dict()) def test_enabled_connection_requires_a_provider_and_rejects_bad_http_urls() -> None: @@ -247,3 +251,65 @@ def test_codex_stdio_forwards_only_each_connections_explicit_environment(): assert "env_vars" not in config["web"] assert config["web"]["bearer_token_env_var"] == "HTTP_TOKEN" assert "synthetic-" not in "\n".join(overrides) + + +@pytest.mark.parametrize("failure,code", [ + (401, "authentication_failed"), (403, "access_denied"), + ("unreachable", "connection_unreachable"), +]) +def test_http_probe_preserves_transport_diagnostics_without_credentials(monkeypatch, failure, code): + import httpx2 + + original_client = httpx2.AsyncClient + monkeypatch.setenv("PROBE_TOKEN", "synthetic-private-token") + + async def respond(request): + assert request.headers["Authorization"] == "Bearer synthetic-private-token" + if failure == "unreachable": + raise httpx2.ConnectError("synthetic-private-token is unreachable", request=request) + return httpx2.Response(failure, json={"error": "synthetic-private-token rejected"}) + + monkeypatch.setattr(httpx2, "AsyncClient", lambda **kwargs: original_client( + transport=httpx2.MockTransport(respond), **kwargs)) + connection = McpConnectionSpec("probe", "Probe", "http", url="https://example.invalid/mcp", + bearer_token_env_var="PROBE_TOKEN") + result = asyncio.run(McpConnectionHandler((connection,)).handle( + Method.MCP_CONNECTION_TEST, {"connection_id": "probe"})) + assert result["status"] == "error" + assert result["code"] == code + assert "synthetic-private-token" not in str(result) + assert "Check" in result["detail"] + + +def test_http_probe_keeps_native_protocol_negotiation(monkeypatch): + import httpx2 + + original_client = httpx2.AsyncClient + negotiation = [] + + async def respond(request): + if request.method != "POST": + return httpx2.Response(405) + message = json.loads(request.content) + if message["method"] == "server/discover": + negotiation.append(message["method"]) + return httpx2.Response(400, json={"jsonrpc": "2.0", "id": message["id"], + "error": {"code": -32601, "message": "Use supported initialize"}}) + if message["method"] == "initialize": + negotiation.append(message["method"]) + result = {"protocolVersion": "2025-11-25", "capabilities": {"tools": {}}, + "serverInfo": {"name": "probe-fixture", "version": "1"}} + elif message["method"] == "tools/list": + result = {"tools": []} + else: + return httpx2.Response(202) + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": message["id"], "result": result}) + + monkeypatch.setattr(httpx2, "AsyncClient", lambda **kwargs: original_client( + transport=httpx2.MockTransport(respond), **kwargs)) + connection = McpConnectionSpec("probe", "Probe", "http", url="https://example.invalid/mcp") + result = asyncio.run(McpConnectionHandler((connection,)).handle( + Method.MCP_CONNECTION_TEST, {"connection_id": "probe"})) + assert result["status"] == "connected", result + assert result["tool_count"] == 0 + assert negotiation == ["server/discover", "initialize"] diff --git a/tests/test_pi_provider.py b/tests/test_pi_provider.py index 03e0430c..faeb5ff5 100644 --- a/tests/test_pi_provider.py +++ b/tests/test_pi_provider.py @@ -347,7 +347,7 @@ async def test_pi_native_notifications_are_redacted_without_task_progress(pi_hos def notify(self, *args, **kwargs): self.events.put_nowait({"type": "extension_ui_request", "method": "notify", - "message": "Connection failed: synthetic-secret", "notifyType": "error"}) + "message": "Connection failed: synthetic-secret\nRun /mcp to fix.", "notifyType": "error"}) finish(self, *args, **kwargs) monkeypatch.setattr(FakePi, "finish", notify) @@ -358,6 +358,10 @@ def notify(self, *args, **kwargs): event, = [event for event in events if event.type == "runtime.notice"] assert "synthetic-secret" not in str(event.payload) assert "[redacted]" in event.payload["message"] + assert "Plugins" in event.payload["message"] + assert "Settings -> Providers -> MCP" in event.payload["message"] + assert "Test connection" in event.payload["message"] + assert "Run /mcp" not in event.payload["message"] assert event.payload["level"] == "error" assert semantic_progress_fact(event.type, event.payload) is None From cd4ab09ced88d6813613c966ba6b1406c0fb6f28 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Thu, 5 Nov 2026 10:53:03 +1300 Subject: [PATCH 2/2] test(extensions): align fixtures and locale ordering with connector diagnostics --- electron/src/renderer/locales/zh-CN.json | 8 ++++---- electron/tests/extensionSettings.test.mjs | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/electron/src/renderer/locales/zh-CN.json b/electron/src/renderer/locales/zh-CN.json index 98918930..545242ad 100644 --- a/electron/src/renderer/locales/zh-CN.json +++ b/electron/src/renderer/locales/zh-CN.json @@ -21,6 +21,7 @@ "Absolute path to a .model3.json file": ".model3.json 文件的绝对路径", "Accept WorkItem": "接受工作项", "Accepting": "正在接受", + "Access denied (HTTP 403). Check the server access policy and connection credentials.": "访问被拒绝(HTTP 403)。请检查服务端的访问策略和连接凭据。", "Action role needs setup": "动作职责需要配置", "Active": "当前使用", "Active Work permissions": "本次 Work 的有效授权", @@ -114,6 +115,7 @@ "Attach game view": "附加游戏画面", "Attach hook agent": "附加 Hook 代理", "Attached game view": "已附加的游戏画面", + "Authentication failed (HTTP 401). Check the connection token in the extension or MCP settings.": "身份验证失败(HTTP 401)。请检查扩展或 MCP 设置中的连接令牌。", "Author": "作者", "Auto Return to Idle": "自动返回待机", "Automatic": "自动", @@ -314,10 +316,6 @@ "Connected · {count} tools discovered": "已连接 · 发现 {count} 个工具", "Connecting to game…": "正在连接游戏…", "Connecting…": "正在连接…", - "Test connection": "测试连接", - "Authentication failed (HTTP 401). Check the connection token in the extension or MCP settings.": "身份验证失败(HTTP 401)。请检查扩展或 MCP 设置中的连接令牌。", - "Access denied (HTTP 403). Check the server access policy and connection credentials.": "访问被拒绝(HTTP 403)。请检查服务端的访问策略和连接凭据。", - "Could not reach the MCP server. Check that it is running and that the URL and port are correct.": "无法连接 MCP 服务。请确认服务正在运行,并检查地址与端口是否正确。", "Connection details": "连接设置", "Connection details and diagnostics": "连接详情与诊断", "Connection failed": "连接失败", @@ -335,6 +333,7 @@ "Could not install extension": "无法安装扩展", "Could not load roles.": "无法加载角色。", "Could not open package link": "无法打开插件链接", + "Could not reach the MCP server. Check that it is running and that the URL and port are correct.": "无法连接 MCP 服务。请确认服务正在运行,并检查地址与端口是否正确。", "Could not read startup status.": "无法读取启动状态。", "Could not remove extension": "无法卸载扩展", "Could not save console language": "无法保存控制台语言", @@ -1173,6 +1172,7 @@ "Terms and preferred names": "术语与偏好称谓", "Test": "测试", "Test a few lines before your first play session.": "首次游玩前,建议先核对几句取文。", + "Test connection": "测试连接", "Test text capture": "测试取文", "Tests this draft in a separate instance. Chat audio and the applied visuals continue independently.": "使用独立实例测试当前草稿,不影响聊天语音或已应用的形象配置。", "Text capture is working. Check the companion model before asking questions.": "取文正常。请检查陪伴模型配置后再提问。", diff --git a/electron/tests/extensionSettings.test.mjs b/electron/tests/extensionSettings.test.mjs index e0436115..87cce6a8 100644 --- a/electron/tests/extensionSettings.test.mjs +++ b/electron/tests/extensionSettings.test.mjs @@ -39,7 +39,9 @@ const providers = [ { provider_id: 'pi', display_name: 'Pi', capabilities: { capability_projections: ['mcp_connection'] } }, { provider_id: 'unrelated', display_name: 'Incompatible agent', capabilities: { capability_projections: ['skill'] } }, ] -const props = overrides => ({ platform: 'win32', extensions: [extension()], runtime: [], providers, connected: false, available: true, encryptionAvailable: true, selectedVoiceBackend: '', +const props = overrides => ({ platform: 'win32', extensions: [extension()], runtime: [], mcpConnections: [], + async send(method) { throw new Error(`Unexpected fixture RPC: ${method}`) }, + providers, connected: false, available: true, encryptionAvailable: true, selectedVoiceBackend: '', onSettingsChanged() {}, onRestartRequired() {}, onOpenVoice() {}, ...overrides }) test('typed extension edits preserve defaults and never reconstruct saved secrets', () => {