From d53b9e27c08b13a9f7f3404cae4fe3c88fc3e48e Mon Sep 17 00:00:00 2001 From: IanYet Date: Thu, 22 Oct 2026 18:49:21 +0800 Subject: [PATCH 1/5] feat(plugin): collect hosted MCP connection evidence --- .github/workflows/test.yml | 11 + claude/README.md | 11 + .../chatcut-plugin-basics-claude/SKILL.md | 4 +- .../references/connection-diagnostics.md | 86 +++ .../scripts/mcp-doctor.mjs | 531 ++++++++++++++++++ codex/skills/chatcut-plugin-basics/SKILL.md | 2 + .../references/connection-diagnostics.md | 86 +++ .../scripts/mcp-doctor.mjs | 531 ++++++++++++++++++ docs/mcp-doctor.md | 84 +++ tests/mcp-doctor.test.mjs | 406 +++++++++++++ 10 files changed, 1751 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md create mode 100644 claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs create mode 100644 codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md create mode 100644 codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs create mode 100644 docs/mcp-doctor.md create mode 100644 tests/mcp-doctor.test.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1b9af4b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,11 @@ +name: Plugin helpers +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: node --test tests/*.test.mjs diff --git a/claude/README.md b/claude/README.md index 4b0e2a0..3309eff 100644 --- a/claude/README.md +++ b/claude/README.md @@ -64,6 +64,17 @@ Local imports use one of two bundled Node.js helpers: Only import media you have permission to use. If the host denies a transfer, the workflow stops rather than bypassing that restriction. +For hosted MCP connection failures, the Agent can run the bundled diagnostic +helper with Node.js 18+. It reads bounded ChatCut connection log records and +checks public OAuth metadata responses, preserving the original failure +separately from later probes. A local report is saved first. When network +permissions permit diagnostic reporting, the Agent can send the redacted report +to ChatCut's telemetry endpoint without MCP login. Credential stores and +conversation transcripts are not read. Response bodies are not retained as +separate fields; original parser errors may quote response text. This does not +repair or replace the connection, and it does not monitor every connection attempt. +See [the evidence and delivery contract](../docs/mcp-doctor.md). + ## Links - [Documentation](https://chatcut.io/claude) diff --git a/claude/skills/chatcut-plugin-basics-claude/SKILL.md b/claude/skills/chatcut-plugin-basics-claude/SKILL.md index ba10346..e3b3f00 100644 --- a/claude/skills/chatcut-plugin-basics-claude/SKILL.md +++ b/claude/skills/chatcut-plugin-basics-claude/SKILL.md @@ -1,6 +1,6 @@ --- name: chatcut-plugin-basics-claude -description: "Hosted ChatCut plugin sessions only (the `chatcut` server, namespaced `plugin:chatcut:chatcut`). If the conversation is driving ChatCut Desktop (a `chatcut_desktop*` MCP server), load this skill only when the user explicitly chooses the plugin/web surface — desktop sessions otherwise ship their own instructions and tools. MANDATORY Claude Code prerequisite for any conversation that may use the ChatCut MCP server: invoke this Skill before the first ChatCut MCP tool call and wait for it to finish loading. Also invoke it whenever video editing or creation should remain editable in ChatCut, even if the user does not mention ChatCut. Covers local or attached media editing, captions, subtitles, transcription, trimming, talking-head cleanup, highlights, B-roll, overlays, generation, export, project or editor opening, importing, targeting, verification, watching, and identifying the active ChatCut project or editor URL. Also invoke it when ChatCut tools appear missing, unavailable, or not connected: that is normally an unauthenticated MCP server rather than a broken install, and this skill says what to tell the user." +description: "Hosted ChatCut plugin only (`chatcut` or `plugin:chatcut:chatcut`). MANDATORY Claude Code prerequisite: invoke this Skill before the first hosted ChatCut MCP tool call and wait for loading. Also invoke when video editing or creation should remain editable in ChatCut, even if the user does not mention ChatCut. Covers project/editor access, local media import, timeline editing, captions, transcription, talking-head cleanup, B-roll, overlays, motion graphics, generation, audio, translation, export, and verification. Use when hosted tools are missing, unavailable, unauthenticated, or fail to connect; includes connection evidence diagnostics. For `chatcut_desktop*`, follow Desktop instructions unless the user explicitly selects the hosted plugin/web surface." --- # ChatCut Plugin Basics (Claude Code) @@ -26,6 +26,8 @@ ChatCut tools are served through the configured Hosted MCP server: `chatcut` for If the tools are available and authenticated, continue normal work without reinstalling or starting a new session. Otherwise, check registration with `claude mcp get chatcut` and `claude mcp get plugin:chatcut:chatcut` (or inspect `/mcp` when the CLI is unavailable). If either exists, use that server and do not create a duplicate. Follow the matching path: +When an existing connection or OAuth attempt reports an error, or tools remain absent after successful authentication, preserve that evidence and follow [connection diagnostics](references/connection-diagnostics.md) before retrying login or changing anything. An initial sign-in requirement by itself is expected, not a confirmed connection defect. The diagnostic helper collects evidence independently of the broken MCP connection; it does not require ChatCut Desktop. + 1. **Server not registered:** Offer to register the Hosted MCP server directly at user scope. Do not install, reinstall, repair, or enable a plugin to obtain the server: ```sh diff --git a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md new file mode 100644 index 0000000..93920f9 --- /dev/null +++ b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md @@ -0,0 +1,86 @@ +# Hosted MCP connection diagnostics + +Use this branch when a registered hosted ChatCut connection fails, including +OAuth failing before a browser opens, or authentication completing without usable +tools. Preserve evidence before reconnecting or restarting. This version gathers +evidence; it does not repair configuration, credentials, network settings, or the +original connection. + +Briefly tell the user that you will collect ChatCut connection diagnostics and +send the redacted report to ChatCut. Run under the host's existing execution and +network permissions; do not add a separate manual step when those permit it. +Honor a refusal or a denied read/network operation. Do not change permission +settings to make collection work. + +## Run once for the reported failure + +1. Obtain the existing server's registered URL through the host. Preserve its + environment. The helper accepts ChatCut production/beta/dev endpoints and + loopback development servers; it reports unsupported endpoints rather than + silently probing production. Never read credential stores or paste complete + configuration, environment, or conversation dumps into the report. + An intentionally disabled hosted server is not a failed connection; preserve + that choice instead of starting diagnostics or re-enabling it. +2. Save only the original connection error and its available cause chain into a + new temporary text file. Use the exact original text, not a generated root + cause. If unavailable, omit `--original-error`. Obtain the actual bundled + host version if accessible without starting a session. Do not treat update + metadata as the running version. +3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: + + ```text + node /scripts/mcp-doctor.mjs --host codex --server-url --original-error --host-version --upload + ``` + + For Claude Code, use `--host claude`. Quote paths as shell arguments; do not + interpolate error text into shell code. Omit unknown optional fields. Supply + `--occurred-at` only when the failure's actual ISO timestamp and timezone are + known. A missing timestamp remains unknown; the collection time is separate. + If transfer is denied, omit `--upload` and retain the local JSON report. + +Use an available Node.js 18+ runtime. Do not install a runtime or ChatCut Desktop +just for diagnostics. Codex SQLite extraction additionally needs `node:sqlite` +(available on recent Node 22+/24 runtimes). Without it, other collection still +runs and the report marks the missing SQLite evidence. A denied file read is a +coverage gap, not proof that the server has no errors. + +The helper reads only bounded ChatCut MCP runtime log records or debug log tails. +It never reads `auth.json`, OAuth credential stores, session transcripts, or +other project files. It sends three unauthenticated GET requests to the same +registered origin's public discovery endpoints, with a 3.5-second timeout each. +It does not run login or start an MCP/desktop process. Its Node HTTP transport +can differ from the host's proxy/TLS stack; it records that limitation. + +## Interpret the report + +- `originalFailure` is the original error supplied by the Agent. `hostLogs` + are matching records in a bounded time window, with unconfirmed attempt + association. `probes` are new requests made later. Keep these separate. +- Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. + A healthy later probe never proves the original host connection recovered. +- Preserve original error messages and codes. Redact actual credentials, not + entire error categories. No separate metadata response body is retained; + original parser errors may quote response text. +- Missing debug logs cannot be reconstructed. Claude's detailed debug logs may + require `claude --debug=mcp` during a future, separately chosen reproduction; + this helper does not enable logging or relaunch Claude. +- A `diagnosticId` identifies the diagnostic report and its new probes. It is + not the original failed request's ID. Server response request IDs are useful + only when actually present and matched; time/path matching is weaker evidence. + +The local JSON is written before any upload. `ingest_accepted` means that the +endpoint acknowledged the batch; `storageVerified:false` remains until support +finds every fragment in the final log store. Failed uploads preserve the local +report and upload error; do not automatically repeat the run or claim success. +Output larger than the upload budget stays local with an explicit upload error. + +Return the diagnostic ID, local report location, one new observed fact, and the +next action justified by it. If no new actionable fact was obtained, state the +specific missing evidence. Do not assign blame to antivirus, VPN, firewall, host, +or service merely from a generic failure message. Do not claim a user failure +percentage from diagnostic samples. + +This branch runs when the Agent loads it and has access to local execution. It +is not an installation hook or an all-user failure monitor. It cannot inspect a +web host's remote connection process or internal logs. With Claude Code tool +search disabled, connection failures may not be passed to the Agent at all. diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs new file mode 100644 index 0000000..20323b9 --- /dev/null +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -0,0 +1,531 @@ +import { randomUUID, createHash } from "node:crypto"; +import { open, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { homedir, tmpdir, platform, arch } from "node:os"; +import { join, resolve, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const READ_LIMIT = 128 * 1024; +const UPLOAD_LIMIT = 256 * 1024; +const TIMEOUT_MS = 3500; +const CHATCUT_MESSAGE = + /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|https:\/\/api\.chatcut\.(?:io|dev|org)\//i; + +export function redact(text) { + return String(text) + .replace(/\bBearer\s+(?!(?:token|required)\b|<)[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]") + .replace(/\b(Authorization\s*[:=]\s*Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[REDACTED]") + .replace(/\b((?:set-)?cookie\s*[:=]\s*)[^\r\n]+/gi, "$1[REDACTED]") + .replace(/\bchatcut_live_[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]") + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]") + .replace( + /([?&](?:code|state|token|access_token|refresh_token|id_token|client_secret)=)[^&\s"<>]+/gi, + "$1[REDACTED]", + ) + .replace( + /("(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)"\s*:\s*")[^"]*/gi, + "$1[REDACTED]", + ) + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); +} + +function clean(value) { + return JSON.parse( + JSON.stringify(value, (_, item) => (typeof item === "string" ? redact(item) : item)), + ); +} + +export function errorDetails(error, seen = new Set()) { + if (!error || typeof error !== "object") return { message: redact(error) }; + if (seen.has(error) || seen.size >= 8) return { unavailable: "error_chain_depth_or_cycle" }; + seen.add(error); + const result = {}; + for (const key of ["name", "message", "code", "errno", "syscall", "stack"]) { + if (typeof error[key] === "string" || typeof error[key] === "number") result[key] = error[key]; + } + if (error.cause) result.cause = errorDetails(error.cause, seen); + if (Array.isArray(error.errors)) { + result.errors = error.errors.slice(0, 8).map((item) => errorDetails(item, seen)); + if (error.errors.length > 8) result.omittedErrors = error.errors.length - 8; + } + return clean(result); +} + +export function serverUrl(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + const hosted = ["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes( + url.hostname, + ); + if ( + (!local && (!hosted || url.protocol !== "https:" || (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/api\/external-mcp\/(?:mcp|claude|v2\/mcp)\/?$/.test(url.pathname) + ) { + throw new Error( + "Unsupported registered MCP URL; preserve its configuration and report unsupported_endpoint", + ); + } + return url; +} + +async function limitedBody(response, limit = READ_LIMIT) { + const reader = response.body?.getReader(); + if (!reader) return ""; + const parts = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return Buffer.concat(parts).toString("utf8"); + bytes += value.length; + if (bytes > limit) { + const error = new Error(`Response exceeds ${limit} bytes; body not retained`); + error.code = "DIAGNOSTIC_BODY_LIMIT"; + throw error; + } + parts.push(Buffer.from(value)); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url, + startedAt, + implementation: `node-fetch/${process.version}`, + stage: "request", + status: null, + bodyComplete: false, + jsonValid: null, + originalAttemptMatch: "unconfirmed", + }; + try { + const response = await fetchImpl(url, { + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { accept: "application/json", "x-chatcut-diagnostic-id": diagnosticId }, + }); + result.status = response.status; + result.responseHeaders = {}; + for (const key of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + ]) { + const value = response.headers.get(key); + if (value !== null) result.responseHeaders[key] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + result.stage = "json_parse"; + try { + const parsed = JSON.parse(body); + result.jsonValid = true; + result.metadataShape = { + object: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed), + issuerPresent: typeof parsed?.issuer === "string", + resourcePresent: typeof parsed?.resource === "string", + }; + } catch (error) { + result.jsonValid = false; + throw error; + } + result.stage = "complete"; + const required = new URL(url).pathname.includes("oauth-protected-resource") + ? "resourcePresent" + : "issuerPresent"; + result.outcome = !response.ok + ? "http_rejected" + : result.metadataShape.object && result.metadataShape[required] + ? "metadata_read" + : "invalid_metadata_shape"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +function logMessage(body) { + const marker = body.lastIndexOf("}:"); + return (marker < 0 ? body : body.slice(marker + 2)).trim(); +} + +async function textLog(path, source, gaps) { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const start = Math.max(0, info.size - READ_LIMIT); + const buffer = Buffer.alloc(Math.min(info.size, READ_LIMIT)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); + if (start) { + lines.shift(); + gaps.push({ source, reason: "only_log_tail_read", bytesBeforeWindow: start }); + } + const entries = []; + for (let i = 0; i < lines.length; i++) { + if ( + !CHATCUT_MESSAGE.test(lines[i]) || + /function_call|tool_call|tool result|prompt|transcript|"method"\s*:\s*"tools\/call"/i.test( + lines[i], + ) || + !/connect|oauth|authenticat|metadata|handshak|initializ|tools\/list|tool catalog/i.test( + lines[i], + ) + ) + continue; + const block = [lines[i]]; + while (i + 1 < lines.length && /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1])) + block.push(lines[++i]); + entries.push({ + source, + message: redact(block.join("\n")), + originalAttemptMatch: "unconfirmed", + }); + } + if (entries.length > 30) + gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); + return entries.slice(-30); + } finally { + await handle.close(); + } +} + +export async function collectHostLogs(host, options = {}) { + const userHome = options.home ?? homedir(); + const gaps = []; + const entries = []; + const now = options.now ?? Date.now(); + const center = options.occurredAt ? Date.parse(options.occurredAt) : now; + const since = Math.floor((center - (options.occurredAt ? 15 : 30) * 60_000) / 1000); + const until = Math.floor(Math.min(now, center + 15 * 60_000) / 1000); + if (host === "codex") { + const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); + let db; + try { + await stat(join(root, "logs_2.sqlite")); + const { DatabaseSync } = await import("node:sqlite"); + db = new DatabaseSync(join(root, "logs_2.sqlite"), { readOnly: true, timeout: 1000 }); + db.exec("PRAGMA query_only=ON"); + const rows = db + .prepare( + "SELECT id,ts,level,target,feedback_log_body FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200", + ) + .all(since, until); + for (const row of rows) { + const message = logMessage(row.feedback_log_body ?? ""); + const original = row.feedback_log_body ?? ""; + if (!CHATCUT_MESSAGE.test(original) || /server_name="?chatcut_desktop/.test(original)) + continue; + if ( + !["ERROR", "WARN"].includes(row.level) && + !/omitting MCP server|cached MCP catalog/.test(message) + ) + continue; + entries.push({ + source: "codex_runtime_sqlite", + rowId: row.id, + occurredAt: new Date(row.ts * 1000).toISOString(), + target: row.target, + level: row.level, + message: redact(message), + originalAttemptMatch: "unconfirmed", + }); + if (entries.length === 30) break; + } + if (rows.length === 200 || entries.length === 30) + gaps.push({ source: "codex_runtime_sqlite", reason: "log_record_limit" }); + } catch (error) { + gaps.push({ + source: "codex_runtime_sqlite", + reason: "unavailable", + error: errorDetails(error), + }); + } finally { + db?.close(); + } + try { + entries.push(...(await textLog(join(root, "log", "codex-tui.log"), "codex_tui_log", gaps))); + } catch (error) { + gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); + } + } else { + const root = + options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(userHome, ".claude"); + try { + const dir = join(root, "debug"); + const names = await readdir(dir, { withFileTypes: true }); + const files = []; + const deadline = Date.now() + 1500; + for (const item of names) { + if (Date.now() > deadline) { + gaps.push({ source: "claude_debug_log", reason: "directory_scan_deadline" }); + break; + } + if (!item.isFile() || !item.name.endsWith(".txt")) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= since * 1000) files.push({ path, modified: info.mtimeMs }); + } + files.sort((a, b) => b.modified - a.modified); + for (const item of files.slice(0, 3)) + entries.push(...(await textLog(item.path, "claude_debug_log", gaps))); + if (files.length > 3) + gaps.push({ + source: "claude_debug_log", + reason: "log_file_limit", + omitted: files.length - 3, + }); + } catch (error) { + gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (!entries.length) + gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); + return clean({ + entries, + gaps, + window: { + since: new Date(since * 1000).toISOString(), + until: new Date(until * 1000).toISOString(), + }, + }); +} + +export function fragmentEvents(report) { + const json = JSON.stringify(clean(report)); + if (Buffer.byteLength(json) > UPLOAD_LIMIT) + throw new Error("Report exceeds upload budget; complete local report retained"); + const chunks = []; + let chunk = ""; + let bytes = 0; + for (const char of json) { + const size = Buffer.byteLength(char); + if (bytes + size > 4096) { + chunks.push(chunk); + chunk = ""; + bytes = 0; + } + chunk += char; + bytes += size; + } + if (chunk) chunks.push(chunk); + const sha256 = createHash("sha256").update(json).digest("hex"); + return chunks.map((fragment, fragmentIndex) => ({ + name: "mcp.doctor.report_fragment", + properties: { + diagnosticId: report.diagnosticId, + schemaVersion: report.schemaVersion, + collectedAt: report.collectedAt, + host: report.host, + scope: "cloud", + fragmentIndex, + fragmentCount: chunks.length, + reportSha256: sha256, + fragment, + }, + })); +} + +export async function runDoctor(options, dependencies = {}) { + if (!["codex", "claude"].includes(options.host)) + throw new Error("--host must be codex or claude"); + const endpoint = serverUrl(options.serverUrl); + if ( + options.occurredAt && + (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || + !Number.isFinite(Date.parse(options.occurredAt))) + ) { + throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); + } + const fetchImpl = dependencies.fetchImpl ?? fetch; + const diagnosticId = randomUUID(); + const report = { + schemaVersion: 1, + collectorVersion: "1.0.0", + diagnosticId, + collectedAt: new Date().toISOString(), + scope: "cloud", + host: options.host, + hostVersion: options.hostVersion ?? null, + hostVersionSource: options.hostVersion ? "agent_supplied" : "unavailable", + environment: { + platform: platform(), + architecture: arch(), + nodeVersion: process.version, + proxyEnvironmentPresent: [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ].some((key) => Boolean(process.env[key])), + probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", + }, + serverUrl: endpoint.href, + originalFailure: { + evidenceKind: "original_failure", + occurredAt: options.occurredAt ?? null, + source: "agent_supplied_error_file", + status: "unavailable", + }, + probes: [], + gaps: [], + outcome: "diagnosis_only; original_connection_recovery_unverified", + coverage: "triggered_diagnostics_only; not_all_users_or_all_failures", + correlation: + "diagnosticId identifies this report and later probes, not the original host request", + }; + try { + const manifest = + options.host === "codex" + ? "../../../.codex-plugin/plugin.json" + : "../../../.claude-plugin/plugin.json"; + report.pluginVersion = JSON.parse( + await readFile(new URL(manifest, import.meta.url), "utf8"), + ).version; + report.pluginVersionSource = + "collector_package_manifest; installed_host_plugin_not_independently_verified"; + } catch (error) { + report.gaps.push({ source: "plugin_version", error: errorDetails(error) }); + } + if (options.originalError) { + try { + if (!/\.(?:txt|log)$/i.test(basename(String(options.originalError)))) { + throw new Error( + "Original error must be a dedicated .txt or .log file; credential/config/session files are not inputs", + ); + } + const info = await stat(options.originalError); + if (info.size > 64 * 1024) + throw new Error("Original error file exceeds 64 KiB; source file retained without reading"); + report.originalFailure.message = redact(await readFile(options.originalError, "utf8")); + report.originalFailure.status = "recorded"; + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "unavailable", + error: errorDetails(error), + }); + } + } else + report.gaps.push({ + source: "original_failure", + reason: "not_provided; do_not_invent_original_error", + }); + try { + report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { + occurredAt: options.occurredAt, + }); + } catch (error) { + report.hostLogs = { + entries: [], + gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], + }; + } + const resource = endpoint.pathname.replace(/\/$/, ""); + for (const path of [ + `/.well-known/oauth-protected-resource${resource}`, + `/.well-known/oauth-authorization-server${resource}`, + "/.well-known/oauth-authorization-server", + ]) { + report.probes.push( + await probeMetadata(new URL(path, endpoint.origin).href, diagnosticId, fetchImpl), + ); + } + report.upload = { status: options.upload ? "pending" : "not_requested", storageVerified: false }; + const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); + const save = async (flag) => + writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); + await save("wx"); + if (options.upload) { + try { + const events = fragmentEvents(report); + const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { "content-type": "application/json" }, + body: JSON.stringify(events), + }); + const body = await limitedBody(response, 16 * 1024); + if (!response.ok || JSON.parse(body).ok !== true) + throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); + report.upload = { + status: "ingest_accepted", + storageVerified: false, + fragmentCount: events.length, + reportSha256: events[0].properties.reportSha256, + }; + } catch (error) { + report.upload = { status: "failed", storageVerified: false, error: errorDetails(error) }; + } + await save("w"); + } + return { path, report: clean(report) }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { values } = parseArgs({ + options: { + host: { type: "string" }, + "server-url": { type: "string" }, + "host-version": { type: "string" }, + "original-error": { type: "string" }, + "occurred-at": { type: "string" }, + output: { type: "string" }, + upload: { type: "boolean", default: false }, + help: { type: "boolean", default: false }, + }, + }); + if (values.help) { + console.log( + "node mcp-doctor.mjs --host codex|claude --server-url [--host-version ] [--original-error ] [--occurred-at ] [--output ] [--upload]", + ); + } else { + const { path, report } = await runDoctor({ + host: values.host, + serverUrl: values["server-url"], + hostVersion: values["host-version"], + originalError: values["original-error"], + occurredAt: values["occurred-at"], + output: values.output, + upload: values.upload, + }); + console.log( + JSON.stringify({ + diagnosticId: report.diagnosticId, + path, + upload: report.upload, + recoveryVerified: false, + }), + ); + } + } catch (error) { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + } +} diff --git a/codex/skills/chatcut-plugin-basics/SKILL.md b/codex/skills/chatcut-plugin-basics/SKILL.md index 7a8b59d..f0ff9f7 100644 --- a/codex/skills/chatcut-plugin-basics/SKILL.md +++ b/codex/skills/chatcut-plugin-basics/SKILL.md @@ -26,6 +26,8 @@ ChatCut tools are served through the configured Hosted `chatcut` MCP server, whe If the tools are available and authenticated, continue normal work without reinstalling or starting a new session. Otherwise, check registration with `codex mcp get chatcut` (or inspect Codex's MCP settings when the CLI is unavailable). If it exists, use that server and do not create a duplicate. Follow the matching MCP path: +When an existing connection or OAuth attempt reports an error, or tools remain absent after successful authentication, preserve that evidence and follow [connection diagnostics](references/connection-diagnostics.md) before retrying login or changing anything. An initial sign-in requirement by itself is expected, not a confirmed connection defect. The diagnostic helper collects evidence independently of the broken MCP connection; it does not require ChatCut Desktop. + 1. **Server not registered:** Offer to register the Hosted MCP server directly. Do not install, reinstall, repair, or enable a plugin to obtain the server: ```sh diff --git a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md new file mode 100644 index 0000000..93920f9 --- /dev/null +++ b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md @@ -0,0 +1,86 @@ +# Hosted MCP connection diagnostics + +Use this branch when a registered hosted ChatCut connection fails, including +OAuth failing before a browser opens, or authentication completing without usable +tools. Preserve evidence before reconnecting or restarting. This version gathers +evidence; it does not repair configuration, credentials, network settings, or the +original connection. + +Briefly tell the user that you will collect ChatCut connection diagnostics and +send the redacted report to ChatCut. Run under the host's existing execution and +network permissions; do not add a separate manual step when those permit it. +Honor a refusal or a denied read/network operation. Do not change permission +settings to make collection work. + +## Run once for the reported failure + +1. Obtain the existing server's registered URL through the host. Preserve its + environment. The helper accepts ChatCut production/beta/dev endpoints and + loopback development servers; it reports unsupported endpoints rather than + silently probing production. Never read credential stores or paste complete + configuration, environment, or conversation dumps into the report. + An intentionally disabled hosted server is not a failed connection; preserve + that choice instead of starting diagnostics or re-enabling it. +2. Save only the original connection error and its available cause chain into a + new temporary text file. Use the exact original text, not a generated root + cause. If unavailable, omit `--original-error`. Obtain the actual bundled + host version if accessible without starting a session. Do not treat update + metadata as the running version. +3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: + + ```text + node /scripts/mcp-doctor.mjs --host codex --server-url --original-error --host-version --upload + ``` + + For Claude Code, use `--host claude`. Quote paths as shell arguments; do not + interpolate error text into shell code. Omit unknown optional fields. Supply + `--occurred-at` only when the failure's actual ISO timestamp and timezone are + known. A missing timestamp remains unknown; the collection time is separate. + If transfer is denied, omit `--upload` and retain the local JSON report. + +Use an available Node.js 18+ runtime. Do not install a runtime or ChatCut Desktop +just for diagnostics. Codex SQLite extraction additionally needs `node:sqlite` +(available on recent Node 22+/24 runtimes). Without it, other collection still +runs and the report marks the missing SQLite evidence. A denied file read is a +coverage gap, not proof that the server has no errors. + +The helper reads only bounded ChatCut MCP runtime log records or debug log tails. +It never reads `auth.json`, OAuth credential stores, session transcripts, or +other project files. It sends three unauthenticated GET requests to the same +registered origin's public discovery endpoints, with a 3.5-second timeout each. +It does not run login or start an MCP/desktop process. Its Node HTTP transport +can differ from the host's proxy/TLS stack; it records that limitation. + +## Interpret the report + +- `originalFailure` is the original error supplied by the Agent. `hostLogs` + are matching records in a bounded time window, with unconfirmed attempt + association. `probes` are new requests made later. Keep these separate. +- Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. + A healthy later probe never proves the original host connection recovered. +- Preserve original error messages and codes. Redact actual credentials, not + entire error categories. No separate metadata response body is retained; + original parser errors may quote response text. +- Missing debug logs cannot be reconstructed. Claude's detailed debug logs may + require `claude --debug=mcp` during a future, separately chosen reproduction; + this helper does not enable logging or relaunch Claude. +- A `diagnosticId` identifies the diagnostic report and its new probes. It is + not the original failed request's ID. Server response request IDs are useful + only when actually present and matched; time/path matching is weaker evidence. + +The local JSON is written before any upload. `ingest_accepted` means that the +endpoint acknowledged the batch; `storageVerified:false` remains until support +finds every fragment in the final log store. Failed uploads preserve the local +report and upload error; do not automatically repeat the run or claim success. +Output larger than the upload budget stays local with an explicit upload error. + +Return the diagnostic ID, local report location, one new observed fact, and the +next action justified by it. If no new actionable fact was obtained, state the +specific missing evidence. Do not assign blame to antivirus, VPN, firewall, host, +or service merely from a generic failure message. Do not claim a user failure +percentage from diagnostic samples. + +This branch runs when the Agent loads it and has access to local execution. It +is not an installation hook or an all-user failure monitor. It cannot inspect a +web host's remote connection process or internal logs. With Claude Code tool +search disabled, connection failures may not be passed to the Agent at all. diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs new file mode 100644 index 0000000..20323b9 --- /dev/null +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -0,0 +1,531 @@ +import { randomUUID, createHash } from "node:crypto"; +import { open, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { homedir, tmpdir, platform, arch } from "node:os"; +import { join, resolve, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const READ_LIMIT = 128 * 1024; +const UPLOAD_LIMIT = 256 * 1024; +const TIMEOUT_MS = 3500; +const CHATCUT_MESSAGE = + /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|https:\/\/api\.chatcut\.(?:io|dev|org)\//i; + +export function redact(text) { + return String(text) + .replace(/\bBearer\s+(?!(?:token|required)\b|<)[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]") + .replace(/\b(Authorization\s*[:=]\s*Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[REDACTED]") + .replace(/\b((?:set-)?cookie\s*[:=]\s*)[^\r\n]+/gi, "$1[REDACTED]") + .replace(/\bchatcut_live_[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]") + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]") + .replace( + /([?&](?:code|state|token|access_token|refresh_token|id_token|client_secret)=)[^&\s"<>]+/gi, + "$1[REDACTED]", + ) + .replace( + /("(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)"\s*:\s*")[^"]*/gi, + "$1[REDACTED]", + ) + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); +} + +function clean(value) { + return JSON.parse( + JSON.stringify(value, (_, item) => (typeof item === "string" ? redact(item) : item)), + ); +} + +export function errorDetails(error, seen = new Set()) { + if (!error || typeof error !== "object") return { message: redact(error) }; + if (seen.has(error) || seen.size >= 8) return { unavailable: "error_chain_depth_or_cycle" }; + seen.add(error); + const result = {}; + for (const key of ["name", "message", "code", "errno", "syscall", "stack"]) { + if (typeof error[key] === "string" || typeof error[key] === "number") result[key] = error[key]; + } + if (error.cause) result.cause = errorDetails(error.cause, seen); + if (Array.isArray(error.errors)) { + result.errors = error.errors.slice(0, 8).map((item) => errorDetails(item, seen)); + if (error.errors.length > 8) result.omittedErrors = error.errors.length - 8; + } + return clean(result); +} + +export function serverUrl(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + const hosted = ["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes( + url.hostname, + ); + if ( + (!local && (!hosted || url.protocol !== "https:" || (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/api\/external-mcp\/(?:mcp|claude|v2\/mcp)\/?$/.test(url.pathname) + ) { + throw new Error( + "Unsupported registered MCP URL; preserve its configuration and report unsupported_endpoint", + ); + } + return url; +} + +async function limitedBody(response, limit = READ_LIMIT) { + const reader = response.body?.getReader(); + if (!reader) return ""; + const parts = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return Buffer.concat(parts).toString("utf8"); + bytes += value.length; + if (bytes > limit) { + const error = new Error(`Response exceeds ${limit} bytes; body not retained`); + error.code = "DIAGNOSTIC_BODY_LIMIT"; + throw error; + } + parts.push(Buffer.from(value)); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url, + startedAt, + implementation: `node-fetch/${process.version}`, + stage: "request", + status: null, + bodyComplete: false, + jsonValid: null, + originalAttemptMatch: "unconfirmed", + }; + try { + const response = await fetchImpl(url, { + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { accept: "application/json", "x-chatcut-diagnostic-id": diagnosticId }, + }); + result.status = response.status; + result.responseHeaders = {}; + for (const key of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + ]) { + const value = response.headers.get(key); + if (value !== null) result.responseHeaders[key] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + result.stage = "json_parse"; + try { + const parsed = JSON.parse(body); + result.jsonValid = true; + result.metadataShape = { + object: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed), + issuerPresent: typeof parsed?.issuer === "string", + resourcePresent: typeof parsed?.resource === "string", + }; + } catch (error) { + result.jsonValid = false; + throw error; + } + result.stage = "complete"; + const required = new URL(url).pathname.includes("oauth-protected-resource") + ? "resourcePresent" + : "issuerPresent"; + result.outcome = !response.ok + ? "http_rejected" + : result.metadataShape.object && result.metadataShape[required] + ? "metadata_read" + : "invalid_metadata_shape"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +function logMessage(body) { + const marker = body.lastIndexOf("}:"); + return (marker < 0 ? body : body.slice(marker + 2)).trim(); +} + +async function textLog(path, source, gaps) { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const start = Math.max(0, info.size - READ_LIMIT); + const buffer = Buffer.alloc(Math.min(info.size, READ_LIMIT)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); + if (start) { + lines.shift(); + gaps.push({ source, reason: "only_log_tail_read", bytesBeforeWindow: start }); + } + const entries = []; + for (let i = 0; i < lines.length; i++) { + if ( + !CHATCUT_MESSAGE.test(lines[i]) || + /function_call|tool_call|tool result|prompt|transcript|"method"\s*:\s*"tools\/call"/i.test( + lines[i], + ) || + !/connect|oauth|authenticat|metadata|handshak|initializ|tools\/list|tool catalog/i.test( + lines[i], + ) + ) + continue; + const block = [lines[i]]; + while (i + 1 < lines.length && /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1])) + block.push(lines[++i]); + entries.push({ + source, + message: redact(block.join("\n")), + originalAttemptMatch: "unconfirmed", + }); + } + if (entries.length > 30) + gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); + return entries.slice(-30); + } finally { + await handle.close(); + } +} + +export async function collectHostLogs(host, options = {}) { + const userHome = options.home ?? homedir(); + const gaps = []; + const entries = []; + const now = options.now ?? Date.now(); + const center = options.occurredAt ? Date.parse(options.occurredAt) : now; + const since = Math.floor((center - (options.occurredAt ? 15 : 30) * 60_000) / 1000); + const until = Math.floor(Math.min(now, center + 15 * 60_000) / 1000); + if (host === "codex") { + const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); + let db; + try { + await stat(join(root, "logs_2.sqlite")); + const { DatabaseSync } = await import("node:sqlite"); + db = new DatabaseSync(join(root, "logs_2.sqlite"), { readOnly: true, timeout: 1000 }); + db.exec("PRAGMA query_only=ON"); + const rows = db + .prepare( + "SELECT id,ts,level,target,feedback_log_body FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200", + ) + .all(since, until); + for (const row of rows) { + const message = logMessage(row.feedback_log_body ?? ""); + const original = row.feedback_log_body ?? ""; + if (!CHATCUT_MESSAGE.test(original) || /server_name="?chatcut_desktop/.test(original)) + continue; + if ( + !["ERROR", "WARN"].includes(row.level) && + !/omitting MCP server|cached MCP catalog/.test(message) + ) + continue; + entries.push({ + source: "codex_runtime_sqlite", + rowId: row.id, + occurredAt: new Date(row.ts * 1000).toISOString(), + target: row.target, + level: row.level, + message: redact(message), + originalAttemptMatch: "unconfirmed", + }); + if (entries.length === 30) break; + } + if (rows.length === 200 || entries.length === 30) + gaps.push({ source: "codex_runtime_sqlite", reason: "log_record_limit" }); + } catch (error) { + gaps.push({ + source: "codex_runtime_sqlite", + reason: "unavailable", + error: errorDetails(error), + }); + } finally { + db?.close(); + } + try { + entries.push(...(await textLog(join(root, "log", "codex-tui.log"), "codex_tui_log", gaps))); + } catch (error) { + gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); + } + } else { + const root = + options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(userHome, ".claude"); + try { + const dir = join(root, "debug"); + const names = await readdir(dir, { withFileTypes: true }); + const files = []; + const deadline = Date.now() + 1500; + for (const item of names) { + if (Date.now() > deadline) { + gaps.push({ source: "claude_debug_log", reason: "directory_scan_deadline" }); + break; + } + if (!item.isFile() || !item.name.endsWith(".txt")) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= since * 1000) files.push({ path, modified: info.mtimeMs }); + } + files.sort((a, b) => b.modified - a.modified); + for (const item of files.slice(0, 3)) + entries.push(...(await textLog(item.path, "claude_debug_log", gaps))); + if (files.length > 3) + gaps.push({ + source: "claude_debug_log", + reason: "log_file_limit", + omitted: files.length - 3, + }); + } catch (error) { + gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (!entries.length) + gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); + return clean({ + entries, + gaps, + window: { + since: new Date(since * 1000).toISOString(), + until: new Date(until * 1000).toISOString(), + }, + }); +} + +export function fragmentEvents(report) { + const json = JSON.stringify(clean(report)); + if (Buffer.byteLength(json) > UPLOAD_LIMIT) + throw new Error("Report exceeds upload budget; complete local report retained"); + const chunks = []; + let chunk = ""; + let bytes = 0; + for (const char of json) { + const size = Buffer.byteLength(char); + if (bytes + size > 4096) { + chunks.push(chunk); + chunk = ""; + bytes = 0; + } + chunk += char; + bytes += size; + } + if (chunk) chunks.push(chunk); + const sha256 = createHash("sha256").update(json).digest("hex"); + return chunks.map((fragment, fragmentIndex) => ({ + name: "mcp.doctor.report_fragment", + properties: { + diagnosticId: report.diagnosticId, + schemaVersion: report.schemaVersion, + collectedAt: report.collectedAt, + host: report.host, + scope: "cloud", + fragmentIndex, + fragmentCount: chunks.length, + reportSha256: sha256, + fragment, + }, + })); +} + +export async function runDoctor(options, dependencies = {}) { + if (!["codex", "claude"].includes(options.host)) + throw new Error("--host must be codex or claude"); + const endpoint = serverUrl(options.serverUrl); + if ( + options.occurredAt && + (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || + !Number.isFinite(Date.parse(options.occurredAt))) + ) { + throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); + } + const fetchImpl = dependencies.fetchImpl ?? fetch; + const diagnosticId = randomUUID(); + const report = { + schemaVersion: 1, + collectorVersion: "1.0.0", + diagnosticId, + collectedAt: new Date().toISOString(), + scope: "cloud", + host: options.host, + hostVersion: options.hostVersion ?? null, + hostVersionSource: options.hostVersion ? "agent_supplied" : "unavailable", + environment: { + platform: platform(), + architecture: arch(), + nodeVersion: process.version, + proxyEnvironmentPresent: [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ].some((key) => Boolean(process.env[key])), + probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", + }, + serverUrl: endpoint.href, + originalFailure: { + evidenceKind: "original_failure", + occurredAt: options.occurredAt ?? null, + source: "agent_supplied_error_file", + status: "unavailable", + }, + probes: [], + gaps: [], + outcome: "diagnosis_only; original_connection_recovery_unverified", + coverage: "triggered_diagnostics_only; not_all_users_or_all_failures", + correlation: + "diagnosticId identifies this report and later probes, not the original host request", + }; + try { + const manifest = + options.host === "codex" + ? "../../../.codex-plugin/plugin.json" + : "../../../.claude-plugin/plugin.json"; + report.pluginVersion = JSON.parse( + await readFile(new URL(manifest, import.meta.url), "utf8"), + ).version; + report.pluginVersionSource = + "collector_package_manifest; installed_host_plugin_not_independently_verified"; + } catch (error) { + report.gaps.push({ source: "plugin_version", error: errorDetails(error) }); + } + if (options.originalError) { + try { + if (!/\.(?:txt|log)$/i.test(basename(String(options.originalError)))) { + throw new Error( + "Original error must be a dedicated .txt or .log file; credential/config/session files are not inputs", + ); + } + const info = await stat(options.originalError); + if (info.size > 64 * 1024) + throw new Error("Original error file exceeds 64 KiB; source file retained without reading"); + report.originalFailure.message = redact(await readFile(options.originalError, "utf8")); + report.originalFailure.status = "recorded"; + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "unavailable", + error: errorDetails(error), + }); + } + } else + report.gaps.push({ + source: "original_failure", + reason: "not_provided; do_not_invent_original_error", + }); + try { + report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { + occurredAt: options.occurredAt, + }); + } catch (error) { + report.hostLogs = { + entries: [], + gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], + }; + } + const resource = endpoint.pathname.replace(/\/$/, ""); + for (const path of [ + `/.well-known/oauth-protected-resource${resource}`, + `/.well-known/oauth-authorization-server${resource}`, + "/.well-known/oauth-authorization-server", + ]) { + report.probes.push( + await probeMetadata(new URL(path, endpoint.origin).href, diagnosticId, fetchImpl), + ); + } + report.upload = { status: options.upload ? "pending" : "not_requested", storageVerified: false }; + const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); + const save = async (flag) => + writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); + await save("wx"); + if (options.upload) { + try { + const events = fragmentEvents(report); + const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { "content-type": "application/json" }, + body: JSON.stringify(events), + }); + const body = await limitedBody(response, 16 * 1024); + if (!response.ok || JSON.parse(body).ok !== true) + throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); + report.upload = { + status: "ingest_accepted", + storageVerified: false, + fragmentCount: events.length, + reportSha256: events[0].properties.reportSha256, + }; + } catch (error) { + report.upload = { status: "failed", storageVerified: false, error: errorDetails(error) }; + } + await save("w"); + } + return { path, report: clean(report) }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { values } = parseArgs({ + options: { + host: { type: "string" }, + "server-url": { type: "string" }, + "host-version": { type: "string" }, + "original-error": { type: "string" }, + "occurred-at": { type: "string" }, + output: { type: "string" }, + upload: { type: "boolean", default: false }, + help: { type: "boolean", default: false }, + }, + }); + if (values.help) { + console.log( + "node mcp-doctor.mjs --host codex|claude --server-url [--host-version ] [--original-error ] [--occurred-at ] [--output ] [--upload]", + ); + } else { + const { path, report } = await runDoctor({ + host: values.host, + serverUrl: values["server-url"], + hostVersion: values["host-version"], + originalError: values["original-error"], + occurredAt: values["occurred-at"], + output: values.output, + upload: values.upload, + }); + console.log( + JSON.stringify({ + diagnosticId: report.diagnosticId, + path, + upload: report.upload, + recoveryVerified: false, + }), + ); + } + } catch (error) { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + } +} diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md new file mode 100644 index 0000000..5624e25 --- /dev/null +++ b/docs/mcp-doctor.md @@ -0,0 +1,84 @@ +# Hosted MCP evidence report v1 + +The Codex and Claude Code basics skills route actual connection failures to a +bundled, dependency-free Node helper. This does not change the MCP URL, transport, +authentication, plugin installation, or Desktop integration. It is triggered by +the Agent during diagnosis and is not an automatic monitor of all connections. + +The two plugin packages contain identical copies of the helper and its reference +because either package must remain self-contained. Update both; tests check +parity. Node 18+ runs probes and text-log extraction. Optional Codex SQLite reads +need a runtime with `node:sqlite`; missing support is an explicit evidence gap. + +## Evidence and delivery contract + +`schemaVersion:1` reports contain `diagnosticId`, `collectedAt`, declared host and +version source, collector/package/runtime/platform versions, registered cloud server URL, +`originalFailure`, `hostLogs`, `probes`, `gaps`, and `upload`. + +The original failure, host log window, and independent probes have separate +provenance. No original request association or recovery is inferred from a later +probe. A timestamp supplied by the Agent is identified as such; missing times +remain null. Normal initial OAuth challenges are not proof of an incident. + +`pluginVersion` comes from the collector's own package manifest, identified by +`pluginVersionSource`; a separately run helper does not prove which plugin +version the host loaded. `collectorVersion` identifies the diagnostic helper. + +The default report is local only. `--upload` sends one unauthenticated POST to +`/telemetry/events` through the existing public telemetry +ingest. It never adds MCP bearer tokens. The body is an array of +`mcp.doctor.report_fragment` events, each with these properties: + +| Property | Meaning | +| --- | --- | +| `diagnosticId` | One diagnostic execution; never a user ID or original request ID | +| `schemaVersion`, `host`, `scope` | Report format and cloud host grouping | +| `collectedAt` | Collection time preserved in properties by the existing ingest | +| `fragmentIndex`, `fragmentCount` | Zero-based index and expected count | +| `reportSha256` | SHA-256 of the exact reassembled UTF-8 JSON | +| `fragment` | Up to 4096 UTF-8 bytes, split on Unicode code-point boundaries | + +The existing backend logs these as `frontend_event`, with +`clientEventName=mcp.doctor.report_fragment`. Reassemble by diagnostic ID and +hash, require all distinct indexes from zero through count minus one, concatenate +in index order, verify the hash, then parse JSON. Duplicate or missing indexes +are delivery issues, not additional user failures. A batch acknowledgement is +not proof of final-store completeness. Backend request IDs for the upload or +probes do not correlate the original host failure automatically. + +Raw report uploads above 256 KiB are refused and the complete collected local +report remains available. Each metadata response is bounded to 128 KiB and 3.5 +seconds; exceeding the limit records an incomplete probe rather than success. +The helper makes three discovery probes serially and at most one upload, without +retries. Redirects are not followed. Proxy environment presence is recorded only +as a boolean; no proxy URLs or secrets are collected. Node fetch's network stack +may differ from the host's, so a healthy probe cannot rule out a host-path issue. + +Only dedicated MCP runtime modules and matching ChatCut records are selected +from Codex's read-only SQLite database, up to 200 candidates / 30 selected rows. +Native `chatcut_desktop` records are excluded. The text fallback reads up to 128 +KiB from a known host log tail; Claude reads at most three recent debug files. +Missing logs, bounded windows, scan limits, or denied reads are explicit gaps. +Credential files and conversation transcripts are never read. Credential-shaped +values in error text are redacted while the remaining error and cause stay +intact. No separate metadata response body is retained; original JSON parser +errors may quote response text and pass through the same credential redactor. + +## Validation and pilot gate + +Run `node --test tests/*.test.mjs`. Focused tests exercise a real malformed +chunked response, invalid JSON under HTTP 200, original failure preservation +despite later healthy probes, upload rejection/local persistence, UTF-8 fragment +reassembly, credential redaction, and scoped host log extraction. + +Before release, use a still-failing user's report to establish at least one new +observed fact that changes the next investigation step. Repeating Mark's already +known HTTP 200/chunk error alone does not satisfy this gate. Rick's next request +also needs the separately owned backend auth-logging patch to distinguish a +missing bearer from a rejected bearer. Neither fixture results nor another +client's successful request establish recovery of those historical cases. + +Track diagnostic completion/gaps and actionable evidence among received reports. +Do not call these all-user connection failure rates. Browser-only hosts and +failures that do not trigger this Skill remain outside coverage. diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs new file mode 100644 index 0000000..aede450 --- /dev/null +++ b/tests/mcp-doctor.test.mjs @@ -0,0 +1,406 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { createServer as createTcpServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { + collectHostLogs, + fragmentEvents, + probeMetadata, + redact, + runDoctor, + serverUrl, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; + +async function temporary(t) { + const path = await mkdtemp(join(tmpdir(), "chatcut-mcp-doctor-test-")); + t.after(() => rm(path, { recursive: true, force: true })); + return path; +} + +async function listen(t, server) { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after( + () => + new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections?.(); + }), + ); + return `http://127.0.0.1:${server.address().port}`; +} + +const noLogs = async () => ({ entries: [], gaps: [{ reason: "fixture_no_logs" }] }); + +test("real HTTP 200 with malformed chunk framing remains a body failure with its original cause", async (t) => { + const server = createTcpServer((socket) => { + socket.once("data", () => + socket.end( + 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{"issuer":"fixture"}', + ), + ); + socket.on("error", () => {}); + }); + const origin = await listen(t, server); + const result = await probeMetadata(`${origin}/.well-known/oauth-authorization-server`, "fixture"); + assert.equal(result.status, 200); + assert.equal(result.bodyComplete, false); + assert.equal(result.stage, "response_body"); + assert.equal(result.outcome, "failed"); + assert.match( + JSON.stringify(result.error), + /chunk|HPE_INVALID_CHUNK_SIZE|Invalid character in chunk size/i, + ); +}); + +test("HTTP 200 with an HTML interception page records the original JSON parse failure", async () => { + const result = await probeMetadata( + "https://api.chatcut.io/metadata", + "fixture", + async () => new Response("fixture private contents"), + ); + assert.equal(result.status, 200); + assert.equal(result.bodyComplete, true); + assert.equal(result.jsonValid, false); + assert.equal(result.stage, "json_parse"); + assert.equal(result.outcome, "failed"); + assert.equal(result.body, undefined); + assert.equal(result.error.name, "SyntaxError"); +}); + +test("parseable JSON with missing metadata fields is not reported as valid metadata", async () => { + const result = await probeMetadata( + "https://api.chatcut.io/.well-known/oauth-authorization-server", + "fixture", + async () => new Response("{}"), + ); + assert.equal(result.jsonValid, true); + assert.equal(result.outcome, "invalid_metadata_shape"); + assert.equal(result.metadataShape.issuerPresent, false); +}); + +test("original failure survives later healthy probes and an unavailable log collector", async (t) => { + const dir = await temporary(t); + const original = join(dir, "failure.txt"); + await writeFile(original, "OAuth metadata failed\nCaused by: socket ECONNRESET"); + const { report, path } = await runDoctor( + { + host: "codex", + serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", + originalError: original, + output: join(dir, "report.json"), + }, + { + collectLogs: async () => { + throw Object.assign(new Error("read denied"), { code: "EACCES" }); + }, + fetchImpl: async () => new Response('{"issuer":"fixture","resource":"fixture"}'), + }, + ); + assert.match(report.originalFailure.message, /socket ECONNRESET/); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(report.probes.length, 3); + assert.ok( + report.probes.every( + (p) => p.outcome === "metadata_read" && p.originalAttemptMatch === "unconfirmed", + ), + ); + assert.match(report.outcome, /recovery_unverified/); + assert.equal(report.hostLogs.gaps[0].error.code, "EACCES"); + assert.equal( + JSON.parse(await readFile(path)).originalFailure.message, + report.originalFailure.message, + ); +}); + +test("upload rejection retains the prewritten local report and never replaces the original error", async (t) => { + const dir = await temporary(t); + const output = join(dir, "report.json"); + const original = join(dir, "failure.txt"); + await writeFile(original, "Original: auth failed"); + let posts = 0; + const { report } = await runDoctor( + { + host: "claude", + serverUrl: "https://api.chatcut.dev/api/external-mcp/mcp", + originalError: original, + output, + upload: true, + }, + { + collectLogs: noLogs, + fetchImpl: async (url, init) => { + if (init.method === "POST") { + posts++; + assert.equal(url, "https://api.chatcut.dev/telemetry/events"); + const local = JSON.parse(await readFile(output)); + assert.match(local.originalFailure.message, /Original: auth failed/); + return new Response("capacity reached", { status: 429 }); + } + return new Response("{}"); + }, + }, + ); + assert.equal(posts, 1); + assert.equal(report.upload.status, "failed"); + assert.match(report.upload.error.message, /429.*capacity reached/); + assert.match(JSON.parse(await readFile(output)).originalFailure.message, /Original: auth failed/); +}); + +test("accepted batch has reconstructable UTF-8 fragments and does not claim final-store delivery", async (t) => { + const dir = await temporary(t); + let uploaded; + const { report } = await runDoctor( + { + host: "codex", + serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", + output: join(dir, "report.json"), + upload: true, + }, + { + collectLogs: async () => ({ entries: [{ message: "原始故障🎬".repeat(1600) }], gaps: [] }), + fetchImpl: async (_, init) => { + if (init.method === "POST") { + assert.equal(init.headers.authorization, undefined); + uploaded = JSON.parse(init.body); + return new Response('{"ok":true}'); + } + return new Response("{}"); + }, + }, + ); + assert.equal(report.upload.status, "ingest_accepted"); + assert.equal(report.upload.storageVerified, false); + const json = uploaded + .map((event, index) => { + assert.equal(event.properties.fragmentIndex, index); + assert.equal(event.properties.fragmentCount, uploaded.length); + assert.ok(Buffer.byteLength(event.properties.fragment) <= 4096); + return event.properties.fragment; + }) + .join(""); + assert.equal(createHash("sha256").update(json).digest("hex"), report.upload.reportSha256); + assert.equal(JSON.parse(json).hostLogs.entries[0].message, "原始故障🎬".repeat(1600)); +}); + +test("oversized diagnostics are retained locally instead of silently truncated for upload", async (t) => { + const dir = await temporary(t); + let posts = 0; + const { report, path } = await runDoctor( + { + host: "codex", + serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", + output: join(dir, "report.json"), + upload: true, + }, + { + collectLogs: async () => ({ entries: [{ message: "x".repeat(280_000) }], gaps: [] }), + fetchImpl: async (_, init) => { + if (init.method === "POST") posts++; + return new Response("{}"); + }, + }, + ); + assert.equal(posts, 0); + assert.equal(report.upload.status, "failed"); + assert.match(report.upload.error.message, /upload budget/); + assert.equal(JSON.parse(await readFile(path)).hostLogs.entries[0].message.length, 280_000); +}); + +test("credentials in original errors are removed while error codes and useful detail remain", () => { + const text = + 'ECONNRESET Bearer fixture-secret https://api.chatcut.io/auth?code=secret-code&state=secret-state\nCookie: sid=secret-cookie\n{"access_token":"secret-json"}'; + const output = redact(text); + assert.match(output, /ECONNRESET/); + for (const value of [ + "fixture-secret", + "secret-code", + "secret-state", + "secret-cookie", + "secret-json", + ]) + assert.ok(!output.includes(value)); + assert.equal(redact("Bearer token required"), "Bearer token required"); + assert.equal( + redact("chatcut_managed_startup_error ECONNRESET"), + "chatcut_managed_startup_error ECONNRESET", + ); + for (const field of ["authorization", "accessToken", "refresh_token", "clientSecret", "apiKey"]) { + assert.ok(!redact(JSON.stringify({ [field]: "private-value" })).includes("private-value")); + } +}); + +test("a credential file cannot be used as the original-error input", async (t) => { + const dir = await temporary(t); + const credential = join(dir, "auth.json"); + await writeFile(credential, '{"private":"fixture-credential-value"}'); + const { report } = await runDoctor( + { + host: "codex", + serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", + originalError: credential, + output: join(dir, "report.json"), + }, + { collectLogs: noLogs, fetchImpl: async () => new Response("{}") }, + ); + assert.equal(report.originalFailure.status, "unavailable"); + assert.ok(!JSON.stringify(report).includes("fixture-credential-value")); + assert.match(report.gaps[0].error.message, /dedicated/); +}); + +for (const [host, folder, manifest] of [ + ["codex", "codex/skills/chatcut-plugin-basics", "codex/.codex-plugin/plugin.json"], + ["claude", "claude/skills/chatcut-plugin-basics-claude", "claude/.claude-plugin/plugin.json"], +]) { + test(`${host} packaged CLI writes a complete local report and receives a real loopback ingest acknowledgement`, async (t) => { + const dir = await temporary(t); + const original = join(dir, "original.txt"); + const output = join(dir, "report.json"); + await writeFile(original, "CONTROLLED FIXTURE: original metadata connection failed"); + let posts = 0; + const server = createServer(async (req, res) => { + if (req.method === "POST") { + const parts = []; + for await (const part of req) parts.push(part); + const events = JSON.parse(Buffer.concat(parts).toString()); + assert.equal(events[0].name, "mcp.doctor.report_fragment"); + posts++; + res.end('{"ok":true}'); + } else res.end('{"issuer":"fixture","resource":"fixture"}'); + }); + const origin = await listen(t, server); + const helper = fileURLToPath(new URL(`../${folder}/scripts/mcp-doctor.mjs`, import.meta.url)); + const { stdout } = await promisify(execFile)( + process.execPath, + [ + helper, + "--host", + host, + "--server-url", + `${origin}/api/external-mcp/mcp`, + "--host-version", + "fixture-1.0", + "--original-error", + original, + "--output", + output, + "--upload", + ], + { + windowsHide: true, + timeout: 10_000, + env: { ...process.env, CODEX_HOME: dir, CLAUDE_CONFIG_DIR: dir }, + }, + ); + const receipt = JSON.parse(stdout); + assert.equal(receipt.upload.status, "ingest_accepted"); + assert.equal(receipt.upload.storageVerified, false); + const report = JSON.parse(await readFile(output)); + assert.equal(report.host, host); + assert.equal(report.hostVersion, "fixture-1.0"); + assert.equal( + report.pluginVersion, + JSON.parse(await readFile(new URL(`../${manifest}`, import.meta.url))).version, + ); + assert.equal(posts, 1); + assert.equal(report.probes.length, 3); + assert.match(report.originalFailure.message, /CONTROLLED FIXTURE/); + assert.equal(receipt.recoveryVerified, false); + }); +} + +test("text extraction includes only hosted ChatCut MCP records and their cause continuation", async (t) => { + const dir = await temporary(t); + await mkdir(join(dir, ".claude", "debug"), { recursive: true }); + await writeFile( + join(dir, ".claude", "debug", "fixture.txt"), + [ + 'MCP server "other-service" connection failed with unrelated private data', + 'MCP server "chatcut_desktop" native failure', + 'MCP server "plugin:chatcut:chatcut" connection failed: ETIMEDOUT', + 'MCP server "chatcut" connection failed: ECONNRESET', + " Caused by: socket reset", + "user prompt: unrelated conversation", + 'MCP server "chatcut" Tool result: private project data', + ].join("\n"), + ); + const result = await collectHostLogs("claude", { + home: dir, + claudeConfigDir: join(dir, ".claude"), + }); + assert.ok(result.entries.some((e) => /ECONNRESET.*\n.*socket reset/.test(e.message))); + assert.ok(result.entries.some((e) => /ETIMEDOUT/.test(e.message))); + assert.ok(!JSON.stringify(result).includes("unrelated private data")); + assert.ok(!JSON.stringify(result).includes("unrelated conversation")); + assert.ok(!JSON.stringify(result).includes("native failure")); + assert.ok(!JSON.stringify(result).includes("private project data")); +}); + +test("SQLite extraction is read-only, scoped to runtime modules, and excludes native MCP and tool-call echoes", async (t) => { + const { DatabaseSync } = await import("node:sqlite"); + const dir = await temporary(t); + const db = new DatabaseSync(join(dir, "logs_2.sqlite")); + db.exec("CREATE TABLE logs(id INTEGER,ts INTEGER,level TEXT,target TEXT,feedback_log_body TEXT)"); + const add = db.prepare("INSERT INTO logs VALUES(?,?,?,?,?)"); + const ts = Math.floor(Date.now() / 1000); + add.run( + 1, + ts, + "WARN", + "codex_mcp::connection_manager", + "start{server_name=chatcut}: failed metadata request: ECONNRESET", + ); + add.run(2, ts, "WARN", "codex_core::tools", "tool output server_name=chatcut quoted old error"); + add.run( + 3, + ts, + "WARN", + "codex_mcp::connection_manager", + "start{server_name=chatcut_desktop}: native error", + ); + db.close(); + const result = await collectHostLogs("codex", { codexHome: dir }); + assert.equal(result.entries.length, 1); + assert.match(result.entries[0].message, /ECONNRESET/); + assert.equal(result.entries[0].rowId, 1); + assert.equal(result.entries[0].originalAttemptMatch, "unconfirmed"); +}); + +test("endpoint validation never silently retargets an unsupported environment or follows embedded credentials", () => { + assert.equal( + serverUrl("https://api.chatcut.dev/api/external-mcp/mcp").hostname, + "api.chatcut.dev", + ); + assert.throws(() => serverUrl("https://attacker.invalid/api/external-mcp/mcp"), /Unsupported/); + assert.throws( + () => serverUrl("https://user:secret@api.chatcut.io/api/external-mcp/mcp"), + /Unsupported/, + ); + assert.throws( + () => serverUrl("https://api.chatcut.io/api/external-mcp/mcp?token=secret"), + /Unsupported/, + ); +}); + +test("self-contained host packages ship identical helpers and diagnostic references", async () => { + for (const file of ["scripts/mcp-doctor.mjs", "references/connection-diagnostics.md"]) { + assert.equal( + await readFile( + new URL(`../codex/skills/chatcut-plugin-basics/${file}`, import.meta.url), + "utf8", + ), + await readFile( + new URL(`../claude/skills/chatcut-plugin-basics-claude/${file}`, import.meta.url), + "utf8", + ), + ); + } +}); From e68533eee87d09d8560a673374a3bad8cb7ec253 Mon Sep 17 00:00:00 2001 From: IanYet Date: Thu, 22 Oct 2026 18:59:17 +0800 Subject: [PATCH 2/5] docs(plugin): validate release without waiting for user follow-up --- docs/mcp-doctor.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index 5624e25..16a5fea 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -65,19 +65,28 @@ values in error text are redacted while the remaining error and cause stay intact. No separate metadata response body is retained; original JSON parser errors may quote response text and pass through the same credential redactor. -## Validation and pilot gate +## Release validation and production observation Run `node --test tests/*.test.mjs`. Focused tests exercise a real malformed chunked response, invalid JSON under HTTP 200, original failure preservation despite later healthy probes, upload rejection/local persistence, UTF-8 fragment reassembly, credential redaction, and scoped host log extraction. -Before release, use a still-failing user's report to establish at least one new -observed fact that changes the next investigation step. Repeating Mark's already -known HTTP 200/chunk error alone does not satisfy this gate. Rick's next request -also needs the separately owned backend auth-logging patch to distinguish a -missing bearer from a rejected bearer. Neither fixture results nor another -client's successful request establish recovery of those historical cases. +Before release, verify the controlled failure cases and use an explicitly +labelled internal report to check acknowledgement, final-store fragment +completeness, reassembly and hash. This acceptance work must not depend on a +reporter replying or reproducing an old issue. Check normal tool access in the +candidate's supported host before wider distribution. + +After release, inspect the new server auth evidence and the reports produced +when Agents trigger diagnostics. Establish which reports add an observed fact +that changes the next investigation step, and list remaining evidence gaps. +Repeating Mark's already known HTTP 200/chunk error alone is not proof of +diagnostic improvement. Rick's next request also needs the separately owned +backend auth-logging patch to distinguish a missing bearer from a rejected +bearer. A direct user follow-up is useful supplementary evidence, not a release +gate. Neither fixture results nor another client's successful request establish +recovery of those historical cases. Track diagnostic completion/gaps and actionable evidence among received reports. Do not call these all-user connection failure rates. Browser-only hosts and From a329e696e5a1b9ee56e5b946dbe19847f1ff5907 Mon Sep 17 00:00:00 2001 From: IanYet Date: Thu, 22 Oct 2026 19:12:52 +0800 Subject: [PATCH 3/5] fix(plugin): preserve Doctor reports in canonical telemetry attributes --- .../scripts/mcp-doctor.mjs | 27 ++++++++++++------- .../scripts/mcp-doctor.mjs | 27 ++++++++++++------- docs/mcp-doctor.md | 12 ++++++--- tests/mcp-doctor.test.mjs | 11 +++++--- 4 files changed, 50 insertions(+), 27 deletions(-) diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs index 20323b9..87d1f81 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -332,18 +332,25 @@ export function fragmentEvents(report) { } if (chunk) chunks.push(chunk); const sha256 = createHash("sha256").update(json).digest("hex"); + // Canonical fields and a map avoid growing the backend dataset's flat schema. return chunks.map((fragment, fragmentIndex) => ({ name: "mcp.doctor.report_fragment", properties: { - diagnosticId: report.diagnosticId, - schemaVersion: report.schemaVersion, - collectedAt: report.collectedAt, - host: report.host, - scope: "cloud", - fragmentIndex, - fragmentCount: chunks.length, - reportSha256: sha256, - fragment, + clientEventName: null, + eventName: "mcp.doctor.report_fragment", + attributes: { + mcpDoctor: { + diagnosticId: report.diagnosticId, + schemaVersion: report.schemaVersion, + collectedAt: report.collectedAt, + host: report.host, + scope: "cloud", + fragmentIndex, + fragmentCount: chunks.length, + reportSha256: sha256, + fragment, + }, + }, }, })); } @@ -477,7 +484,7 @@ export async function runDoctor(options, dependencies = {}) { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, - reportSha256: events[0].properties.reportSha256, + reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256, }; } catch (error) { report.upload = { status: "failed", storageVerified: false, error: errorDetails(error) }; diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index 20323b9..87d1f81 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -332,18 +332,25 @@ export function fragmentEvents(report) { } if (chunk) chunks.push(chunk); const sha256 = createHash("sha256").update(json).digest("hex"); + // Canonical fields and a map avoid growing the backend dataset's flat schema. return chunks.map((fragment, fragmentIndex) => ({ name: "mcp.doctor.report_fragment", properties: { - diagnosticId: report.diagnosticId, - schemaVersion: report.schemaVersion, - collectedAt: report.collectedAt, - host: report.host, - scope: "cloud", - fragmentIndex, - fragmentCount: chunks.length, - reportSha256: sha256, - fragment, + clientEventName: null, + eventName: "mcp.doctor.report_fragment", + attributes: { + mcpDoctor: { + diagnosticId: report.diagnosticId, + schemaVersion: report.schemaVersion, + collectedAt: report.collectedAt, + host: report.host, + scope: "cloud", + fragmentIndex, + fragmentCount: chunks.length, + reportSha256: sha256, + fragment, + }, + }, }, })); } @@ -477,7 +484,7 @@ export async function runDoctor(options, dependencies = {}) { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, - reportSha256: events[0].properties.reportSha256, + reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256, }; } catch (error) { report.upload = { status: "failed", storageVerified: false, error: errorDetails(error) }; diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index 16a5fea..66d517d 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -28,7 +28,12 @@ version the host loaded. `collectorVersion` identifies the diagnostic helper. The default report is local only. `--upload` sends one unauthenticated POST to `/telemetry/events` through the existing public telemetry ingest. It never adds MCP bearer tokens. The body is an array of -`mcp.doctor.report_fragment` events, each with these properties: +`mcp.doctor.report_fragment` events. The existing `eventName` is used for the +canonical event name and each fragment is nested in +`properties.attributes.mcpDoctor`. The legacy `clientEventName` alias is null so +that the endpoint does not introduce an additional flat dataset field. This +envelope was checked against beta ingestion rather than assuming that an HTTP +acknowledgement proves schema compatibility. Each fragment has these fields: | Property | Meaning | | --- | --- | @@ -39,8 +44,9 @@ ingest. It never adds MCP bearer tokens. The body is an array of | `reportSha256` | SHA-256 of the exact reassembled UTF-8 JSON | | `fragment` | Up to 4096 UTF-8 bytes, split on Unicode code-point boundaries | -The existing backend logs these as `frontend_event`, with -`clientEventName=mcp.doctor.report_fragment`. Reassemble by diagnostic ID and +The existing backend logs these with `eventName=mcp.doctor.report_fragment` and +the message `frontend_event`. Read `attributes.mcpDoctor`, or +`parse_json(attributesJson).mcpDoctor` when map fields are disabled. Reassemble by diagnostic ID and hash, require all distinct indexes from zero through count minus one, concatenate in index order, verify the hash, then parse JSON. Duplicate or missing indexes are delivery issues, not additional user failures. A batch acknowledgement is diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs index aede450..8b41174 100644 --- a/tests/mcp-doctor.test.mjs +++ b/tests/mcp-doctor.test.mjs @@ -181,10 +181,13 @@ test("accepted batch has reconstructable UTF-8 fragments and does not claim fina assert.equal(report.upload.storageVerified, false); const json = uploaded .map((event, index) => { - assert.equal(event.properties.fragmentIndex, index); - assert.equal(event.properties.fragmentCount, uploaded.length); - assert.ok(Buffer.byteLength(event.properties.fragment) <= 4096); - return event.properties.fragment; + const payload = event.properties.attributes.mcpDoctor; + assert.equal(payload.fragmentIndex, index); + assert.equal(payload.fragmentCount, uploaded.length); + assert.ok(Buffer.byteLength(payload.fragment) <= 4096); + assert.equal(event.properties.clientEventName, null); + assert.equal(event.properties.eventName, "mcp.doctor.report_fragment"); + return payload.fragment; }) .join(""); assert.equal(createHash("sha256").update(json).digest("hex"), report.upload.reportSha256); From 8ddae5a583a138273f00bee6dcda77c6fa797a9f Mon Sep 17 00:00:00 2001 From: IanYet Date: Thu, 22 Oct 2026 20:53:44 +0800 Subject: [PATCH 4/5] feat(plugin): capture host state and staged MCP diagnostic evidence --- claude/README.md | 5 +- .../chatcut-plugin-basics-claude/SKILL.md | 2 + .../references/connection-diagnostics.md | 83 +++- .../scripts/mcp-doctor.mjs | 380 ++++++++++++++-- .../scripts/mcp-evidence.mjs | 289 ++++++++++++ codex/skills/chatcut-plugin-basics/SKILL.md | 2 + .../references/connection-diagnostics.md | 83 +++- .../scripts/mcp-doctor.mjs | 380 ++++++++++++++-- .../scripts/mcp-evidence.mjs | 289 ++++++++++++ docs/mcp-doctor.md | 40 +- tests/mcp-doctor.test.mjs | 60 ++- tests/mcp-evidence.test.mjs | 410 ++++++++++++++++++ 12 files changed, 1938 insertions(+), 85 deletions(-) create mode 100644 claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs create mode 100644 codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs create mode 100644 tests/mcp-evidence.test.mjs diff --git a/claude/README.md b/claude/README.md index 3309eff..e315f1c 100644 --- a/claude/README.md +++ b/claude/README.md @@ -66,13 +66,16 @@ the workflow stops rather than bypassing that restriction. For hosted MCP connection failures, the Agent can run the bundled diagnostic helper with Node.js 18+. It reads bounded ChatCut connection log records and -checks public OAuth metadata responses, preserving the original failure +checks the anonymous MCP entry and public OAuth metadata responses, preserving the original failure separately from later probes. A local report is saved first. When network permissions permit diagnostic reporting, the Agent can send the redacted report to ChatCut's telemetry endpoint without MCP login. Credential stores and conversation transcripts are not read. Response bodies are not retained as separate fields; original parser errors may quote response text. This does not repair or replace the connection, and it does not monitor every connection attempt. +The Agent can attach a small, source-labelled current-host snapshot. Failed +HTTPS checks may additionally collect a bounded direct TLS certificate/protocol +observation, explicitly distinct from the host's connection route. See [the evidence and delivery contract](../docs/mcp-doctor.md). ## Links diff --git a/claude/skills/chatcut-plugin-basics-claude/SKILL.md b/claude/skills/chatcut-plugin-basics-claude/SKILL.md index e3b3f00..6746cbd 100644 --- a/claude/skills/chatcut-plugin-basics-claude/SKILL.md +++ b/claude/skills/chatcut-plugin-basics-claude/SKILL.md @@ -28,6 +28,8 @@ If the tools are available and authenticated, continue normal work without reins When an existing connection or OAuth attempt reports an error, or tools remain absent after successful authentication, preserve that evidence and follow [connection diagnostics](references/connection-diagnostics.md) before retrying login or changing anything. An initial sign-in requirement by itself is expected, not a confirmed connection defect. The diagnostic helper collects evidence independently of the broken MCP connection; it does not require ChatCut Desktop. +Also use connection diagnostics when previously available tools disappear or the host reports an unfamiliar MCP/protocol error. Capture the current-host snapshot described in that reference; preserve unknown errors and stages. + 1. **Server not registered:** Offer to register the Hosted MCP server directly at user scope. Do not install, reinstall, repair, or enable a plugin to obtain the server: ```sh diff --git a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md index 93920f9..c5ee5b8 100644 --- a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md +++ b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md @@ -12,12 +12,69 @@ network permissions; do not add a separate manual step when those permit it. Honor a refusal or a denied read/network operation. Do not change permission settings to make collection work. +## Capture the current host, including unfamiliar failures + +The Agent creates a dedicated `.mcp-snapshot.json` from facts already available +through the current host. The user does not fill a form. Do not copy an entire +configuration, credential file, environment, or conversation. Preserve unfamiliar +errors as well as known OAuth errors; classify uncertain stages as unknown. +Static CLI configuration describes registration, not tools loaded into the +current chat. Browser sign-in does not establish MCP authentication. + +Snapshot schema 1 accepts this shape; omit unavailable groups, use null for an +unknown tool count/enabled flag, and label each group's actual source: + +```json +{ + "schemaVersion": 1, + "observedAt": "2026-10-22T12:00:00Z", + "hostVersion": "actual-running-host-version", + "pluginVersion": "actual-loaded-plugin-version-if-known", + "configuration": { + "source": "host_settings", + "serverName": "chatcut", + "url": "https://api.chatcut.io/api/external-mcp/mcp", + "enabled": true + }, + "session": { + "source": "current_session", + "conversationId": "host-chat-id-if-available", + "connectionStatus": "failed", + "authStatus": "unknown", + "visibleToolCount": 0, + "visibleTools": [] + }, + "observations": [{ + "stage": "initialize", + "operation": "initialize", + "source": "runtime_log", + "observedAt": "2026-10-22T11:59:00Z", + "outcome": "failed", + "requestId": "server-request-id-only-if-recorded", + "message": "Exact original error, including an unfamiliar error code" + }] +} +``` + +Sources: `current_session`, `tool_search`, `host_settings`, +`cli_configuration`, `runtime_log`. A session group cannot claim +`cli_configuration` as its source. Connection status is `connected`, `failed`, +`needs_authentication`, `disabled`, or `unknown`; auth status is +`authenticated`, `needs_authentication`, or `unknown`. +Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` +(`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, +`protocolCode`, and `message`. New stage names are preserved. Do not put an +MCP session credential in `conversationId` or guess a server request ID. +Only include ChatCut tool names; keep tool arguments and results out. + ## Run once for the reported failure 1. Obtain the existing server's registered URL through the host. Preserve its environment. The helper accepts ChatCut production/beta/dev endpoints and loopback development servers; it reports unsupported endpoints rather than - silently probing production. Never read credential stores or paste complete + silently probing production. If no registered URL is available, omit + `--server-url` to collect a local report without probing or uploading. + Never read credential stores or paste complete configuration, environment, or conversation dumps into the report. An intentionally disabled hosted server is not a failed connection; preserve that choice instead of starting diagnostics or re-enabling it. @@ -29,7 +86,7 @@ settings to make collection work. 3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: ```text - node /scripts/mcp-doctor.mjs --host codex --server-url --original-error --host-version --upload + node /scripts/mcp-doctor.mjs --host codex --server-url --host-snapshot --original-error --host-version --upload ``` For Claude Code, use `--host claude`. Quote paths as shell arguments; do not @@ -46,16 +103,30 @@ coverage gap, not proof that the server has no errors. The helper reads only bounded ChatCut MCP runtime log records or debug log tails. It never reads `auth.json`, OAuth credential stores, session transcripts, or -other project files. It sends three unauthenticated GET requests to the same -registered origin's public discovery endpoints, with a 3.5-second timeout each. -It does not run login or start an MCP/desktop process. Its Node HTTP transport -can differ from the host's proxy/TLS stack; it records that limitation. +other project files. It makes one unauthenticated initialize probe and at most +five public metadata GETs, all within the selected origin and with a 3.5-second +timeout each. An anonymous 401 challenge is expected. It validates resource / +issuer consistency and this product's OAuth-code/PKCE metadata. It never logs in, +refreshes credentials, or lists tools using extracted credentials. + +When an HTTPS request/body fails, one additional direct TLS connection may +record the peer certificate issuer and protocol, with the same timeout and +normal certificate verification. This is a separately labelled route, not the +original host's TLS/proxy path. Proxy/CA environment presence is boolean only. +A healthy later check cannot establish recovery or identify antivirus/VPN as +the cause. A disabled connection sends neither probes nor an upload. ## Interpret the report - `originalFailure` is the original error supplied by the Agent. `hostLogs` are matching records in a bounded time window, with unconfirmed attempt association. `probes` are new requests made later. Keep these separate. +- `hostSnapshot` is Agent-supplied evidence with per-group sources, not an + independently verified host API result. `evidenceSummary` keeps original + stage observations separate from independent checks. Unknown stages and + unfamiliar errors remain available; missing stages are `not_observed`. +- Timestamped text records outside the requested window are excluded. Records + without parseable timestamps are labelled time-unknown, never window matches. - Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. A healthy later probe never proves the original host connection recovered. - Preserve original error messages and codes. Redact actual credentials, not diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs index 87d1f81..33fbbe2 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -4,12 +4,15 @@ import { homedir, tmpdir, platform, arch } from "node:os"; import { join, resolve, basename } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; +import { connect as connectTls } from "node:tls"; +import { isIP } from "node:net"; +import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; const READ_LIMIT = 128 * 1024; const UPLOAD_LIMIT = 256 * 1024; const TIMEOUT_MS = 3500; const CHATCUT_MESSAGE = - /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|https:\/\/api\.chatcut\.(?:io|dev|org)\//i; + /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|\bplugin(?:_name)?[=: "\t]+chatcut(?:@chatcut-inc)?(?:["\s}:]|$)|https:\/\/(?:api\.chatcut\.(?:io|dev)|api-dev\.chatcut\.org)\//i; export function redact(text) { return String(text) @@ -19,7 +22,11 @@ export function redact(text) { .replace(/\bchatcut_live_[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]") .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]") .replace( - /([?&](?:code|state|token|access_token|refresh_token|id_token|client_secret)=)[^&\s"<>]+/gi, + /([?&](?:code|state|token|access_token|refresh_token|id_token|client_secret|code_verifier|client_assertion|api[_-]?key|signature|sig|x-amz-(?:signature|credential|security-token))=)[^&\s"<>]+/gi, + "$1[REDACTED]", + ) + .replace( + /\b((?:access_?token|refresh_?token|id_?token|client_?secret|api[_-]?key)\s*[:=]\s*)[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]", ) .replace( @@ -31,7 +38,15 @@ export function redact(text) { function clean(value) { return JSON.parse( - JSON.stringify(value, (_, item) => (typeof item === "string" ? redact(item) : item)), + JSON.stringify(value, (key, item) => + /^(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( + key, + ) + ? "[REDACTED]" + : typeof item === "string" + ? redact(item) + : item, + ), ); } @@ -96,7 +111,7 @@ async function limitedBody(response, limit = READ_LIMIT) { } } -export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { +export async function probeMetadata(url, diagnosticId, fetchImpl = fetch, expected = {}) { const startedAt = new Date().toISOString(); const started = Date.now(); const result = { @@ -127,6 +142,9 @@ export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { "via", "x-request-id", "x-amzn-trace-id", + "www-authenticate", + "retry-after", + "location", ]) { const value = response.headers.get(key); if (value !== null) result.responseHeaders[key] = value; @@ -145,6 +163,7 @@ export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { issuerPresent: typeof parsed?.issuer === "string", resourcePresent: typeof parsed?.resource === "string", }; + result.metadataValidation = inspectMetadata(parsed, url, expected); } catch (error) { result.jsonValid = false; throw error; @@ -156,7 +175,9 @@ export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { result.outcome = !response.ok ? "http_rejected" : result.metadataShape.object && result.metadataShape[required] - ? "metadata_read" + ? result.metadataValidation.valid + ? "metadata_read" + : "invalid_metadata" : "invalid_metadata_shape"; } catch (error) { result.outcome = "failed"; @@ -166,12 +187,179 @@ export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { return clean(result); } +async function guardProbe(url, operation) { + const startedAt = new Date().toISOString(); + try { + return await operation(); + } catch (error) { + return { + evidenceKind: "later_probe", + url, + startedAt, + outcome: "failed", + stage: "probe_collector", + status: null, + originalAttemptMatch: "unconfirmed", + error: errorDetails(error), + gap: "partial_probe_evidence_unavailable; original_failure_retained", + }; + } +} + +export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: endpoint.href, + method: "POST", + operation: "initialize", + implementation: "node-fetch/" + process.version, + startedAt: new Date().toISOString(), + stage: "request", + status: null, + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + }; + try { + const response = await fetchImpl(endpoint.href, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "x-chatcut-diagnostic-id": diagnosticId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: diagnosticId, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.1.0" }, + }, + }), + }); + result.status = response.status; + result.responseHeaders = {}; + for (const name of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "www-authenticate", + "retry-after", + "location", + "x-request-id", + "x-amzn-trace-id", + "server", + "via", + ]) { + const value = response.headers.get(name); + if (value !== null) result.responseHeaders[name] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + let parsed; + try { + parsed = JSON.parse(body); + } catch (error) { + result.parseError = errorDetails(error); + if (!response.ok) result.responseError = { message: redact(body) }; + } + if (parsed?.error) result.protocolError = clean(parsed.error); + if (response.status === 401) { + const header = response.headers.get("www-authenticate") ?? ""; + const metadataHint = header.match(/resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i); + result.resourceMetadataUrl = metadataHint?.[1] ?? metadataHint?.[2] ?? null; + result.outcome = /\bBearer\b/i.test(header) + ? "authentication_challenge" + : "missing_authentication_challenge"; + } else if (!response.ok) result.outcome = "http_rejected"; + else if (parsed?.error) result.outcome = "protocol_error"; + else if (parsed?.jsonrpc === "2.0" && typeof parsed.result?.protocolVersion === "string") { + result.outcome = "anonymous_initialize_returned"; + result.serverInfo = parsed.result.serverInfo; + result.protocolVersion = parsed.result.protocolVersion; + } else result.outcome = "invalid_initialize_response"; + result.stage = "complete"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +export function inspectTransport(endpoint) { + if (endpoint.protocol !== "https:") + return Promise.resolve({ status: "not_applicable", reason: "not_tls" }); + return new Promise((resolveProbe) => { + const startedAt = new Date().toISOString(); + let settled = false; + const host = endpoint.hostname.replace(/^\[|\]$/g, ""); + const socket = connectTls({ + host, + port: Number(endpoint.port || 443), + rejectUnauthorized: true, + servername: isIP(host) ? undefined : host, + }); + const finish = (details) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolveProbe( + clean({ + evidenceKind: "later_probe", + implementation: "node:tls", + startedAt, + route: "direct_tls; does_not_reproduce_host_or_fetch_proxy_route", + originalAttemptMatch: "unconfirmed", + ...details, + }), + ); + }; + const timer = setTimeout( + () => + finish({ + status: "failed", + error: { + name: "TimeoutError", + message: "Direct TLS context timed out", + code: "ETIMEDOUT", + }, + }), + TIMEOUT_MS, + ); + socket.once("secureConnect", () => { + const certificate = socket.getPeerCertificate(); + finish({ + status: "observed", + tlsProtocol: socket.getProtocol(), + authorized: socket.authorized, + certificate: { + issuer: certificate.issuer, + validFrom: certificate.valid_from, + validTo: certificate.valid_to, + fingerprint256: certificate.fingerprint256, + }, + }); + }); + socket.once("error", (error) => finish({ status: "failed", error: errorDetails(error) })); + }); +} + function logMessage(body) { const marker = body.lastIndexOf("}:"); return (marker < 0 ? body : body.slice(marker + 2)).trim(); } -async function textLog(path, source, gaps) { +async function textLog(path, source, gaps, window) { const handle = await open(path, "r"); try { const info = await handle.stat(); @@ -187,10 +375,7 @@ async function textLog(path, source, gaps) { for (let i = 0; i < lines.length; i++) { if ( !CHATCUT_MESSAGE.test(lines[i]) || - /function_call|tool_call|tool result|prompt|transcript|"method"\s*:\s*"tools\/call"/i.test( - lines[i], - ) || - !/connect|oauth|authenticat|metadata|handshak|initializ|tools\/list|tool catalog/i.test( + /chatcut_desktop|function_call|tool_call|tool result|user prompt|transcript|"method"\s*:\s*"tools\/call"/i.test( lines[i], ) ) @@ -198,14 +383,27 @@ async function textLog(path, source, gaps) { const block = [lines[i]]; while (i + 1 < lines.length && /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1])) block.push(lines[++i]); + const occurredAt = logTime(block[0]); + if ( + occurredAt && + (Date.parse(occurredAt) < window.since * 1000 || + Date.parse(occurredAt) > window.until * 1000) + ) + continue; entries.push({ source, + sourceFile: basename(path), + occurredAt, + timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", message: redact(block.join("\n")), originalAttemptMatch: "unconfirmed", }); } if (entries.length > 30) gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); + const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; + if (unknownTimes) + gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); return entries.slice(-30); } finally { await handle.close(); @@ -230,7 +428,7 @@ export async function collectHostLogs(host, options = {}) { db.exec("PRAGMA query_only=ON"); const rows = db .prepare( - "SELECT id,ts,level,target,feedback_log_body FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200", + "SELECT id,ts,level,target,feedback_log_body FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200", ) .all(since, until); for (const row of rows) { @@ -266,7 +464,12 @@ export async function collectHostLogs(host, options = {}) { db?.close(); } try { - entries.push(...(await textLog(join(root, "log", "codex-tui.log"), "codex_tui_log", gaps))); + entries.push( + ...(await textLog(join(root, "log", "codex-tui.log"), "codex_tui_log", gaps, { + since, + until, + })), + ); } catch (error) { gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); } @@ -290,7 +493,7 @@ export async function collectHostLogs(host, options = {}) { } files.sort((a, b) => b.modified - a.modified); for (const item of files.slice(0, 3)) - entries.push(...(await textLog(item.path, "claude_debug_log", gaps))); + entries.push(...(await textLog(item.path, "claude_debug_log", gaps, { since, until }))); if (files.length > 3) gaps.push({ source: "claude_debug_log", @@ -358,7 +561,7 @@ export function fragmentEvents(report) { export async function runDoctor(options, dependencies = {}) { if (!["codex", "claude"].includes(options.host)) throw new Error("--host must be codex or claude"); - const endpoint = serverUrl(options.serverUrl); + const endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; if ( options.occurredAt && (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || @@ -369,8 +572,8 @@ export async function runDoctor(options, dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? fetch; const diagnosticId = randomUUID(); const report = { - schemaVersion: 1, - collectorVersion: "1.0.0", + schemaVersion: 2, + collectorVersion: "1.1.0", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", @@ -390,8 +593,12 @@ export async function runDoctor(options, dependencies = {}) { "all_proxy", ].some((key) => Boolean(process.env[key])), probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", + noProxyEnvironmentPresent: Boolean(process.env.NO_PROXY || process.env.no_proxy), + nodeUseEnvProxyRequested: process.env.NODE_USE_ENV_PROXY === "1", + extraCaConfigured: Boolean(process.env.NODE_EXTRA_CA_CERTS), + tlsVerificationDisabledInEnvironment: process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0", }, - serverUrl: endpoint.href, + serverUrl: endpoint?.href ?? null, originalFailure: { evidenceKind: "original_failure", occurredAt: options.occurredAt ?? null, @@ -405,6 +612,31 @@ export async function runDoctor(options, dependencies = {}) { correlation: "diagnosticId identifies this report and later probes, not the original host request", }; + if (options.hostSnapshot) { + try { + if (!basename(String(options.hostSnapshot)).endsWith(".mcp-snapshot.json")) + throw new Error( + "Host snapshot must be a dedicated .mcp-snapshot.json file, never a credential/config file", + ); + const info = await stat(options.hostSnapshot); + if (info.size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + report.hostSnapshot = clean( + validateSnapshot(JSON.parse(await readFile(options.hostSnapshot, "utf8")), endpoint), + ); + report.hostSnapshotSource = + "agent_supplied; see each field source; not_independently_verified"; + if (!report.hostVersion && report.hostSnapshot.hostVersion) { + report.hostVersion = report.hostSnapshot.hostVersion; + report.hostVersionSource = "agent_supplied_host_snapshot"; + } + } catch (error) { + report.gaps.push({ + source: "host_snapshot", + reason: "unavailable", + error: errorDetails(error), + }); + } + } try { const manifest = options.host === "codex" @@ -452,22 +684,112 @@ export async function runDoctor(options, dependencies = {}) { gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], }; } - const resource = endpoint.pathname.replace(/\/$/, ""); - for (const path of [ - `/.well-known/oauth-protected-resource${resource}`, - `/.well-known/oauth-authorization-server${resource}`, - "/.well-known/oauth-authorization-server", - ]) { - report.probes.push( - await probeMetadata(new URL(path, endpoint.origin).href, diagnosticId, fetchImpl), + const disabled = + report.hostSnapshot?.configuration?.enabled === false || + report.hostSnapshot?.session?.connectionStatus === "disabled"; + if (disabled || !endpoint) { + report.gaps.push({ + source: "probes", + reason: disabled + ? "host_reports_intentionally_disabled; no_requests_sent" + : "registered_endpoint_unavailable; no_requests_sent", + }); + } else { + report.entryProbe = await guardProbe(endpoint.href, () => + probeEntry(endpoint, diagnosticId, fetchImpl), ); + const resource = endpoint.pathname.replace(/\/$/, ""); + const queue = [ + new URL("/.well-known/oauth-protected-resource" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server", endpoint.origin).href, + ]; + const enqueue = (value, label) => { + try { + const url = new URL(value); + if ( + url.origin !== endpoint.origin || + url.username || + url.password || + url.search || + url.hash || + !url.pathname.startsWith("/.well-known/") + ) + throw new Error( + "Unsupported metadata target; no cross-origin or credential-bearing probes", + ); + if (!queue.includes(url.href)) queue.push(url.href); + } catch (error) { + report.gaps.push({ source: label, error: errorDetails(error) }); + } + }; + if (report.entryProbe.resourceMetadataUrl) + enqueue(report.entryProbe.resourceMetadataUrl, "challenge_metadata_target"); + let issuer; + for (let index = 0; index < queue.length && index < 5; index++) { + const url = queue[index]; + const result = await guardProbe(url, () => + probeMetadata(url, diagnosticId, fetchImpl, { + resource: endpoint.href, + issuer: url.includes("oauth-authorization-server") ? issuer : undefined, + }), + ); + report.probes.push(result); + if (url === queue[0] && result.outcome !== "metadata_read") + enqueue( + new URL("/.well-known/oauth-protected-resource", endpoint.origin).href, + "root_resource_metadata_target", + ); + const fields = result.metadataValidation?.fields; + if (url.includes("oauth-protected-resource") && result.metadataValidation?.valid) { + issuer = fields.authorization_servers[0]; + const auth = new URL(issuer); + enqueue( + new URL( + "/.well-known/oauth-authorization-server" + auth.pathname.replace(/\/$/, ""), + auth.origin, + ).href, + "issuer_metadata_target", + ); + } + } + if (queue.length > 5) + report.gaps.push({ + source: "metadata_probes", + reason: "probe_limit", + omitted: queue.length - 5, + }); + if ( + endpoint.protocol === "https:" && + [report.entryProbe, ...report.probes].some( + (probe) => probe.outcome === "failed" && probe.stage !== "probe_collector", + ) + ) { + try { + report.transportContext = await (dependencies.inspectTransport ?? inspectTransport)( + endpoint, + ); + } catch (error) { + report.transportContext = { status: "failed", error: errorDetails(error) }; + } + } } - report.upload = { status: options.upload ? "pending" : "not_requested", storageVerified: false }; + report.evidenceSummary = evidenceSummary(report); + report.upload = { + status: disabled + ? "skipped_intentionally_disabled" + : !endpoint + ? "skipped_endpoint_unavailable" + : options.upload + ? "pending" + : "not_requested", + storageVerified: false, + }; const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); const save = async (flag) => writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); await save("wx"); - if (options.upload) { + if (options.upload && !disabled && endpoint) { try { const events = fragmentEvents(report); const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { @@ -501,6 +823,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur host: { type: "string" }, "server-url": { type: "string" }, "host-version": { type: "string" }, + "host-snapshot": { type: "string" }, "original-error": { type: "string" }, "occurred-at": { type: "string" }, output: { type: "string" }, @@ -510,13 +833,14 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur }); if (values.help) { console.log( - "node mcp-doctor.mjs --host codex|claude --server-url [--host-version ] [--original-error ] [--occurred-at ] [--output ] [--upload]", + "node mcp-doctor.mjs --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--original-error ] [--occurred-at ] [--output ] [--upload]", ); } else { const { path, report } = await runDoctor({ host: values.host, serverUrl: values["server-url"], hostVersion: values["host-version"], + hostSnapshot: values["host-snapshot"], originalError: values["original-error"], occurredAt: values["occurred-at"], output: values.output, diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs new file mode 100644 index 0000000..df2a931 --- /dev/null +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs @@ -0,0 +1,289 @@ +const STAGES = [ + "plugin_loading", + "discovery", + "client_registration", + "authorization", + "token_exchange", + "token_refresh", + "initialize", + "tools_list", + "session_tools", +]; +const SOURCES = [ + "current_session", + "tool_search", + "host_settings", + "cli_configuration", + "runtime_log", +]; +const TIMESTAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/; + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function keys(value, allowed) { + if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) + throw new Error("Snapshot contains unsupported fields; use only the dedicated evidence schema"); +} + +function source(value) { + if (!SOURCES.includes(value)) + throw new Error("Snapshot evidence needs an explicit supported source"); +} + +function instant(value) { + return typeof value === "string" && TIMESTAMP.test(value) && Number.isFinite(Date.parse(value)); +} + +export function validateSnapshot(input, endpoint) { + keys(input, [ + "schemaVersion", + "observedAt", + "hostVersion", + "pluginVersion", + "configuration", + "session", + "observations", + ]); + if (input.schemaVersion !== 1 || !instant(input.observedAt)) + throw new Error("Snapshot requires schemaVersion 1 and observedAt with a timezone"); + for (const key of ["hostVersion", "pluginVersion"]) + if (input[key] !== undefined && (typeof input[key] !== "string" || input[key].length > 256)) + throw new Error("Invalid snapshot version"); + if (input.configuration) { + const config = input.configuration; + keys(config, ["source", "serverName", "url", "enabled"]); + source(config.source); + if (!["chatcut", "plugin:chatcut:chatcut"].includes(config.serverName)) + throw new Error("Snapshot must describe the hosted ChatCut server"); + if (!publicUrl(config.url, true)) + throw new Error("Snapshot URL must be a public URL without embedded credentials"); + if (endpoint && new URL(config.url).href !== endpoint.href) + throw new Error( + "Snapshot URL differs from the explicitly selected server; preserve both configurations", + ); + if (config.enabled !== null && typeof config.enabled !== "boolean") + throw new Error("Snapshot enabled state must be boolean or null"); + } + if (input.session) { + const session = input.session; + keys(session, [ + "source", + "conversationId", + "connectionStatus", + "authStatus", + "visibleToolCount", + "visibleTools", + ]); + source(session.source); + if (session.source === "cli_configuration") + throw new Error("Static CLI configuration cannot describe current session tool availability"); + if ( + !["connected", "failed", "needs_authentication", "disabled", "unknown"].includes( + session.connectionStatus, + ) + ) + throw new Error("Unsupported connection status"); + if (!["authenticated", "needs_authentication", "unknown"].includes(session.authStatus)) + throw new Error("Unsupported authentication status"); + if ( + session.visibleToolCount !== null && + (!Number.isSafeInteger(session.visibleToolCount) || session.visibleToolCount < 0) + ) + throw new Error("Visible tool count must be a nonnegative integer or null"); + if (session.conversationId !== undefined && typeof session.conversationId !== "string") + throw new Error("conversationId must be a host conversation identifier"); + if ( + session.visibleTools !== undefined && + (!Array.isArray(session.visibleTools) || + session.visibleTools.length > 256 || + session.visibleTools.some( + (name) => typeof name !== "string" || !/^[A-Za-z0-9_:.-]{1,256}$/.test(name), + )) + ) + throw new Error("visibleTools must contain only ChatCut tool names, at most 256"); + if ( + session.visibleTools && + session.visibleToolCount !== null && + session.visibleTools.length > session.visibleToolCount + ) + throw new Error("Tool name count exceeds the observed tool count"); + } + if (input.observations !== undefined) { + if (!Array.isArray(input.observations) || input.observations.length > 32) + throw new Error("Snapshot accepts at most 32 stage observations"); + for (const item of input.observations) { + keys(item, [ + "stage", + "operation", + "source", + "observedAt", + "outcome", + "requestId", + "httpStatus", + "protocolCode", + "message", + ]); + source(item.source); + if ( + !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || + !["success", "failed", "unknown"].includes(item.outcome) || + !instant(item.observedAt) + ) + throw new Error("Stage observation needs a stage, outcome, and timestamp"); + for (const field of ["operation", "requestId", "message"]) + if (item[field] !== undefined && typeof item[field] !== "string") + throw new Error("Observation text fields must be strings"); + for (const field of ["httpStatus", "protocolCode"]) + if (item[field] !== undefined && !Number.isSafeInteger(item[field])) + throw new Error("Observation status/code must be an integer"); + } + } + return input; +} + +export function logTime(line) { + const match = line.match(TIMESTAMP)?.[0]; + return match && Number.isFinite(Date.parse(match)) ? new Date(match).toISOString() : null; +} + +export function stageHint(message) { + if (/tools\/list|tool catalog|tool list/i.test(message)) return "tools_list"; + if (/initializ|handshake/i.test(message)) return "initialize"; + if (/refresh.{0,20}token|token.{0,20}refresh/i.test(message)) return "token_refresh"; + if (/token.{0,20}exchange|exchange.{0,20}token|\/mcp\/token/i.test(message)) + return "token_exchange"; + if (/registr|\/mcp\/register/i.test(message)) return "client_registration"; + if (/callback|authoriz|oauth/i.test(message) && !/metadata|discovery/i.test(message)) + return "authorization"; + if (/metadata|discovery/i.test(message)) return "discovery"; + return "unclassified"; +} + +export function evidenceSummary(report) { + const snapshot = report.hostSnapshot; + const observations = snapshot?.observations ?? []; + const gaps = []; + if (!snapshot) gaps.push("current_host_snapshot_unavailable"); + if (!snapshot?.configuration) gaps.push("registration_state_unavailable"); + if (!snapshot?.session || snapshot.session.visibleToolCount === null) + gaps.push("current_session_tools_unavailable"); + if (report.originalFailure.status !== "recorded") gaps.push("original_failure_unavailable"); + if (!report.originalFailure.occurredAt) gaps.push("original_failure_time_unavailable"); + const stages = STAGES.map((stage) => ({ + stage, + observations: observations.filter((item) => item.stage === stage), + status: observations.some((item) => item.stage === stage) + ? "observed; see individual attempts" + : "not_observed", + })); + const unknown = observations.filter((item) => !STAGES.includes(item.stage)); + const logHints = report.hostLogs.entries.map((entry, index) => ({ + entryIndex: index, + stage: stageHint(entry.message), + interpretation: "text_hint_only; attempt_match_unconfirmed", + })); + return { + stages, + otherObservations: unknown, + logHints, + gaps, + independentChecks: + "entryProbe/probes/transportContext are later requests, never original stage completions", + recoveryVerified: false, + }; +} + +function publicUrl(value, localAllowed = false) { + try { + const url = new URL(value); + return ( + !url.username && + !url.password && + !url.hash && + (url.protocol === "https:" || + (localAllowed && + url.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) + ); + } catch { + return false; + } +} + +export function inspectMetadata(document, url, expected = {}) { + const issues = []; + const result = { valid: false, issues, fields: {} }; + if (!object(document)) { + issues.push({ field: "document", reason: "expected_object" }); + return result; + } + for (const field of [ + "resource", + "authorization_servers", + "issuer", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "grant_types_supported", + "response_types_supported", + "code_challenge_methods_supported", + "token_endpoint_auth_methods_supported", + ]) + if (document[field] !== undefined) result.fields[field] = document[field]; + const protectedResource = new URL(url).pathname.includes("oauth-protected-resource"); + const requireUrl = (field) => { + if (typeof document[field] !== "string" || !publicUrl(document[field], true)) + issues.push({ field, reason: "missing_or_invalid_public_url" }); + }; + if (protectedResource) { + requireUrl("resource"); + const suffix = new URL(url).pathname.split("/.well-known/oauth-protected-resource")[1]; + const resource = expected.resource ?? (suffix ? new URL(url).origin + suffix : undefined); + if (resource && document.resource !== resource) + issues.push({ + field: "resource", + reason: "resource_mismatch", + expected: resource, + actual: document.resource, + }); + if ( + !Array.isArray(document.authorization_servers) || + !document.authorization_servers.length || + document.authorization_servers.some((item) => !publicUrl(item, true)) + ) + issues.push({ + field: "authorization_servers", + reason: "missing_or_invalid_authorization_servers", + }); + } else { + requireUrl("issuer"); + requireUrl("authorization_endpoint"); + requireUrl("token_endpoint"); + if ( + Array.isArray(document.grant_types_supported) && + !document.grant_types_supported.includes("authorization_code") + ) + issues.push({ field: "grant_types_supported", reason: "authorization_code_not_supported" }); + if ( + !Array.isArray(document.response_types_supported) || + !document.response_types_supported.includes("code") + ) + issues.push({ field: "response_types_supported", reason: "code_response_not_advertised" }); + if (expected.issuer && document.issuer !== expected.issuer) + issues.push({ + field: "issuer", + reason: "issuer_mismatch", + expected: expected.issuer, + actual: document.issuer, + }); + if ( + !Array.isArray(document.code_challenge_methods_supported) || + !document.code_challenge_methods_supported.includes("S256") + ) + issues.push({ field: "code_challenge_methods_supported", reason: "S256_not_advertised" }); + } + result.valid = issues.length === 0; + return result; +} diff --git a/codex/skills/chatcut-plugin-basics/SKILL.md b/codex/skills/chatcut-plugin-basics/SKILL.md index f0ff9f7..2689615 100644 --- a/codex/skills/chatcut-plugin-basics/SKILL.md +++ b/codex/skills/chatcut-plugin-basics/SKILL.md @@ -28,6 +28,8 @@ If the tools are available and authenticated, continue normal work without reins When an existing connection or OAuth attempt reports an error, or tools remain absent after successful authentication, preserve that evidence and follow [connection diagnostics](references/connection-diagnostics.md) before retrying login or changing anything. An initial sign-in requirement by itself is expected, not a confirmed connection defect. The diagnostic helper collects evidence independently of the broken MCP connection; it does not require ChatCut Desktop. +Also use connection diagnostics when previously available tools disappear or the host reports an unfamiliar MCP/protocol error. Capture the current-host snapshot described in that reference; preserve unknown errors and stages. + 1. **Server not registered:** Offer to register the Hosted MCP server directly. Do not install, reinstall, repair, or enable a plugin to obtain the server: ```sh diff --git a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md index 93920f9..c5ee5b8 100644 --- a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md +++ b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md @@ -12,12 +12,69 @@ network permissions; do not add a separate manual step when those permit it. Honor a refusal or a denied read/network operation. Do not change permission settings to make collection work. +## Capture the current host, including unfamiliar failures + +The Agent creates a dedicated `.mcp-snapshot.json` from facts already available +through the current host. The user does not fill a form. Do not copy an entire +configuration, credential file, environment, or conversation. Preserve unfamiliar +errors as well as known OAuth errors; classify uncertain stages as unknown. +Static CLI configuration describes registration, not tools loaded into the +current chat. Browser sign-in does not establish MCP authentication. + +Snapshot schema 1 accepts this shape; omit unavailable groups, use null for an +unknown tool count/enabled flag, and label each group's actual source: + +```json +{ + "schemaVersion": 1, + "observedAt": "2026-10-22T12:00:00Z", + "hostVersion": "actual-running-host-version", + "pluginVersion": "actual-loaded-plugin-version-if-known", + "configuration": { + "source": "host_settings", + "serverName": "chatcut", + "url": "https://api.chatcut.io/api/external-mcp/mcp", + "enabled": true + }, + "session": { + "source": "current_session", + "conversationId": "host-chat-id-if-available", + "connectionStatus": "failed", + "authStatus": "unknown", + "visibleToolCount": 0, + "visibleTools": [] + }, + "observations": [{ + "stage": "initialize", + "operation": "initialize", + "source": "runtime_log", + "observedAt": "2026-10-22T11:59:00Z", + "outcome": "failed", + "requestId": "server-request-id-only-if-recorded", + "message": "Exact original error, including an unfamiliar error code" + }] +} +``` + +Sources: `current_session`, `tool_search`, `host_settings`, +`cli_configuration`, `runtime_log`. A session group cannot claim +`cli_configuration` as its source. Connection status is `connected`, `failed`, +`needs_authentication`, `disabled`, or `unknown`; auth status is +`authenticated`, `needs_authentication`, or `unknown`. +Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` +(`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, +`protocolCode`, and `message`. New stage names are preserved. Do not put an +MCP session credential in `conversationId` or guess a server request ID. +Only include ChatCut tool names; keep tool arguments and results out. + ## Run once for the reported failure 1. Obtain the existing server's registered URL through the host. Preserve its environment. The helper accepts ChatCut production/beta/dev endpoints and loopback development servers; it reports unsupported endpoints rather than - silently probing production. Never read credential stores or paste complete + silently probing production. If no registered URL is available, omit + `--server-url` to collect a local report without probing or uploading. + Never read credential stores or paste complete configuration, environment, or conversation dumps into the report. An intentionally disabled hosted server is not a failed connection; preserve that choice instead of starting diagnostics or re-enabling it. @@ -29,7 +86,7 @@ settings to make collection work. 3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: ```text - node /scripts/mcp-doctor.mjs --host codex --server-url --original-error --host-version --upload + node /scripts/mcp-doctor.mjs --host codex --server-url --host-snapshot --original-error --host-version --upload ``` For Claude Code, use `--host claude`. Quote paths as shell arguments; do not @@ -46,16 +103,30 @@ coverage gap, not proof that the server has no errors. The helper reads only bounded ChatCut MCP runtime log records or debug log tails. It never reads `auth.json`, OAuth credential stores, session transcripts, or -other project files. It sends three unauthenticated GET requests to the same -registered origin's public discovery endpoints, with a 3.5-second timeout each. -It does not run login or start an MCP/desktop process. Its Node HTTP transport -can differ from the host's proxy/TLS stack; it records that limitation. +other project files. It makes one unauthenticated initialize probe and at most +five public metadata GETs, all within the selected origin and with a 3.5-second +timeout each. An anonymous 401 challenge is expected. It validates resource / +issuer consistency and this product's OAuth-code/PKCE metadata. It never logs in, +refreshes credentials, or lists tools using extracted credentials. + +When an HTTPS request/body fails, one additional direct TLS connection may +record the peer certificate issuer and protocol, with the same timeout and +normal certificate verification. This is a separately labelled route, not the +original host's TLS/proxy path. Proxy/CA environment presence is boolean only. +A healthy later check cannot establish recovery or identify antivirus/VPN as +the cause. A disabled connection sends neither probes nor an upload. ## Interpret the report - `originalFailure` is the original error supplied by the Agent. `hostLogs` are matching records in a bounded time window, with unconfirmed attempt association. `probes` are new requests made later. Keep these separate. +- `hostSnapshot` is Agent-supplied evidence with per-group sources, not an + independently verified host API result. `evidenceSummary` keeps original + stage observations separate from independent checks. Unknown stages and + unfamiliar errors remain available; missing stages are `not_observed`. +- Timestamped text records outside the requested window are excluded. Records + without parseable timestamps are labelled time-unknown, never window matches. - Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. A healthy later probe never proves the original host connection recovered. - Preserve original error messages and codes. Redact actual credentials, not diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index 87d1f81..33fbbe2 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -4,12 +4,15 @@ import { homedir, tmpdir, platform, arch } from "node:os"; import { join, resolve, basename } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; +import { connect as connectTls } from "node:tls"; +import { isIP } from "node:net"; +import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; const READ_LIMIT = 128 * 1024; const UPLOAD_LIMIT = 256 * 1024; const TIMEOUT_MS = 3500; const CHATCUT_MESSAGE = - /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|https:\/\/api\.chatcut\.(?:io|dev|org)\//i; + /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|\bplugin(?:_name)?[=: "\t]+chatcut(?:@chatcut-inc)?(?:["\s}:]|$)|https:\/\/(?:api\.chatcut\.(?:io|dev)|api-dev\.chatcut\.org)\//i; export function redact(text) { return String(text) @@ -19,7 +22,11 @@ export function redact(text) { .replace(/\bchatcut_live_[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]") .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]") .replace( - /([?&](?:code|state|token|access_token|refresh_token|id_token|client_secret)=)[^&\s"<>]+/gi, + /([?&](?:code|state|token|access_token|refresh_token|id_token|client_secret|code_verifier|client_assertion|api[_-]?key|signature|sig|x-amz-(?:signature|credential|security-token))=)[^&\s"<>]+/gi, + "$1[REDACTED]", + ) + .replace( + /\b((?:access_?token|refresh_?token|id_?token|client_?secret|api[_-]?key)\s*[:=]\s*)[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]", ) .replace( @@ -31,7 +38,15 @@ export function redact(text) { function clean(value) { return JSON.parse( - JSON.stringify(value, (_, item) => (typeof item === "string" ? redact(item) : item)), + JSON.stringify(value, (key, item) => + /^(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( + key, + ) + ? "[REDACTED]" + : typeof item === "string" + ? redact(item) + : item, + ), ); } @@ -96,7 +111,7 @@ async function limitedBody(response, limit = READ_LIMIT) { } } -export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { +export async function probeMetadata(url, diagnosticId, fetchImpl = fetch, expected = {}) { const startedAt = new Date().toISOString(); const started = Date.now(); const result = { @@ -127,6 +142,9 @@ export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { "via", "x-request-id", "x-amzn-trace-id", + "www-authenticate", + "retry-after", + "location", ]) { const value = response.headers.get(key); if (value !== null) result.responseHeaders[key] = value; @@ -145,6 +163,7 @@ export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { issuerPresent: typeof parsed?.issuer === "string", resourcePresent: typeof parsed?.resource === "string", }; + result.metadataValidation = inspectMetadata(parsed, url, expected); } catch (error) { result.jsonValid = false; throw error; @@ -156,7 +175,9 @@ export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { result.outcome = !response.ok ? "http_rejected" : result.metadataShape.object && result.metadataShape[required] - ? "metadata_read" + ? result.metadataValidation.valid + ? "metadata_read" + : "invalid_metadata" : "invalid_metadata_shape"; } catch (error) { result.outcome = "failed"; @@ -166,12 +187,179 @@ export async function probeMetadata(url, diagnosticId, fetchImpl = fetch) { return clean(result); } +async function guardProbe(url, operation) { + const startedAt = new Date().toISOString(); + try { + return await operation(); + } catch (error) { + return { + evidenceKind: "later_probe", + url, + startedAt, + outcome: "failed", + stage: "probe_collector", + status: null, + originalAttemptMatch: "unconfirmed", + error: errorDetails(error), + gap: "partial_probe_evidence_unavailable; original_failure_retained", + }; + } +} + +export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: endpoint.href, + method: "POST", + operation: "initialize", + implementation: "node-fetch/" + process.version, + startedAt: new Date().toISOString(), + stage: "request", + status: null, + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + }; + try { + const response = await fetchImpl(endpoint.href, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "x-chatcut-diagnostic-id": diagnosticId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: diagnosticId, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.1.0" }, + }, + }), + }); + result.status = response.status; + result.responseHeaders = {}; + for (const name of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "www-authenticate", + "retry-after", + "location", + "x-request-id", + "x-amzn-trace-id", + "server", + "via", + ]) { + const value = response.headers.get(name); + if (value !== null) result.responseHeaders[name] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + let parsed; + try { + parsed = JSON.parse(body); + } catch (error) { + result.parseError = errorDetails(error); + if (!response.ok) result.responseError = { message: redact(body) }; + } + if (parsed?.error) result.protocolError = clean(parsed.error); + if (response.status === 401) { + const header = response.headers.get("www-authenticate") ?? ""; + const metadataHint = header.match(/resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i); + result.resourceMetadataUrl = metadataHint?.[1] ?? metadataHint?.[2] ?? null; + result.outcome = /\bBearer\b/i.test(header) + ? "authentication_challenge" + : "missing_authentication_challenge"; + } else if (!response.ok) result.outcome = "http_rejected"; + else if (parsed?.error) result.outcome = "protocol_error"; + else if (parsed?.jsonrpc === "2.0" && typeof parsed.result?.protocolVersion === "string") { + result.outcome = "anonymous_initialize_returned"; + result.serverInfo = parsed.result.serverInfo; + result.protocolVersion = parsed.result.protocolVersion; + } else result.outcome = "invalid_initialize_response"; + result.stage = "complete"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +export function inspectTransport(endpoint) { + if (endpoint.protocol !== "https:") + return Promise.resolve({ status: "not_applicable", reason: "not_tls" }); + return new Promise((resolveProbe) => { + const startedAt = new Date().toISOString(); + let settled = false; + const host = endpoint.hostname.replace(/^\[|\]$/g, ""); + const socket = connectTls({ + host, + port: Number(endpoint.port || 443), + rejectUnauthorized: true, + servername: isIP(host) ? undefined : host, + }); + const finish = (details) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolveProbe( + clean({ + evidenceKind: "later_probe", + implementation: "node:tls", + startedAt, + route: "direct_tls; does_not_reproduce_host_or_fetch_proxy_route", + originalAttemptMatch: "unconfirmed", + ...details, + }), + ); + }; + const timer = setTimeout( + () => + finish({ + status: "failed", + error: { + name: "TimeoutError", + message: "Direct TLS context timed out", + code: "ETIMEDOUT", + }, + }), + TIMEOUT_MS, + ); + socket.once("secureConnect", () => { + const certificate = socket.getPeerCertificate(); + finish({ + status: "observed", + tlsProtocol: socket.getProtocol(), + authorized: socket.authorized, + certificate: { + issuer: certificate.issuer, + validFrom: certificate.valid_from, + validTo: certificate.valid_to, + fingerprint256: certificate.fingerprint256, + }, + }); + }); + socket.once("error", (error) => finish({ status: "failed", error: errorDetails(error) })); + }); +} + function logMessage(body) { const marker = body.lastIndexOf("}:"); return (marker < 0 ? body : body.slice(marker + 2)).trim(); } -async function textLog(path, source, gaps) { +async function textLog(path, source, gaps, window) { const handle = await open(path, "r"); try { const info = await handle.stat(); @@ -187,10 +375,7 @@ async function textLog(path, source, gaps) { for (let i = 0; i < lines.length; i++) { if ( !CHATCUT_MESSAGE.test(lines[i]) || - /function_call|tool_call|tool result|prompt|transcript|"method"\s*:\s*"tools\/call"/i.test( - lines[i], - ) || - !/connect|oauth|authenticat|metadata|handshak|initializ|tools\/list|tool catalog/i.test( + /chatcut_desktop|function_call|tool_call|tool result|user prompt|transcript|"method"\s*:\s*"tools\/call"/i.test( lines[i], ) ) @@ -198,14 +383,27 @@ async function textLog(path, source, gaps) { const block = [lines[i]]; while (i + 1 < lines.length && /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1])) block.push(lines[++i]); + const occurredAt = logTime(block[0]); + if ( + occurredAt && + (Date.parse(occurredAt) < window.since * 1000 || + Date.parse(occurredAt) > window.until * 1000) + ) + continue; entries.push({ source, + sourceFile: basename(path), + occurredAt, + timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", message: redact(block.join("\n")), originalAttemptMatch: "unconfirmed", }); } if (entries.length > 30) gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); + const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; + if (unknownTimes) + gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); return entries.slice(-30); } finally { await handle.close(); @@ -230,7 +428,7 @@ export async function collectHostLogs(host, options = {}) { db.exec("PRAGMA query_only=ON"); const rows = db .prepare( - "SELECT id,ts,level,target,feedback_log_body FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200", + "SELECT id,ts,level,target,feedback_log_body FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200", ) .all(since, until); for (const row of rows) { @@ -266,7 +464,12 @@ export async function collectHostLogs(host, options = {}) { db?.close(); } try { - entries.push(...(await textLog(join(root, "log", "codex-tui.log"), "codex_tui_log", gaps))); + entries.push( + ...(await textLog(join(root, "log", "codex-tui.log"), "codex_tui_log", gaps, { + since, + until, + })), + ); } catch (error) { gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); } @@ -290,7 +493,7 @@ export async function collectHostLogs(host, options = {}) { } files.sort((a, b) => b.modified - a.modified); for (const item of files.slice(0, 3)) - entries.push(...(await textLog(item.path, "claude_debug_log", gaps))); + entries.push(...(await textLog(item.path, "claude_debug_log", gaps, { since, until }))); if (files.length > 3) gaps.push({ source: "claude_debug_log", @@ -358,7 +561,7 @@ export function fragmentEvents(report) { export async function runDoctor(options, dependencies = {}) { if (!["codex", "claude"].includes(options.host)) throw new Error("--host must be codex or claude"); - const endpoint = serverUrl(options.serverUrl); + const endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; if ( options.occurredAt && (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || @@ -369,8 +572,8 @@ export async function runDoctor(options, dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? fetch; const diagnosticId = randomUUID(); const report = { - schemaVersion: 1, - collectorVersion: "1.0.0", + schemaVersion: 2, + collectorVersion: "1.1.0", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", @@ -390,8 +593,12 @@ export async function runDoctor(options, dependencies = {}) { "all_proxy", ].some((key) => Boolean(process.env[key])), probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", + noProxyEnvironmentPresent: Boolean(process.env.NO_PROXY || process.env.no_proxy), + nodeUseEnvProxyRequested: process.env.NODE_USE_ENV_PROXY === "1", + extraCaConfigured: Boolean(process.env.NODE_EXTRA_CA_CERTS), + tlsVerificationDisabledInEnvironment: process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0", }, - serverUrl: endpoint.href, + serverUrl: endpoint?.href ?? null, originalFailure: { evidenceKind: "original_failure", occurredAt: options.occurredAt ?? null, @@ -405,6 +612,31 @@ export async function runDoctor(options, dependencies = {}) { correlation: "diagnosticId identifies this report and later probes, not the original host request", }; + if (options.hostSnapshot) { + try { + if (!basename(String(options.hostSnapshot)).endsWith(".mcp-snapshot.json")) + throw new Error( + "Host snapshot must be a dedicated .mcp-snapshot.json file, never a credential/config file", + ); + const info = await stat(options.hostSnapshot); + if (info.size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + report.hostSnapshot = clean( + validateSnapshot(JSON.parse(await readFile(options.hostSnapshot, "utf8")), endpoint), + ); + report.hostSnapshotSource = + "agent_supplied; see each field source; not_independently_verified"; + if (!report.hostVersion && report.hostSnapshot.hostVersion) { + report.hostVersion = report.hostSnapshot.hostVersion; + report.hostVersionSource = "agent_supplied_host_snapshot"; + } + } catch (error) { + report.gaps.push({ + source: "host_snapshot", + reason: "unavailable", + error: errorDetails(error), + }); + } + } try { const manifest = options.host === "codex" @@ -452,22 +684,112 @@ export async function runDoctor(options, dependencies = {}) { gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], }; } - const resource = endpoint.pathname.replace(/\/$/, ""); - for (const path of [ - `/.well-known/oauth-protected-resource${resource}`, - `/.well-known/oauth-authorization-server${resource}`, - "/.well-known/oauth-authorization-server", - ]) { - report.probes.push( - await probeMetadata(new URL(path, endpoint.origin).href, diagnosticId, fetchImpl), + const disabled = + report.hostSnapshot?.configuration?.enabled === false || + report.hostSnapshot?.session?.connectionStatus === "disabled"; + if (disabled || !endpoint) { + report.gaps.push({ + source: "probes", + reason: disabled + ? "host_reports_intentionally_disabled; no_requests_sent" + : "registered_endpoint_unavailable; no_requests_sent", + }); + } else { + report.entryProbe = await guardProbe(endpoint.href, () => + probeEntry(endpoint, diagnosticId, fetchImpl), ); + const resource = endpoint.pathname.replace(/\/$/, ""); + const queue = [ + new URL("/.well-known/oauth-protected-resource" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server", endpoint.origin).href, + ]; + const enqueue = (value, label) => { + try { + const url = new URL(value); + if ( + url.origin !== endpoint.origin || + url.username || + url.password || + url.search || + url.hash || + !url.pathname.startsWith("/.well-known/") + ) + throw new Error( + "Unsupported metadata target; no cross-origin or credential-bearing probes", + ); + if (!queue.includes(url.href)) queue.push(url.href); + } catch (error) { + report.gaps.push({ source: label, error: errorDetails(error) }); + } + }; + if (report.entryProbe.resourceMetadataUrl) + enqueue(report.entryProbe.resourceMetadataUrl, "challenge_metadata_target"); + let issuer; + for (let index = 0; index < queue.length && index < 5; index++) { + const url = queue[index]; + const result = await guardProbe(url, () => + probeMetadata(url, diagnosticId, fetchImpl, { + resource: endpoint.href, + issuer: url.includes("oauth-authorization-server") ? issuer : undefined, + }), + ); + report.probes.push(result); + if (url === queue[0] && result.outcome !== "metadata_read") + enqueue( + new URL("/.well-known/oauth-protected-resource", endpoint.origin).href, + "root_resource_metadata_target", + ); + const fields = result.metadataValidation?.fields; + if (url.includes("oauth-protected-resource") && result.metadataValidation?.valid) { + issuer = fields.authorization_servers[0]; + const auth = new URL(issuer); + enqueue( + new URL( + "/.well-known/oauth-authorization-server" + auth.pathname.replace(/\/$/, ""), + auth.origin, + ).href, + "issuer_metadata_target", + ); + } + } + if (queue.length > 5) + report.gaps.push({ + source: "metadata_probes", + reason: "probe_limit", + omitted: queue.length - 5, + }); + if ( + endpoint.protocol === "https:" && + [report.entryProbe, ...report.probes].some( + (probe) => probe.outcome === "failed" && probe.stage !== "probe_collector", + ) + ) { + try { + report.transportContext = await (dependencies.inspectTransport ?? inspectTransport)( + endpoint, + ); + } catch (error) { + report.transportContext = { status: "failed", error: errorDetails(error) }; + } + } } - report.upload = { status: options.upload ? "pending" : "not_requested", storageVerified: false }; + report.evidenceSummary = evidenceSummary(report); + report.upload = { + status: disabled + ? "skipped_intentionally_disabled" + : !endpoint + ? "skipped_endpoint_unavailable" + : options.upload + ? "pending" + : "not_requested", + storageVerified: false, + }; const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); const save = async (flag) => writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); await save("wx"); - if (options.upload) { + if (options.upload && !disabled && endpoint) { try { const events = fragmentEvents(report); const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { @@ -501,6 +823,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur host: { type: "string" }, "server-url": { type: "string" }, "host-version": { type: "string" }, + "host-snapshot": { type: "string" }, "original-error": { type: "string" }, "occurred-at": { type: "string" }, output: { type: "string" }, @@ -510,13 +833,14 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur }); if (values.help) { console.log( - "node mcp-doctor.mjs --host codex|claude --server-url [--host-version ] [--original-error ] [--occurred-at ] [--output ] [--upload]", + "node mcp-doctor.mjs --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--original-error ] [--occurred-at ] [--output ] [--upload]", ); } else { const { path, report } = await runDoctor({ host: values.host, serverUrl: values["server-url"], hostVersion: values["host-version"], + hostSnapshot: values["host-snapshot"], originalError: values["original-error"], occurredAt: values["occurred-at"], output: values.output, diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs new file mode 100644 index 0000000..df2a931 --- /dev/null +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs @@ -0,0 +1,289 @@ +const STAGES = [ + "plugin_loading", + "discovery", + "client_registration", + "authorization", + "token_exchange", + "token_refresh", + "initialize", + "tools_list", + "session_tools", +]; +const SOURCES = [ + "current_session", + "tool_search", + "host_settings", + "cli_configuration", + "runtime_log", +]; +const TIMESTAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/; + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function keys(value, allowed) { + if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) + throw new Error("Snapshot contains unsupported fields; use only the dedicated evidence schema"); +} + +function source(value) { + if (!SOURCES.includes(value)) + throw new Error("Snapshot evidence needs an explicit supported source"); +} + +function instant(value) { + return typeof value === "string" && TIMESTAMP.test(value) && Number.isFinite(Date.parse(value)); +} + +export function validateSnapshot(input, endpoint) { + keys(input, [ + "schemaVersion", + "observedAt", + "hostVersion", + "pluginVersion", + "configuration", + "session", + "observations", + ]); + if (input.schemaVersion !== 1 || !instant(input.observedAt)) + throw new Error("Snapshot requires schemaVersion 1 and observedAt with a timezone"); + for (const key of ["hostVersion", "pluginVersion"]) + if (input[key] !== undefined && (typeof input[key] !== "string" || input[key].length > 256)) + throw new Error("Invalid snapshot version"); + if (input.configuration) { + const config = input.configuration; + keys(config, ["source", "serverName", "url", "enabled"]); + source(config.source); + if (!["chatcut", "plugin:chatcut:chatcut"].includes(config.serverName)) + throw new Error("Snapshot must describe the hosted ChatCut server"); + if (!publicUrl(config.url, true)) + throw new Error("Snapshot URL must be a public URL without embedded credentials"); + if (endpoint && new URL(config.url).href !== endpoint.href) + throw new Error( + "Snapshot URL differs from the explicitly selected server; preserve both configurations", + ); + if (config.enabled !== null && typeof config.enabled !== "boolean") + throw new Error("Snapshot enabled state must be boolean or null"); + } + if (input.session) { + const session = input.session; + keys(session, [ + "source", + "conversationId", + "connectionStatus", + "authStatus", + "visibleToolCount", + "visibleTools", + ]); + source(session.source); + if (session.source === "cli_configuration") + throw new Error("Static CLI configuration cannot describe current session tool availability"); + if ( + !["connected", "failed", "needs_authentication", "disabled", "unknown"].includes( + session.connectionStatus, + ) + ) + throw new Error("Unsupported connection status"); + if (!["authenticated", "needs_authentication", "unknown"].includes(session.authStatus)) + throw new Error("Unsupported authentication status"); + if ( + session.visibleToolCount !== null && + (!Number.isSafeInteger(session.visibleToolCount) || session.visibleToolCount < 0) + ) + throw new Error("Visible tool count must be a nonnegative integer or null"); + if (session.conversationId !== undefined && typeof session.conversationId !== "string") + throw new Error("conversationId must be a host conversation identifier"); + if ( + session.visibleTools !== undefined && + (!Array.isArray(session.visibleTools) || + session.visibleTools.length > 256 || + session.visibleTools.some( + (name) => typeof name !== "string" || !/^[A-Za-z0-9_:.-]{1,256}$/.test(name), + )) + ) + throw new Error("visibleTools must contain only ChatCut tool names, at most 256"); + if ( + session.visibleTools && + session.visibleToolCount !== null && + session.visibleTools.length > session.visibleToolCount + ) + throw new Error("Tool name count exceeds the observed tool count"); + } + if (input.observations !== undefined) { + if (!Array.isArray(input.observations) || input.observations.length > 32) + throw new Error("Snapshot accepts at most 32 stage observations"); + for (const item of input.observations) { + keys(item, [ + "stage", + "operation", + "source", + "observedAt", + "outcome", + "requestId", + "httpStatus", + "protocolCode", + "message", + ]); + source(item.source); + if ( + !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || + !["success", "failed", "unknown"].includes(item.outcome) || + !instant(item.observedAt) + ) + throw new Error("Stage observation needs a stage, outcome, and timestamp"); + for (const field of ["operation", "requestId", "message"]) + if (item[field] !== undefined && typeof item[field] !== "string") + throw new Error("Observation text fields must be strings"); + for (const field of ["httpStatus", "protocolCode"]) + if (item[field] !== undefined && !Number.isSafeInteger(item[field])) + throw new Error("Observation status/code must be an integer"); + } + } + return input; +} + +export function logTime(line) { + const match = line.match(TIMESTAMP)?.[0]; + return match && Number.isFinite(Date.parse(match)) ? new Date(match).toISOString() : null; +} + +export function stageHint(message) { + if (/tools\/list|tool catalog|tool list/i.test(message)) return "tools_list"; + if (/initializ|handshake/i.test(message)) return "initialize"; + if (/refresh.{0,20}token|token.{0,20}refresh/i.test(message)) return "token_refresh"; + if (/token.{0,20}exchange|exchange.{0,20}token|\/mcp\/token/i.test(message)) + return "token_exchange"; + if (/registr|\/mcp\/register/i.test(message)) return "client_registration"; + if (/callback|authoriz|oauth/i.test(message) && !/metadata|discovery/i.test(message)) + return "authorization"; + if (/metadata|discovery/i.test(message)) return "discovery"; + return "unclassified"; +} + +export function evidenceSummary(report) { + const snapshot = report.hostSnapshot; + const observations = snapshot?.observations ?? []; + const gaps = []; + if (!snapshot) gaps.push("current_host_snapshot_unavailable"); + if (!snapshot?.configuration) gaps.push("registration_state_unavailable"); + if (!snapshot?.session || snapshot.session.visibleToolCount === null) + gaps.push("current_session_tools_unavailable"); + if (report.originalFailure.status !== "recorded") gaps.push("original_failure_unavailable"); + if (!report.originalFailure.occurredAt) gaps.push("original_failure_time_unavailable"); + const stages = STAGES.map((stage) => ({ + stage, + observations: observations.filter((item) => item.stage === stage), + status: observations.some((item) => item.stage === stage) + ? "observed; see individual attempts" + : "not_observed", + })); + const unknown = observations.filter((item) => !STAGES.includes(item.stage)); + const logHints = report.hostLogs.entries.map((entry, index) => ({ + entryIndex: index, + stage: stageHint(entry.message), + interpretation: "text_hint_only; attempt_match_unconfirmed", + })); + return { + stages, + otherObservations: unknown, + logHints, + gaps, + independentChecks: + "entryProbe/probes/transportContext are later requests, never original stage completions", + recoveryVerified: false, + }; +} + +function publicUrl(value, localAllowed = false) { + try { + const url = new URL(value); + return ( + !url.username && + !url.password && + !url.hash && + (url.protocol === "https:" || + (localAllowed && + url.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) + ); + } catch { + return false; + } +} + +export function inspectMetadata(document, url, expected = {}) { + const issues = []; + const result = { valid: false, issues, fields: {} }; + if (!object(document)) { + issues.push({ field: "document", reason: "expected_object" }); + return result; + } + for (const field of [ + "resource", + "authorization_servers", + "issuer", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "grant_types_supported", + "response_types_supported", + "code_challenge_methods_supported", + "token_endpoint_auth_methods_supported", + ]) + if (document[field] !== undefined) result.fields[field] = document[field]; + const protectedResource = new URL(url).pathname.includes("oauth-protected-resource"); + const requireUrl = (field) => { + if (typeof document[field] !== "string" || !publicUrl(document[field], true)) + issues.push({ field, reason: "missing_or_invalid_public_url" }); + }; + if (protectedResource) { + requireUrl("resource"); + const suffix = new URL(url).pathname.split("/.well-known/oauth-protected-resource")[1]; + const resource = expected.resource ?? (suffix ? new URL(url).origin + suffix : undefined); + if (resource && document.resource !== resource) + issues.push({ + field: "resource", + reason: "resource_mismatch", + expected: resource, + actual: document.resource, + }); + if ( + !Array.isArray(document.authorization_servers) || + !document.authorization_servers.length || + document.authorization_servers.some((item) => !publicUrl(item, true)) + ) + issues.push({ + field: "authorization_servers", + reason: "missing_or_invalid_authorization_servers", + }); + } else { + requireUrl("issuer"); + requireUrl("authorization_endpoint"); + requireUrl("token_endpoint"); + if ( + Array.isArray(document.grant_types_supported) && + !document.grant_types_supported.includes("authorization_code") + ) + issues.push({ field: "grant_types_supported", reason: "authorization_code_not_supported" }); + if ( + !Array.isArray(document.response_types_supported) || + !document.response_types_supported.includes("code") + ) + issues.push({ field: "response_types_supported", reason: "code_response_not_advertised" }); + if (expected.issuer && document.issuer !== expected.issuer) + issues.push({ + field: "issuer", + reason: "issuer_mismatch", + expected: expected.issuer, + actual: document.issuer, + }); + if ( + !Array.isArray(document.code_challenge_methods_supported) || + !document.code_challenge_methods_supported.includes("S256") + ) + issues.push({ field: "code_challenge_methods_supported", reason: "S256_not_advertised" }); + } + result.valid = issues.length === 0; + return result; +} diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index 66d517d..5da481c 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -1,4 +1,4 @@ -# Hosted MCP evidence report v1 +# Hosted MCP evidence report v2 The Codex and Claude Code basics skills route actual connection failures to a bundled, dependency-free Node helper. This does not change the MCP URL, transport, @@ -12,9 +12,19 @@ need a runtime with `node:sqlite`; missing support is an explicit evidence gap. ## Evidence and delivery contract -`schemaVersion:1` reports contain `diagnosticId`, `collectedAt`, declared host and +`schemaVersion:2` reports contain `diagnosticId`, `collectedAt`, declared host and version source, collector/package/runtime/platform versions, registered cloud server URL, -`originalFailure`, `hostLogs`, `probes`, `gaps`, and `upload`. +`originalFailure`, `hostSnapshot` when available, `hostLogs`, `entryProbe`, +`probes`, optional `transportContext`, `evidenceSummary`, `gaps`, and `upload`. + +The Agent supplies a dedicated schema-1 `.mcp-snapshot.json` using the basics +Skill reference. It identifies configuration vs. current-session sources, +connection/auth state, visible ChatCut tools and actual per-stage observations. +Unknown stages and errors are retained. Missing facts remain unknown. +The helper validates the schema but cannot independently verify Agent-supplied +facts. It does not run a new authenticated session or extract credentials. +Missing registered URL permits local-only collection; a disabled hosted server +sends neither diagnostic requests nor an upload. The original failure, host log window, and independent probes have separate provenance. No original request association or recovery is inferred from a later @@ -56,16 +66,28 @@ probes do not correlate the original host failure automatically. Raw report uploads above 256 KiB are refused and the complete collected local report remains available. Each metadata response is bounded to 128 KiB and 3.5 seconds; exceeding the limit records an incomplete probe rather than success. -The helper makes three discovery probes serially and at most one upload, without -retries. Redirects are not followed. Proxy environment presence is recorded only +The helper makes one anonymous initialize POST, at most five metadata GETs, +and at most one upload, without retries. A normal 401 challenge is recorded as +such. Metadata checks validate resource/issuer consistency and the product's +OAuth-code/PKCE fields rather than only JSON syntax. Targets discovered in +challenge/issuer metadata stay within the explicitly selected origin. +Redirects are not followed. Proxy environment presence is recorded only as a boolean; no proxy URLs or secrets are collected. Node fetch's network stack may differ from the host's, so a healthy probe cannot rule out a host-path issue. +On an HTTPS request/body failure, at most one additional direct TLS connection +may record the certificate issuer and TLS protocol. It retains normal +certificate verification, is limited to 3.5 seconds, and is labelled as a +different route from the host/fetch proxy path. This is not antivirus attribution. -Only dedicated MCP runtime modules and matching ChatCut records are selected +Only dedicated MCP/plugin runtime modules and matching ChatCut records are selected from Codex's read-only SQLite database, up to 200 candidates / 30 selected rows. Native `chatcut_desktop` records are excluded. The text fallback reads up to 128 KiB from a known host log tail; Claude reads at most three recent debug files. Missing logs, bounded windows, scan limits, or denied reads are explicit gaps. +Timestamped text records outside the requested window are excluded; unknown +times are explicit. Unfamiliar records do not need a known connection-error +keyword to survive extraction. Other-service/native records, tool-call echoes, +and conversation payloads remain excluded. Credential files and conversation transcripts are never read. Credential-shaped values in error text are redacted while the remaining error and cause stay intact. No separate metadata response body is retained; original JSON parser @@ -97,3 +119,9 @@ recovery of those historical cases. Track diagnostic completion/gaps and actionable evidence among received reports. Do not call these all-user connection failure rates. Browser-only hosts and failures that do not trigger this Skill remain outside coverage. + +The previously verified beta envelope remains the fragment delivery contract; +it does not establish that every new v2 field has been exercised by a real +failing host. Local controlled checks and post-release case evidence remain +separate. The current coverage follow-up is local and must be reviewed before +updating the existing draft PR or any release. diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs index 8b41174..ff56df9 100644 --- a/tests/mcp-doctor.test.mjs +++ b/tests/mcp-doctor.test.mjs @@ -15,7 +15,7 @@ import { fragmentEvents, probeMetadata, redact, - runDoctor, + runDoctor as runDoctorImpl, serverUrl, } from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; @@ -39,6 +39,23 @@ async function listen(t, server) { } const noLogs = async () => ({ entries: [], gaps: [{ reason: "fixture_no_logs" }] }); +const runDoctor = (options, dependencies) => + runDoctorImpl(options, { + inspectTransport: async () => ({ status: "fixture; no_network" }), + ...dependencies, + }); +const metadataResponse = () => + new Response( + JSON.stringify({ + issuer: "https://api.chatcut.io", + authorization_endpoint: "https://api.chatcut.io/auth/mcp/authorize", + token_endpoint: "https://api.chatcut.io/auth/mcp/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + resource: "https://api.chatcut.io/api/external-mcp/mcp", + authorization_servers: ["https://api.chatcut.io"], + }), + ); test("real HTTP 200 with malformed chunk framing remains a body failure with its original cause", async (t) => { const server = createTcpServer((socket) => { @@ -102,7 +119,7 @@ test("original failure survives later healthy probes and an unavailable log coll collectLogs: async () => { throw Object.assign(new Error("read denied"), { code: "EACCES" }); }, - fetchImpl: async () => new Response('{"issuer":"fixture","resource":"fixture"}'), + fetchImpl: metadataResponse, }, ); assert.match(report.originalFailure.message, /socket ECONNRESET/); @@ -138,7 +155,7 @@ test("upload rejection retains the prewritten local report and never replaces th { collectLogs: noLogs, fetchImpl: async (url, init) => { - if (init.method === "POST") { + if (url.endsWith("/telemetry/events")) { posts++; assert.equal(url, "https://api.chatcut.dev/telemetry/events"); const local = JSON.parse(await readFile(output)); @@ -167,8 +184,8 @@ test("accepted batch has reconstructable UTF-8 fragments and does not claim fina }, { collectLogs: async () => ({ entries: [{ message: "原始故障🎬".repeat(1600) }], gaps: [] }), - fetchImpl: async (_, init) => { - if (init.method === "POST") { + fetchImpl: async (url, init) => { + if (url.endsWith("/telemetry/events")) { assert.equal(init.headers.authorization, undefined); uploaded = JSON.parse(init.body); return new Response('{"ok":true}'); @@ -206,8 +223,8 @@ test("oversized diagnostics are retained locally instead of silently truncated f }, { collectLogs: async () => ({ entries: [{ message: "x".repeat(280_000) }], gaps: [] }), - fetchImpl: async (_, init) => { - if (init.method === "POST") posts++; + fetchImpl: async (url) => { + if (url.endsWith("/telemetry/events")) posts++; return new Response("{}"); }, }, @@ -270,14 +287,33 @@ for (const [host, folder, manifest] of [ await writeFile(original, "CONTROLLED FIXTURE: original metadata connection failed"); let posts = 0; const server = createServer(async (req, res) => { - if (req.method === "POST") { + if (req.url === "/telemetry/events") { const parts = []; for await (const part of req) parts.push(part); const events = JSON.parse(Buffer.concat(parts).toString()); assert.equal(events[0].name, "mcp.doctor.report_fragment"); posts++; res.end('{"ok":true}'); - } else res.end('{"issuer":"fixture","resource":"fixture"}'); + } else if (req.url === "/api/external-mcp/mcp") { + res.writeHead(401, { + "www-authenticate": + 'Bearer resource_metadata="' + + origin + + '/.well-known/oauth-protected-resource/api/external-mcp/mcp"', + }); + res.end('{"jsonrpc":"2.0","error":{"code":-32000,"message":"Authentication required"}}'); + } else + res.end( + JSON.stringify({ + issuer: origin, + resource: origin + "/api/external-mcp/mcp", + authorization_servers: [origin], + authorization_endpoint: origin + "/auth/mcp/authorize", + token_endpoint: origin + "/auth/mcp/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + ); }); const origin = await listen(t, server); const helper = fileURLToPath(new URL(`../${folder}/scripts/mcp-doctor.mjs`, import.meta.url)); @@ -394,7 +430,11 @@ test("endpoint validation never silently retargets an unsupported environment or }); test("self-contained host packages ship identical helpers and diagnostic references", async () => { - for (const file of ["scripts/mcp-doctor.mjs", "references/connection-diagnostics.md"]) { + for (const file of [ + "scripts/mcp-doctor.mjs", + "scripts/mcp-evidence.mjs", + "references/connection-diagnostics.md", + ]) { assert.equal( await readFile( new URL(`../codex/skills/chatcut-plugin-basics/${file}`, import.meta.url), diff --git a/tests/mcp-evidence.test.mjs b/tests/mcp-evidence.test.mjs new file mode 100644 index 0000000..e33da0c --- /dev/null +++ b/tests/mcp-evidence.test.mjs @@ -0,0 +1,410 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import { createServer } from "node:net"; +import { once } from "node:events"; +import { + collectHostLogs, + inspectTransport, + probeEntry, + probeMetadata, + runDoctor, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; +import { + inspectMetadata, + validateSnapshot, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs"; + +const endpoint = new URL("https://api.chatcut.io/api/external-mcp/mcp"); +const now = "2026-10-22T12:00:00.000Z"; +const metadata = (url) => + new Response( + JSON.stringify( + url.includes("oauth-protected-resource") + ? { + resource: endpoint.href, + authorization_servers: [endpoint.origin], + } + : { + issuer: endpoint.origin, + authorization_endpoint: endpoint.origin + "/auth/mcp/authorize", + token_endpoint: endpoint.origin + "/auth/mcp/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }, + ), + ); +const challenge = () => + new Response('{"error":{"code":-32000,"message":"Authentication required"}}', { + status: 401, + headers: { + "www-authenticate": + 'Bearer resource_metadata="' + + endpoint.origin + + '/.well-known/oauth-protected-resource/api/external-mcp/mcp"', + "x-request-id": "fixture-server-id", + }, + }); +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-mcp-evidence-")); + if (!dir.startsWith(join(tmpdir(), "chatcut-mcp-evidence-"))) + throw new Error("Unexpected fixture path"); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} +const dependencies = { + collectLogs: async () => ({ entries: [], gaps: [] }), + inspectTransport: async () => ({ status: "fixture_no_network" }), + fetchImpl: async (url) => (url === endpoint.href ? challenge() : metadata(url)), +}; + +test("issuer-only and wrong-resource documents expose the missing protocol evidence", async () => { + const auth = await probeMetadata( + endpoint.origin + "/.well-known/oauth-authorization-server", + "fixture", + async () => new Response('{"issuer":"https://api.chatcut.io"}'), + ); + assert.equal(auth.outcome, "invalid_metadata"); + for (const field of [ + "authorization_endpoint", + "token_endpoint", + "response_types_supported", + "code_challenge_methods_supported", + ]) + assert.ok(auth.metadataValidation.issues.some((item) => item.field === field)); + const resource = inspectMetadata( + { resource: "https://wrong.example/mcp", authorization_servers: [endpoint.origin] }, + endpoint.origin + "/.well-known/oauth-protected-resource/api/external-mcp/mcp", + ); + assert.equal(resource.valid, false); + assert.ok(resource.issues.some((item) => item.reason === "resource_mismatch")); +}); + +test("unauthenticated entry checks keep normal challenges separate from protocol errors and rate limits", async () => { + const result = await probeEntry(endpoint, "fixture", async (_, init) => { + assert.equal(init.headers.authorization, undefined); + assert.equal(JSON.parse(init.body).method, "initialize"); + return challenge(); + }); + assert.equal(result.outcome, "authentication_challenge"); + assert.equal(result.responseHeaders["x-request-id"], "fixture-server-id"); + const logical = await probeEntry( + endpoint, + "fixture", + async () => + new Response( + '{"jsonrpc":"2.0","error":{"code":-32999,"message":"Previously unseen negotiation failure","data":{"access_token":"private-token"}}}', + ), + ); + assert.equal(logical.status, 200); + assert.equal(logical.outcome, "protocol_error"); + assert.equal(logical.protocolError.code, -32999); + assert.match(logical.protocolError.message, /Previously unseen/); + assert.ok(!JSON.stringify(logical).includes("private-token")); + const limited = await probeEntry( + endpoint, + "fixture", + async () => + new Response("Unfamiliar capacity response", { + status: 429, + headers: { "retry-after": "9" }, + }), + ); + assert.equal(limited.outcome, "http_rejected"); + assert.equal(limited.responseHeaders["retry-after"], "9"); + assert.match(limited.responseError.message, /Unfamiliar/); +}); + +test("current text-log window excludes old errors and retains unclassified diagnostics with unknown times", async (t) => { + const dir = await fixture(t); + await mkdir(join(dir, "log")); + await writeFile( + join(dir, "log", "codex-tui.log"), + [ + '2026-10-01T00:00:00Z WARN MCP server "chatcut" old fixture failure', + '2026-10-22T11:55:00Z ERROR MCP server "chatcut" FLUXION_472: unknown schema negotiation', + 'MCP server "chatcut" completely unfamiliar provider message', + '2026-10-22T11:59:00Z ERROR MCP server "chatcut_desktop" native fixture', + ].join("\n"), + ); + const result = await collectHostLogs("codex", { codexHome: dir, now: Date.parse(now) }); + assert.equal(result.entries.length, 2); + assert.ok(!JSON.stringify(result).includes("old fixture")); + assert.match(result.entries[0].message, /FLUXION_472/); + assert.equal(result.entries[1].occurredAt, null); + assert.ok(result.gaps.some((gap) => gap.reason === "records_with_unknown_time")); +}); + +test("a host snapshot preserves current-session tool absence without treating healthy probes as recovery", async (t) => { + const dir = await fixture(t); + const snapshotPath = join(dir, "current.mcp-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify({ + schemaVersion: 1, + observedAt: now, + hostVersion: "fixture-host", + configuration: { + source: "host_settings", + serverName: "chatcut", + url: endpoint.href, + enabled: true, + }, + session: { + source: "current_session", + conversationId: "fixture-chat", + connectionStatus: "failed", + authStatus: "authenticated", + visibleToolCount: 0, + visibleTools: [], + }, + observations: [ + { + stage: "initialize", + operation: "initialize", + source: "runtime_log", + observedAt: now, + outcome: "failed", + requestId: "original-request-if-present", + message: "Original initialize authentication failure", + }, + { + stage: "future_negotiation", + source: "runtime_log", + observedAt: now, + outcome: "failed", + protocolCode: -32999, + message: "Previously unseen failure", + }, + ], + }), + ); + const { report } = await runDoctor( + { + host: "codex", + serverUrl: endpoint.href, + hostSnapshot: snapshotPath, + output: join(dir, "report.json"), + }, + dependencies, + ); + assert.equal(report.hostSnapshot.session.visibleToolCount, 0); + assert.equal(report.hostVersion, "fixture-host"); + assert.equal(report.entryProbe.outcome, "authentication_challenge"); + assert.equal(report.evidenceSummary.recoveryVerified, false); + assert.equal(report.evidenceSummary.otherObservations[0].protocolCode, -32999); + assert.equal( + report.evidenceSummary.stages.find((item) => item.stage === "tools_list").status, + "not_observed", + ); + assert.match( + report.evidenceSummary.stages.find((item) => item.stage === "initialize").observations[0] + .message, + /Original/, + ); +}); + +test("snapshot rejects credentials, wrong endpoints and static-configuration claims about current tools", () => { + const base = { schemaVersion: 1, observedAt: now }; + assert.throws( + () => validateSnapshot({ ...base, access_token: "secret" }, endpoint), + /unsupported fields/, + ); + assert.throws( + () => + validateSnapshot( + { + ...base, + configuration: { + source: "host_settings", + serverName: "chatcut", + url: "https://api.chatcut.dev/api/external-mcp/mcp", + enabled: true, + }, + }, + endpoint, + ), + /differs/, + ); + assert.throws( + () => + validateSnapshot( + { + ...base, + session: { + source: "cli_configuration", + connectionStatus: "connected", + authStatus: "authenticated", + visibleToolCount: 10, + }, + }, + endpoint, + ), + /Static CLI/, + ); +}); + +test("disabled hosted connections produce a local report without any probe or upload", async (t) => { + const dir = await fixture(t); + const snapshotPath = join(dir, "disabled.mcp-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify({ + schemaVersion: 1, + observedAt: now, + configuration: { + source: "host_settings", + serverName: "chatcut", + url: endpoint.href, + enabled: false, + }, + }), + ); + const { report, path } = await runDoctor( + { + host: "codex", + serverUrl: endpoint.href, + hostSnapshot: snapshotPath, + upload: true, + output: join(dir, "report.json"), + }, + { + ...dependencies, + fetchImpl: () => { + throw new Error("No network request should run"); + }, + }, + ); + assert.equal(report.probes.length, 0); + assert.equal(report.upload.status, "skipped_intentionally_disabled"); + assert.ok(JSON.parse(await readFile(path)).gaps.some((gap) => gap.source === "probes")); +}); + +test("untrusted challenge metadata cannot redirect diagnosis to a different origin", async (t) => { + const dir = await fixture(t); + const requested = []; + const { report } = await runDoctor( + { + host: "claude", + serverUrl: endpoint.href, + output: join(dir, "report.json"), + }, + { + ...dependencies, + fetchImpl: async (url) => { + requested.push(url); + return url === endpoint.href + ? new Response("{}", { + status: 401, + headers: { + "www-authenticate": + 'Bearer resource_metadata="https://untrusted.example/.well-known/oauth-protected-resource"', + }, + }) + : metadata(url); + }, + }, + ); + assert.ok(requested.every((url) => new URL(url).origin === endpoint.origin)); + assert.ok(report.gaps.some((gap) => gap.source === "challenge_metadata_target")); + assert.ok(report.evidenceSummary.gaps.includes("current_host_snapshot_unavailable")); +}); + +test("an unavailable registered URL still permits a local evidence report with no silent production fallback", async (t) => { + const dir = await fixture(t); + const { report, path } = await runDoctor( + { + host: "codex", + output: join(dir, "report.json"), + upload: true, + }, + { + ...dependencies, + fetchImpl: () => { + throw new Error("No endpoint was selected"); + }, + }, + ); + assert.equal(report.serverUrl, null); + assert.equal(report.upload.status, "skipped_endpoint_unavailable"); + assert.equal(report.probes.length, 0); + assert.ok( + JSON.parse(await readFile(path)).gaps.some((gap) => + /registered_endpoint_unavailable/.test(gap.reason ?? ""), + ), + ); +}); + +test("missing path metadata checks the root fallback without turning later success into original recovery", async (t) => { + const dir = await fixture(t); + const { report } = await runDoctor( + { + host: "codex", + serverUrl: endpoint.href, + output: join(dir, "report.json"), + }, + { + ...dependencies, + fetchImpl: async (url) => { + if (url === endpoint.href) + return new Response("{}", { + status: 401, + headers: { + "www-authenticate": + "Bearer resource_metadata=" + + endpoint.origin + + "/.well-known/oauth-protected-resource", + }, + }); + if (url.endsWith("/oauth-protected-resource/api/external-mcp/mcp")) + return new Response("{}", { status: 404 }); + return metadata(url); + }, + }, + ); + assert.ok( + report.probes.some( + (probe) => + probe.url.endsWith("/oauth-protected-resource") && probe.outcome === "metadata_read", + ), + ); + assert.equal(report.evidenceSummary.recoveryVerified, false); +}); + +test("direct TLS context has a bounded timeout and states that it differs from the host route", async (t) => { + const server = createServer((socket) => { + socket.on("error", () => {}); + socket.resume(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + const result = await inspectTransport(new URL("https://127.0.0.1:" + server.address().port)); + assert.equal(result.status, "failed"); + assert.equal(result.error.code, "ETIMEDOUT"); + assert.match(result.route, /does_not_reproduce_host/); +}); + +test("an unexpected probe collector failure cannot erase the original incident or prevent a local report", async (t) => { + const dir = await fixture(t); + const original = join(dir, "original.txt"); + await writeFile(original, "Original unfamiliar MCP failure E_ORIGINAL_472"); + const deepDocument = '{"resource":' + "[".repeat(9000) + "0" + "]".repeat(9000) + "}"; + const { report, path } = await runDoctor( + { + host: "codex", + serverUrl: endpoint.href, + originalError: original, + output: join(dir, "report.json"), + }, + { + ...dependencies, + fetchImpl: async (url) => (url === endpoint.href ? challenge() : new Response(deepDocument)), + }, + ); + assert.match(report.originalFailure.message, /E_ORIGINAL_472/); + assert.ok(report.probes.some((probe) => probe.stage === "probe_collector")); + assert.match(JSON.parse(await readFile(path)).originalFailure.message, /E_ORIGINAL_472/); +}); From 9d311994828d7da51dc83d6f27bf13b5457f1985 Mon Sep 17 00:00:00 2001 From: IanYet Date: Fri, 23 Oct 2026 01:19:46 +0800 Subject: [PATCH 5/5] release(plugin): publish hosted MCP Doctor packages --- claude/.claude-plugin/plugin.json | 2 +- codex/.codex-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index 769c5ad..91b67c7 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "chatcut", "displayName": "ChatCut", - "version": "1.10.19", + "version": "1.10.20", "description": "ChatCut plugin for editable video editing and creation workflows from Claude Code.", "author": { "name": "ChatCut", diff --git a/codex/.codex-plugin/plugin.json b/codex/.codex-plugin/plugin.json index bb3e734..b4d79a4 100644 --- a/codex/.codex-plugin/plugin.json +++ b/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "chatcut", - "version": "1.10.17", + "version": "1.10.18", "description": "ChatCut Codex plugin for editable video editing and creation workflows from Codex.", "author": { "name": "ChatCut",