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
75 changes: 70 additions & 5 deletions apps/desktop/electron/main/mcp-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,22 @@ export function stripSecretMaterial(value: unknown): unknown {
return output;
}

export function boundMcpResult(value: unknown): unknown {
let text: string;
function serializeMcpResult(value: unknown): string {
try {
text = JSON.stringify(value ?? null);
return JSON.stringify(value ?? null);
} catch {
text = JSON.stringify({ value: String(value) });
return JSON.stringify({ value: String(value) });
}
}

export function boundMcpResult(
value: unknown,
projectOversized?: (value: unknown) => unknown,
): unknown {
let text = serializeMcpResult(value);
if (text.length > MAX_RESULT_CHARS && projectOversized) {
value = projectOversized(value);
text = serializeMcpResult(value);
}
if (text.length <= MAX_RESULT_CHARS) {
try {
Expand All @@ -548,6 +558,55 @@ export function boundMcpResult(value: unknown): unknown {
};
}

/** Scalar compaction fields small enough to keep in a control-plane answer. */
const COMPACTION_SCALAR_KEYS = [
"id",
"firstKeptMessageId",
"throughMessageId",
"tokensBefore",
"providerId",
"modelId",
"createdAt",
] as const;

/**
* Bounds the `session/get` answer for the control plane (mocode #495).
*
* A durable session's `ContextCompactionRecord` (`summary` / `retainedTail` /
* `details.modifiedFiles`) grows without bound: on a long session it alone can
* exceed {@link MAX_RESULT_CHARS}, so {@link boundMcpResult} replaced the WHOLE
* answer with a half-JSON `preview` and external clients (`pi_session_get`)
* could never reach `messages` — the phone reported it as an "unexpected
* format" and the session was unopenable.
*
* External clients only need the compact identity the tools contract promises
* (`compaction.createdAt` and `details.generation`), never the summary text,
* the retained tail, or the artifact list. Keep that whitelist and drop the
* rest, so the transcript survives bounding. Non-`session/get` shapes and
* sessions without a compaction record are returned untouched.
*/
export function projectSessionGetResult(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const root = value as Record<string, unknown>;
const session = root.session;
if (!session || typeof session !== "object" || Array.isArray(session)) return value;
const compaction = (session as Record<string, unknown>).compaction;
if (!compaction || typeof compaction !== "object" || Array.isArray(compaction)) return value;

const source = compaction as Record<string, unknown>;
const bounded: Record<string, unknown> = {};
for (const key of COMPACTION_SCALAR_KEYS) {
if (source[key] !== undefined) bounded[key] = source[key];
}
const details = source.details;
if (details && typeof details === "object" && !Array.isArray(details)) {
const generation = (details as Record<string, unknown>).generation;
if (generation !== undefined) bounded.details = { generation };
}

return { ...root, session: { ...(session as Record<string, unknown>), compaction: bounded } };
}

function errorInfo(error: unknown): { code: string; message: string; details?: unknown } {
const candidate = error as {
code?: unknown;
Expand Down Expand Up @@ -1099,7 +1158,13 @@ export class McpControlServer {
const tool = this.toolsList.find((candidate) => candidate.name === name);
if (!tool) return { response: rpcError(id, -32602, `unknown tool: ${name}`) };
try {
const value = boundMcpResult(await tool.execute(input));
const raw = await tool.execute(input);
// Preserve ordinary session details; project oversized session/get
// compaction metadata only before falling back to the truncation envelope.
const value = boundMcpResult(
raw,
name === "pi_session_get" ? projectSessionGetResult : undefined,
);
return {
response: response(id, {
content: [{ type: "text", text: JSON.stringify(value) }],
Expand Down
141 changes: 141 additions & 0 deletions apps/desktop/test/mcp-control.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
isLoopbackBindHost,
mcpControlRendererEvent,
negotiateMcpProtocolVersion,
projectSessionGetResult,
stripSecretMaterial,
tokensEqual,
} = await import("../electron/main/mcp-control.ts");
Expand Down Expand Up @@ -81,6 +82,36 @@ test("local MCP control server authenticates, discovers, and invokes desktop ope
const dataDir = mkdtempSync(join(tmpdir(), "pi-mcp-control-"));
const calls = [];
const events = [];
const sessionMessages = [
{ id: "m1", role: "user", content: "hello" },
{ id: "m2", role: "assistant", content: "hi" },
];
const largeSession = {
session: {
id: "large-session",
compaction: {
id: "cp-large",
createdAt: "2026-10-03T07:37:04.094Z",
summary: "x".repeat(700_000),
retainedTail: [{ content: "large" }],
details: { generation: 13, modifiedFiles: ["src/a.ts"] },
},
messages: sessionMessages,
},
};
const smallSession = {
session: {
id: "small-session",
compaction: {
id: "cp-small",
createdAt: "2026-10-03T07:37:04.094Z",
summary: "Keep this summary",
retainedTail: [{ content: "Keep this reply" }],
details: { generation: 2, modifiedFiles: ["src/b.ts"] },
},
messages: sessionMessages,
},
};
const server = new McpControlServer({
dataDir,
port: 0,
Expand All @@ -97,6 +128,10 @@ test("local MCP control server authenticates, discovers, and invokes desktop ope
if (channel === "pi-desktop/session/create") {
return { session: { id: "session-created", projectPath: args[0]?.projectPath ?? null } };
}
if (channel === "pi-desktop/session/get") {
if (args[0]?.id === largeSession.session.id) return largeSession;
if (args[0]?.id === smallSession.session.id) return smallSession;
}
return { ok: true };
},
onOperationComplete: (operation, result, args) => {
Expand Down Expand Up @@ -392,6 +427,39 @@ test("local MCP control server authenticates, discovers, and invokes desktop ope
assert.equal(readSession.body.result.isError, undefined);
assert.equal(events.filter(Boolean).length, refreshCount);

const readLargeSession = await post(
info.url,
info.token,
{
jsonrpc: "2.0",
id: 16,
method: "tools/call",
params: { name: "pi_session_get", arguments: { id: largeSession.session.id } },
},
{ "Mcp-Session-Id": sessionId },
);
const largeResult = readLargeSession.body.result.structuredContent;
assert.equal(largeResult.truncated, undefined);
assert.deepEqual(largeResult.session.messages, sessionMessages);
assert.equal(largeResult.session.compaction.createdAt, largeSession.session.compaction.createdAt);
assert.equal(largeResult.session.compaction.details.generation, 13);
assert.equal(largeResult.session.compaction.summary, undefined);
assert.equal(largeResult.session.compaction.retainedTail, undefined);
assert.equal(largeResult.session.compaction.details.modifiedFiles, undefined);

const readSmallSession = await post(
info.url,
info.token,
{
jsonrpc: "2.0",
id: 17,
method: "tools/call",
params: { name: "pi_session_get", arguments: { id: smallSession.session.id } },
},
{ "Mcp-Session-Id": sessionId },
);
assert.deepEqual(readSmallSession.body.result.structuredContent, smallSession);

const missingPath = await post(
info.url,
info.token,
Expand Down Expand Up @@ -506,6 +574,79 @@ test("control-plane helpers clamp protocol versions, strip secrets, and bound re
assert.ok(JSON.stringify(bounded).length < 600_000);
});

test("session/get compaction metadata is projected before bounding (mocode #495)", () => {
const messages = [
{ id: "m1", role: "user", content: "hello" },
{ id: "m2", role: "assistant", content: "hi" },
];
const huge = "x".repeat(700_000);
const raw = {
session: {
id: "s1",
title: "Long session",
modelId: "deepseek/deepseek-v4.1-flash",
mode: "agent",
messageStart: 0,
hasMoreBefore: false,
compaction: {
id: "cp1",
throughMessageId: "m900",
tokensBefore: 12_345,
createdAt: "2026-10-03T07:37:04.094Z",
summary: huge,
retainedTail: [{ huge }],
details: { generation: 13, modifiedFiles: ["a", "b", "c"] },
providerId: "p1",
modelId: "deepseek/deepseek-v4.1-flash",
},
messages,
},
};

// Without the projection the whole answer is replaced by a half-JSON preview
// and `messages` becomes unreachable — exactly what the phone hit.
const unprojected = boundMcpResult(raw);
assert.equal(unprojected.truncated, true);

const bounded = boundMcpResult(raw, projectSessionGetResult);
assert.notEqual(bounded.truncated, true, "projected answer must fit the limit");

const session = bounded.session;
assert.deepEqual(session.messages, messages, "messages must survive");
assert.equal(session.title, "Long session");
assert.equal(session.modelId, "deepseek/deepseek-v4.1-flash");
// The compact identity external clients rely on is kept ...
assert.equal(session.compaction.createdAt, "2026-10-03T07:37:04.094Z");
assert.equal(session.compaction.details.generation, 13);
// ... and the unbounded fields are dropped.
assert.equal(session.compaction.summary, undefined);
assert.equal(session.compaction.retainedTail, undefined);
assert.equal(session.compaction.details.modifiedFiles, undefined);
});

test("session/get projection leaves a small session untouched (mocode #495)", () => {
const raw = {
session: {
id: "s1",
compaction: {
id: "cp1",
createdAt: "t",
summary: "A small summary",
retainedTail: [{ role: "assistant", content: "A retained reply" }],
details: { generation: 1, modifiedFiles: ["src/a.ts"] },
},
messages: [],
},
};
assert.deepEqual(boundMcpResult(raw, projectSessionGetResult), raw);
// Non-session shapes and sessions without a compaction record pass through.
assert.deepEqual(boundMcpResult({ ok: true }, projectSessionGetResult), { ok: true });
assert.deepEqual(
boundMcpResult({ session: { id: "s2" } }, projectSessionGetResult),
{ session: { id: "s2" } },
);
});

test("renderer refresh events fire only for mutating control operations", () => {
const sessionOp = (id) => ({
id,
Expand Down
13 changes: 12 additions & 1 deletion docs/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -2277,7 +2277,18 @@ generic operations and the named session-delete, session-configure, and
plan-resolution tools require `confirm: true`. That flag is an agent
acknowledgement, not a desktop user prompt. All calls still pass through the
existing IPC handler validation, host permissions, workspace boundaries, and
error model. Both the text payload and `structuredContent` are size-bounded.
error model. Both the text payload and `structuredContent` are size-bounded to
512 KiB (`MAX_RESULT_CHARS`). A larger answer is not returned verbatim: it is
replaced by `{truncated: true, reason: "MCP_RESULT_LIMIT", preview: "<the first
512 KiB of the JSON>"}`, so an external caller can never receive a silently
shortened payload. If an oversized answer comes from `session/get`
(`pi_session_get`) and has a `compaction` record, Main projects that record to
the compact identity (`createdAt` and `details.generation`) and checks the size
again before returning the truncation envelope. This lets a long session's
transcript survive when its unbounded `ContextCompactionRecord` (`summary` /
`retainedTail` / `details.modifiedFiles`) alone caused the overflow. Results
already under the limit retain their full compaction details, and the desktop's
own session detail is unchanged.

The six `session/collaboration/*` operations are first-party-plugin-only: they
require an authenticated plugin tool invocation context, so they appear in
Expand Down
24 changes: 24 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -9397,6 +9397,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
| M6+ (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project |
| Post-MVP | E2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6) |
| Post-baseline local automation | E2E-220 |
| Post-baseline local automation (MCP `pi_session_get` large compaction) | E2E-MCP-session-get-projects-large-compaction |
| Post-MVP remote control | E2E-221, E2E-222, E2E-223, E2E-224, E2E-225, E2E-226, E2E-227, E2E-228, E2E-229, E2E-230, E2E-231, E2E-232 |
| Trusted extensions (R7 v1) | E2E-DIALOG-long-text-boundaries, E2E-241, E2E-242, E2E-HOOKS-prompt-chain, E2E-HOOKS-cancel-and-dispose, E2E-TRUSTED-EXTENSION-custom-agent-stream-and-binding, E2E-243, E2E-244, E2E-245, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list |
| Trusted extensions (R7 v1 npm recovery) | E2E-PLUGIN-import-extension-recovers-missing-npm |
Expand Down Expand Up @@ -13433,6 +13434,29 @@ are withdrawn with ADR 0165.
full Electron journey documented and remains deferred by the no-local-E2E
policy

#### E2E-MCP-session-get-projects-large-compaction

- **Preconditions**: Start PI-Desktop with `PI_DESKTOP_MCP_CONTROL=1`. A durable
session exists whose `session.compaction` record (`summary` / `retainedTail` /
`details.modifiedFiles`) alone serializes to more than the 512 KiB MCP result
limit.
- **Steps**: 1) Read `mcp-control.json`, use its URL and bearer token, and
complete the MCP handshake. 2) Call `pi_session_get` for that session with any
`messageLimit` / `contentLimit` / `messageBefore`. 3) Inspect
`structuredContent`. 4) Repeat for a small session.
- **Expected**: The answer is not the `{truncated: true, reason:
"MCP_RESULT_LIMIT", preview}` envelope; `session.messages` carries the
requested transcript page; `session.compaction` keeps `createdAt` and
`details.generation` while `summary`, `retainedTail`, and
`details.modifiedFiles` are absent. The small session's answer is unchanged.
- **Specs linked**: `03-runtime/01-ipc-protocol.md` §13d
- **Acceptance**: C (sessions), Quality
- **Milestone**: M6+
- **Status**: The local MCP server contract test in
`apps/desktop/test/mcp-control.test.mjs` exercises authenticated JSON-RPC
`tools/call` for both oversized and under-limit `pi_session_get` results. The
separate full Electron-to-Host journey remains release qualification.

#### E2E-234: Workspace security denylist and ignore layers

- **Preconditions**: A project containing `.env`, `.env.example`,
Expand Down
9 changes: 8 additions & 1 deletion docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1798,7 +1798,14 @@ Electron 等待主机关闭之前会停止服务,并将清单标记为非活
`read`、`write` 或 `dangerous`;通用危险操作,以及命名的删除会话、配置会话和决议
计划工具,都要求 `confirm: true`。该标志是 Agent 确认,不是桌面用户弹窗。所有调用
仍会经过现有 IPC 处理器的校验、主机权限、工作区边界和错误模型。文本负载和
`structuredContent` 都有大小上限。
`structuredContent` 都有大小上限:512 KiB(`MAX_RESULT_CHARS`)。超出上限的答复不会被
原样返回,而是替换为 `{truncated: true, reason: "MCP_RESULT_LIMIT", preview: "<JSON 前
512 KiB>"}`,因此外部调用方永远不会收到被静默缩短的负载。如果超限答复来自
`session/get`(`pi_session_get`)且含有 `compaction` 记录,Main 会先将该记录投影为精简身份
(`createdAt` 与 `details.generation`),再复查大小,然后才返回截断信封。这样,当长会话中
无界增长的 `ContextCompactionRecord`(`summary` / `retainedTail` / `details.modifiedFiles`)
本身导致超限时,转写仍可完整返回。未超限的答复保留完整 compaction 详情;桌面自己的会话详情
保持不变。

六个 `session/collaboration/*` 操作仅限第一方插件:它们要求经过认证的插件工具调用上下文,
因此会出现在 `pi.desktop.listOperations` 中并可通过 `pi.desktop.invoke` 调用,但被排除在
Expand Down
20 changes: 20 additions & 0 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -5642,6 +5642,7 @@ eleven-tool-round desktop paths are verified by
| M6+(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project |
| 后MVP | E2E-022A、E2E-022B、E2E-022C、E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M(插件路线图 R2/R3/R6) |
| 基线后本地自动化 | E2E-220 |
| 基线后本地自动化(MCP `pi_session_get` 超大 compaction) | E2E-MCP-session-get-projects-large-compaction |
| MVP 后远程控制 | E2E-221、E2E-222、E2E-223、E2E-224、E2E-225、E2E-226、E2E-227、E2E-228、E2E-229、E2E-230、E2E-231、E2E-232 |
| 受信任扩展(R7 v1) | E2E-DIALOG-long-text-boundaries、E2E-241、E2E-242、E2E-HOOKS-cancel-and-dispose、E2E-243、E2E-244、E2E-245、E2E-PLUGIN-imported-pi-package-skills、E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency、E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list |
| 受信任扩展(R7 v1 npm 恢复) | E2E-PLUGIN-import-extension-recovers-missing-npm |
Expand Down Expand Up @@ -7642,6 +7643,25 @@ eleven-tool-round desktop paths are verified by
- **状态**:由 `apps/desktop/test/mcp-control.test.mjs` 覆盖 MCP 协议/单元;完整 Electron
旅程已记录,仍按策略延后

#### E2E-MCP-session-get-projects-large-compaction:超大 compaction 记录不再让 pi_session_get 整包截断

- **前提条件**:使用 `PI_DESKTOP_MCP_CONTROL=1` 启动 PI-Desktop。存在一个持久会话,其
`session.compaction` 记录(`summary` / `retainedTail` / `details.modifiedFiles`)单独
序列化即超过 512 KiB 的 MCP 结果上限。
- **步骤**:1)读取 `mcp-control.json`,用其 URL 和 bearer token 完成 MCP 握手。2)对该会话
调用 `pi_session_get`,`messageLimit` / `contentLimit` / `messageBefore` 取任意值。3)检查
`structuredContent`。4)对一个普通小会话重复。
- **预期**:答复不是 `{truncated: true, reason: "MCP_RESULT_LIMIT", preview}` 信封;
`session.messages` 带回请求的转写页;`session.compaction` 保留 `createdAt` 与
`details.generation`,而 `summary`、`retainedTail`、`details.modifiedFiles` 不存在。
小会话的答复不变。
- **链接规格**:`03-runtime/01-ipc-protocol.md` §13d
- **验收**:C(会话)、质量
- **里程碑**:M6+
- **状态**:`apps/desktop/test/mcp-control.test.mjs` 中的本地 MCP Server 合约测试会运行带认证的
JSON-RPC `tools/call`,分别验证超限和未超限的 `pi_session_get` 答复。完整 Electron 到 Host
旅程仍属于发布验收。

## 受信任扩展场景(R7 v1)

以下场景是 D387 / ADR 0214 与 `07-plugins/16-trusted-extensions.md` 的验收目标;无头
Expand Down
Loading