diff --git a/docs/en/docs/integrations/dsh.md b/docs/en/docs/integrations/dsh.md index 6ba2400f31..bac03d77d9 100644 --- a/docs/en/docs/integrations/dsh.md +++ b/docs/en/docs/integrations/dsh.md @@ -88,6 +88,10 @@ liveness, readiness, capabilities, declared routes, the current Scope, and a rea A Scope failure leaves health results available. The endpoint summary shows only its origin, configuration source and whether a path prefix exists; credentials, prefix text, query strings and fragments are not printed. +Doctor reports the active host's native MCP catalog separately. The documented DSH installation is HTTP-only, so +`native_mcp_unconfigured` does not fail an otherwise healthy HTTP plugin. If other native MCP tools are visible +but `mcp__powercontext__*` is absent, Doctor reports `native_mcp_powercontext_missing` with a recovery action. + Each failed check identifies the operation, a stable code, HTTP status/request ID when available, and a recovery action. Protocol errors also include `protocol_issue`, identifying the violated JSON, status or PreparedContext field rule. Readiness retains recognized dependency statuses, including a 503 response. It never forwards raw Server diff --git a/docs/zh/docs/integrations/dsh.md b/docs/zh/docs/integrations/dsh.md index 4000faed29..15bd68bb2d 100644 --- a/docs/zh/docs/integrations/dsh.md +++ b/docs/zh/docs/integrations/dsh.md @@ -85,6 +85,10 @@ Server 使用其他监听地址时同步修改 URL。鉴权使用 `POWERCONTEXT_ 路由声明、当前 Scope 和只读 prepare 操作。Scope 失败不会遮蔽健康检查。 端点摘要仅显示 origin、配置来源和是否存在路径前缀,不打印凭据、前缀正文、查询参数或 fragment。 +Doctor 会单独报告当前宿主的原生 MCP 工具目录。文档中的 DSH 安装仍是仅 HTTP 的插件,因此 +`native_mcp_unconfigured` 不会使原本健康的 HTTP 插件检查失败。若已发现其他原生 MCP 工具但没有 +`mcp__powercontext__*`,Doctor 会报告 `native_mcp_powercontext_missing` 并给出恢复操作。 + 失败项提供操作名、稳定 code、可用的 HTTP status/request ID 和具体恢复操作。 协议错误还提供 `protocol_issue`,指出 JSON、状态码或 PreparedContext 字段违反的具体规则。 Readiness 保留已识别的依赖状态,包括 HTTP 503 的检查结果,不透传 Server 原始错误文字和召回内容。 diff --git a/integrations/dsh/plugins/powercontext/README.md b/integrations/dsh/plugins/powercontext/README.md index fa0d76ebab..d6c2743be3 100644 --- a/integrations/dsh/plugins/powercontext/README.md +++ b/integrations/dsh/plugins/powercontext/README.md @@ -36,6 +36,8 @@ Named `pc_*` tools expose the agent-safe Memory, handoff, experience, skill, and read-only prepare independently. Failures identify the operation, a specific code, available HTTP status/request ID, safe dependency statuses and recovery actions. The endpoint summary omits credentials and path text. A successful report does not prove capture or processing: write routes are declared by OpenAPI but never executed by Doctor. +Doctor also reports native MCP catalog visibility separately. The documented DSH installation remains HTTP-only, so +an unavailable or unconfigured native MCP catalog does not make the HTTP plugin health check fail. Standalone `powercontext doctor dsh` verifies Web-profile registration and explicitly cannot observe the running host's overrides. A healthy Server with extraction disabled may return valid empty recall. diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 4c33a709d9..599ef8648c 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -2217,7 +2217,26 @@ function routes(response, flush) { operations: required }; } -async function diagnoseServer(runtime, cwd, signal) { +function nativeMcpCatalog(catalog, scope) { + const operation = "native_mcp_catalog"; + if (!record(catalog) || typeof catalog.schemas !== "function") return check(operation, "tool_catalog_unavailable", "The DSH tool catalog is unavailable, so native MCP visibility was not checked.", "Run /pc doctor from a DSH session with the tools service available.", "skipped"); + let schemas; + try { + schemas = catalog.schemas(scope); + } catch { + return check(operation, "tool_catalog_unreadable", "The DSH tool catalog could not be read, so native MCP visibility is unknown.", "Inspect the active DSH tool registry and native MCP client configuration.", "degraded"); + } + if (!Array.isArray(schemas)) return check(operation, "tool_catalog_invalid", "The DSH tool catalog returned an invalid schema list.", "Update the active DSH tools runtime and inspect its tool registry.", "degraded"); + const mcpTools = schemas.flatMap((schema) => record(schema) && typeof schema.name === "string" ? [schema.name] : []).filter((name$1) => name$1.startsWith("mcp__")); + const powerContextTools = mcpTools.filter((name$1) => name$1.startsWith("mcp__powercontext__")); + if (powerContextTools.length) return { + ...check(operation, "native_mcp_tools_visible", "The active DSH tool catalog exposes native PowerContext MCP tools.", void 0, "ok"), + tools: powerContextTools + }; + if (mcpTools.length) return check(operation, "native_mcp_powercontext_missing", "Native MCP tools are registered, but none belong to the PowerContext server.", "Check the native PowerContext MCP client name, startup logs and tool-registration configuration.", "degraded"); + return check(operation, "native_mcp_unconfigured", "No native MCP tools are registered in the active DSH tool catalog.", "The HTTP PowerContext plugin remains supported. Configure a native MCP client only when native MCP tools are required.", "skipped"); +} +async function diagnoseServer(runtime, cwd, signal, toolCatalog, toolScope) { const config = configuration(runtime.config, cwd); const checks = { configuration: !config.timeoutValid ? check("configuration", "invalid_timeout", "The plugin requestTimeoutMs is not a positive supported millisecond duration.", "Set requestTimeoutMs in the plugin patch to an integer between 1 and 4294967295, then restart DSH.") : config.valid ? check("configuration", "effective_configuration", "Using the running plugin resolved configuration.", void 0, "ok") : check("configuration", "invalid_endpoint", "The plugin base URL is not an HTTP(S) base URL without userinfo, query or fragment.", "Correct POWERCONTEXT_DSH_BASE_URL or plugin baseUrl. Put credentials in POWERCONTEXT_DSH_AUTHORIZATION and restart DSH.") }; async function probe(operation, run$1) { @@ -2272,14 +2291,15 @@ async function diagnoseServer(runtime, cwd, signal) { } return observed("prepare_context", response, prepared.status, prepared.status === "empty" ? "The prepare route returned a valid empty result." : "The prepare route returned valid context; Doctor discarded the content without injecting it."); }) : check("prepare_context", "scope_unavailable", "Not checked because the current Scope could not be resolved.", "Resolve the Scope check first.", "skipped"); + checks.mcp_catalog = nativeMcpCatalog(toolCatalog, toolScope); return { - ok: Object.values(checks).every((value) => value.state === "ok"), + ok: Object.entries(checks).every(([name$1, value]) => name$1 === "mcp_catalog" || value.state === "ok"), configuration: { ...config.summary, readiness_request_timeout_ms: runtime.client.requestTimeoutMsFor("get_readiness") }, checks, - coverage: "Read-only checks of the current configuration. Write routes are declared by the contract but not executed; processing, capture and injection are not verified by Doctor." + coverage: "Read-only checks of the current configuration. Native MCP catalog visibility is reported separately and does not establish HTTP plugin health. Write routes are declared by the contract but not executed; processing, capture and injection are not verified by Doctor." }; } @@ -2718,7 +2738,7 @@ function statusResult(runtime, scopeId, failure, sessionId, cwd) { text: `scope=${scopeId ?? "unresolved"}\nbaseUrl=${endpoint}\nUse /pc doctor to check Server readiness.` + (failure ? `\nCurrent Scope check (resolve_scope_binding):\n${formatResult(failure)}` : "") + `\nautomatic=${JSON.stringify((runtime.status ?? new RuntimeStatus()).read(sessionId, cwd, scopeId), null, 2)}` }; } -async function handlePcCommand(rawInput, runtime, cwd, signal, sessionId) { +async function handlePcCommand(rawInput, runtime, cwd, signal, sessionId, getToolCatalog, toolScope) { const tokens = rawInput.trim().split(/\s+/).filter(Boolean); const command = tokens[0]; if (!command) try { @@ -2732,7 +2752,11 @@ async function handlePcCommand(rawInput, runtime, cwd, signal, sessionId) { return statusResult(runtime, void 0, await reportDirectFailure(runtime, "command", error), sessionId, cwd); } if (command === "doctor") { - const report = await diagnoseServer(runtime, cwd, signal); + let toolCatalog; + try { + toolCatalog = getToolCatalog?.(); + } catch {} + const report = await diagnoseServer(runtime, cwd, signal, toolCatalog, toolScope); return { kind: report.ok ? "success" : "error", text: JSON.stringify(report, null, 2) @@ -2782,7 +2806,7 @@ function registerCommands(ctx, runtime) { name: "pc", description: "PowerContext status, search, review, and diagnostics", input: { hint: "doctor | capabilities | search | remember | flush | review | stats | skills scan" }, - handler: async (invocation) => handlePcCommand(invocation.rawInput, runtime, invocation.agent.session.header.cwd, invocation.signal, invocation.agent.session.header.id) + handler: async (invocation) => handlePcCommand(invocation.rawInput, runtime, invocation.agent.session.header.cwd, invocation.signal, invocation.agent.session.header.id, () => ctx.get("tools"), invocation.agent) }); } diff --git a/integrations/dsh/plugins/powercontext/src/commands.ts b/integrations/dsh/plugins/powercontext/src/commands.ts index 1c906daf95..b492adb195 100644 --- a/integrations/dsh/plugins/powercontext/src/commands.ts +++ b/integrations/dsh/plugins/powercontext/src/commands.ts @@ -102,6 +102,8 @@ export async function handlePcCommand( cwd?: string, signal?: AbortSignal, sessionId?: string, + getToolCatalog?: () => unknown, + toolScope?: unknown, ): Promise { const tokens = rawInput.trim().split(/\s+/).filter(Boolean) const command = tokens[0] @@ -115,7 +117,13 @@ export async function handlePcCommand( } } if (command === 'doctor') { - const report = await diagnoseServer(runtime, cwd, signal) + let toolCatalog: unknown + try { + toolCatalog = getToolCatalog?.() + } catch { + // Doctor reports an unavailable catalog without failing HTTP checks. + } + const report = await diagnoseServer(runtime, cwd, signal, toolCatalog, toolScope) return { kind: report.ok ? 'success' : 'error', text: JSON.stringify(report, null, 2) } } if (command === 'search') { @@ -160,6 +168,7 @@ export function registerCommands( input: { hint: 'doctor | capabilities | search | remember | flush | review | stats | skills scan' }, handler: async (invocation) => handlePcCommand( invocation.rawInput, runtime, invocation.agent.session.header.cwd, invocation.signal, invocation.agent.session.header.id, + () => ctx.get('tools'), invocation.agent, ), }) } diff --git a/integrations/dsh/plugins/powercontext/src/doctor.ts b/integrations/dsh/plugins/powercontext/src/doctor.ts index bb48f0d0f4..b7bc572b19 100644 --- a/integrations/dsh/plugins/powercontext/src/doctor.ts +++ b/integrations/dsh/plugins/powercontext/src/doctor.ts @@ -34,6 +34,7 @@ export interface DoctorCheck extends BodyFailureDetails { protocol_issue?: keyof typeof RESPONSE_ISSUES dependencies?: Record operations?: string[] + tools?: string[] } const CORE_OPERATIONS: OperationId[] = [ @@ -254,7 +255,50 @@ function routes(response: ClientSuccess, flush: boolean): DoctorCheck { operations: required } } -export async function diagnoseServer(runtime: PluginRuntime, cwd?: string, signal?: AbortSignal) { +function nativeMcpCatalog(catalog: unknown, scope?: unknown): DoctorCheck { + const operation = 'native_mcp_catalog' + if (!record(catalog) || typeof catalog.schemas !== 'function') { + return check(operation, 'tool_catalog_unavailable', + 'The DSH tool catalog is unavailable, so native MCP visibility was not checked.', + 'Run /pc doctor from a DSH session with the tools service available.', 'skipped') + } + let schemas: unknown + try { + schemas = (catalog as { schemas: (scope?: unknown) => unknown }).schemas(scope) + } catch { + return check(operation, 'tool_catalog_unreadable', + 'The DSH tool catalog could not be read, so native MCP visibility is unknown.', + 'Inspect the active DSH tool registry and native MCP client configuration.', 'degraded') + } + if (!Array.isArray(schemas)) { + return check(operation, 'tool_catalog_invalid', + 'The DSH tool catalog returned an invalid schema list.', + 'Update the active DSH tools runtime and inspect its tool registry.', 'degraded') + } + const mcpTools = schemas.flatMap(schema => record(schema) && typeof schema.name === 'string' + ? [schema.name] : []).filter(name => name.startsWith('mcp__')) + const powerContextTools = mcpTools.filter(name => name.startsWith('mcp__powercontext__')) + if (powerContextTools.length) { + return { ...check(operation, 'native_mcp_tools_visible', + 'The active DSH tool catalog exposes native PowerContext MCP tools.', undefined, 'ok'), tools: powerContextTools } + } + if (mcpTools.length) { + return check(operation, 'native_mcp_powercontext_missing', + 'Native MCP tools are registered, but none belong to the PowerContext server.', + 'Check the native PowerContext MCP client name, startup logs and tool-registration configuration.', 'degraded') + } + return check(operation, 'native_mcp_unconfigured', + 'No native MCP tools are registered in the active DSH tool catalog.', + 'The HTTP PowerContext plugin remains supported. Configure a native MCP client only when native MCP tools are required.', 'skipped') +} + +export async function diagnoseServer( + runtime: PluginRuntime, + cwd?: string, + signal?: AbortSignal, + toolCatalog?: unknown, + toolScope?: unknown, +) { const config = configuration(runtime.config, cwd) const checks: Record = { configuration: !config.timeoutValid @@ -325,9 +369,11 @@ export async function diagnoseServer(runtime: PluginRuntime, cwd?: string, signa : 'The prepare route returned valid context; Doctor discarded the content without injecting it.') }) : check('prepare_context', 'scope_unavailable', 'Not checked because the current Scope could not be resolved.', 'Resolve the Scope check first.', 'skipped') + checks.mcp_catalog = nativeMcpCatalog(toolCatalog, toolScope) return { - ok: Object.values(checks).every(value => value.state === 'ok'), + ok: Object.entries(checks).every(([name, value]) => name === 'mcp_catalog' || value.state === 'ok'), configuration: { ...config.summary, readiness_request_timeout_ms: runtime.client.requestTimeoutMsFor('get_readiness') }, checks, - coverage: 'Read-only checks of the current configuration. Write routes are declared by the contract but not executed; processing, capture and injection are not verified by Doctor.', + coverage: 'Read-only checks of the current configuration. Native MCP catalog visibility is reported separately and does not establish ' + + 'HTTP plugin health. Write routes are declared by the contract but not executed; processing, capture and injection are not verified by Doctor.', } } diff --git a/integrations/dsh/plugins/powercontext/tests/doctor.spec.ts b/integrations/dsh/plugins/powercontext/tests/doctor.spec.ts index e9bb3501cc..e0e4402612 100644 --- a/integrations/dsh/plugins/powercontext/tests/doctor.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/doctor.spec.ts @@ -58,8 +58,8 @@ function fixture(override?: FetchFn, config: PluginConfig = {}, env: NodeJS.Proc } return { calls, runtime, - async doctor(signal?: AbortSignal) { - const result = await handlePcCommand('doctor', runtime, '/fixture/workspace', signal) + async doctor(signal?: AbortSignal, toolCatalog?: unknown) { + const result = await handlePcCommand('doctor', runtime, '/fixture/workspace', signal, undefined, () => toolCatalog) return { kind: result.kind, ...JSON.parse(result.text) } }, } @@ -160,6 +160,7 @@ describe('read-only DSH Doctor', () => { routes: { state: 'ok', code: 'routes_declared' }, scope: { state: 'ok', code: 'scope_resolved' }, capabilities: { state: 'ok', code: 'extraction_disabled' }, prepare: { state: 'ok', code: 'empty' }, + mcp_catalog: { state: 'skipped', code: 'tool_catalog_unavailable' }, }, }) expect(result.coverage).toContain('not executed') @@ -172,6 +173,26 @@ describe('read-only DSH Doctor', () => { expect(payload.query).not.toContain('/fixture/workspace') }) + it('reports visible native PowerContext MCP tools separately from HTTP health', async () => { + const h = fixture() + const result = await h.doctor(undefined, { + schemas: () => [{ name: 'mcp__powercontext__search_memory' }, { name: 'pc_search' }], + }) + expect(result).toMatchObject({ ok: true, kind: 'success', checks: { + mcp_catalog: { state: 'ok', code: 'native_mcp_tools_visible', tools: ['mcp__powercontext__search_memory'] }, + } }) + }) + + it.each([ + ['unconfigured', { schemas: () => [] }, 'skipped', 'native_mcp_unconfigured'], + ['missing PowerContext', { schemas: () => [{ name: 'mcp__other__search' }] }, 'degraded', 'native_mcp_powercontext_missing'], + ['unreadable', { schemas: () => { throw new Error(PRIVATE) } }, 'degraded', 'tool_catalog_unreadable'], + ])('keeps healthy HTTP diagnostics successful when MCP catalog is %s', async (_case, catalog, state, code) => { + const result = await fixture().doctor(undefined, catalog) + expect(result).toMatchObject({ ok: true, kind: 'success', checks: { mcp_catalog: { state, code } } }) + expect(JSON.stringify(result)).not.toContain(PRIVATE) + }) + it.each([ [401, 'unauthorized', 'authentication_failed'], [403, 'forbidden', 'authorization_failed'],