Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/en/docs/integrations/dsh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/docs/integrations/dsh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 原始错误文字和召回内容。
Expand Down
2 changes: 2 additions & 0 deletions integrations/dsh/plugins/powercontext/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
36 changes: 30 additions & 6 deletions integrations/dsh/plugins/powercontext/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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."
};
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -2782,7 +2806,7 @@ function registerCommands(ctx, runtime) {
name: "pc",
description: "PowerContext status, search, review, and diagnostics",
input: { hint: "doctor | capabilities | search <query> | remember <text> | 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)
});
}

Expand Down
11 changes: 10 additions & 1 deletion integrations/dsh/plugins/powercontext/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export async function handlePcCommand(
cwd?: string,
signal?: AbortSignal,
sessionId?: string,
getToolCatalog?: () => unknown,
toolScope?: unknown,
): Promise<CommandResult> {
const tokens = rawInput.trim().split(/\s+/).filter(Boolean)
const command = tokens[0]
Expand All @@ -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') {
Expand Down Expand Up @@ -160,6 +168,7 @@ export function registerCommands(
input: { hint: 'doctor | capabilities | search <query> | remember <text> | 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,
),
})
}
52 changes: 49 additions & 3 deletions integrations/dsh/plugins/powercontext/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface DoctorCheck extends BodyFailureDetails {
protocol_issue?: keyof typeof RESPONSE_ISSUES
dependencies?: Record<string, string>
operations?: string[]
tools?: string[]
}

const CORE_OPERATIONS: OperationId[] = [
Expand Down Expand Up @@ -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<string, DoctorCheck> = {
configuration: !config.timeoutValid
Expand Down Expand Up @@ -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.',
}
}
25 changes: 23 additions & 2 deletions integrations/dsh/plugins/powercontext/tests/doctor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
},
}
Expand Down Expand Up @@ -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')
Expand All @@ -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'],
Expand Down
Loading