pi-ui-extension 1.1.8 → 1.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,7 +48,18 @@ The bottom of the `SESSIONS` panel exposes four resource categories:
48
48
  - `Extensions`
49
49
  - `MCP`
50
50
 
51
- These shortcuts let you inspect the resources currently loaded by the project/Pi environment, making it easier to verify which agent instructions, skills, extensions, and MCP servers are actually available.
51
+ These shortcuts let you inspect the resources discovered by the project/Pi environment. The MCP view distinguishes configured and registered servers from confirmed connections.
52
+
53
+ #### MCP Status Center
54
+
55
+ Click **MCP** in the sidebar to open a read-only status picker covering global and trusted-project MCP configuration, servers registered by extensions via `pi.registerMcpServer()`, and observations from `pi-mcp-adapter/status/v1`.
56
+
57
+ - See configuration scope, project overrides, and enabled/disabled state (including native Pi `enabled: false`).
58
+ - Connection health is only shown when an adapter explicitly reports it. **Configured, registered, and loaded do not mean connected.**
59
+ - Commands, endpoints, headers, tokens, environment values, and raw errors are never displayed.
60
+ - Tool usage now attributes native Pi `mcp__server__tool` calls to their MCP server, alongside existing gateway and `mcpScript` counts.
61
+
62
+ Pi does not currently expose its complete native MCP connection state to ordinary UI extensions. For live connection diagnostics, reconnects, authentication, or configuration changes, use the active `/mcp` manager (built-in or provided by your MCP adapter).
52
63
 
53
64
  ### 4. CTX context inspection
54
65
 
@@ -154,7 +165,7 @@ Example configuration:
154
165
  To pin a specific version:
155
166
 
156
167
  ```bash
157
- pi install npm:pi-ui-extension@1.1.1
168
+ pi install npm:pi-ui-extension@1.1.10
158
169
  ```
159
170
 
160
171
  or:
@@ -162,7 +173,7 @@ or:
162
173
  ```json
163
174
  {
164
175
  "packages": [
165
- "npm:pi-ui-extension@1.1.1"
176
+ "npm:pi-ui-extension@1.1.10"
166
177
  ]
167
178
  }
168
179
  ```
@@ -190,15 +201,15 @@ Configuration form:
190
201
  Pin to a Git tag:
191
202
 
192
203
  ```bash
193
- pi install git:github.com/1993yihuan/pi-ui-extension@v1.1.1
204
+ pi install git:github.com/1993yihuan/pi-ui-extension@v1.1.10
194
205
  ```
195
206
 
196
- ### Temporary usage
207
+ ### Temporary local-source testing
197
208
 
198
- To load the package for a single Pi launch without modifying `settings.json`:
209
+ To try a local checkout without changing `settings.json`, run from the repository root. Disable automatic extension discovery to avoid loading a previously installed copy of `pi-ui-extension` twice:
199
210
 
200
211
  ```bash
201
- pi -e npm:pi-ui-extension
212
+ pi --no-extensions -e builtin:mcp -e ./extensions/index.ts --tui-mode fullscreen
202
213
  ```
203
214
 
204
215
  ### Project-local installation
@@ -337,7 +348,7 @@ Pi's extension entry point is declared explicitly in `package.json`:
337
348
 
338
349
  ## CI / Release
339
350
 
340
- Every push to `main` and every pull request runs:
351
+ Every push to `main` and every pull request runs on **both Node.js 22.19.0 and 24**:
341
352
 
342
353
  ```text
343
354
  npm ci
@@ -357,6 +368,7 @@ A `v*` tag triggers the release workflow:
357
368
 
358
369
  ```text
359
370
  Checkout
371
+ → validate release tag format and main ancestry
360
372
  → Node.js 24
361
373
  → npm ci
362
374
  → npm run check
@@ -364,6 +376,7 @@ Checkout
364
376
  → npm pack --dry-run
365
377
  → npm Trusted Publishing (OIDC)
366
378
  → npm publish
379
+ → verify npm Registry visibility (with retries)
367
380
  → GitHub Release
368
381
  ```
369
382
 
@@ -382,7 +395,7 @@ npm publishing uses GitHub Actions OIDC Trusted Publishing, so no long-lived `NP
382
395
 
383
396
  - Pi Coding Agent
384
397
  - Node.js `>= 22.19.0` (use a Node version supported by your Pi installation)
385
- - Development and CI currently use Node.js 24
398
+ - CI validates Node.js 22.19.0 and 24; releases use Node.js 24
386
399
 
387
400
  ## License
388
401
 
package/README.zh-CN.md CHANGED
@@ -48,7 +48,18 @@ Sidebar 支持鼠标交互、滚轮滚动和终端尺寸变化。终端高度不
48
48
  - `Extensions`
49
49
  - `MCP`
50
50
 
51
- 点击后可以直接查看当前项目 / 当前 Pi 环境中已加载的相关资源,便于快速确认 Agent 当前实际使用了哪些上下文、Skill、Extension 和 MCP Server。
51
+ 点击后可以查看当前项目 / Pi 环境发现的相关资源。其中 MCP 状态中心会区分“已配置 / 已注册”和“已确认连接”,避免把配置存在误认为连接成功。
52
+
53
+ #### MCP 状态中心
54
+
55
+ 点击 Sidebar 中的 **MCP** 按钮可打开只读状态中心,查看全局和可信项目配置的 MCP Server、扩展通过 `pi.registerMcpServer()` 注册的服务器,以及 `pi-mcp-adapter/status/v1` 提供的运行时观察结果。
56
+
57
+ - 显示服务器来源、启用/禁用状态与配置覆盖情况;项目级 `enabled: false` 会正确覆盖全局配置。
58
+ - 仅当 Adapter 明确报告连接状态时显示 Connected、Connecting、Failed 等状态;**已配置、已注册或已加载不代表已连接**。
59
+ - 不展示命令、URL、环境变量、请求头、Token 或原始错误内容。
60
+ - 工具调用统计支持 Pi 原生 `mcp__server__tool` 格式,继续兼容既有 MCP Gateway / `mcpScript` 统计。
61
+
62
+ Pi 目前没有向普通 UI 扩展公开完整的原生 MCP 实时连接状态。查看连接错误、重新连接、登录或启停服务器,请使用当前生效的 `/mcp` 管理界面(Pi 内置或由第三方 MCP Adapter 提供);本状态中心不执行连接或配置修改。
52
63
 
53
64
  ### 4. CTX 上下文观察
54
65
 
@@ -154,7 +165,7 @@ Pi 会安装 package,并将声明写入个人配置:
154
165
  如果希望固定版本:
155
166
 
156
167
  ```bash
157
- pi install npm:pi-ui-extension@1.1.1
168
+ pi install npm:pi-ui-extension@1.1.10
158
169
  ```
159
170
 
160
171
  或者:
@@ -162,7 +173,7 @@ pi install npm:pi-ui-extension@1.1.1
162
173
  ```json
163
174
  {
164
175
  "packages": [
165
- "npm:pi-ui-extension@1.1.1"
176
+ "npm:pi-ui-extension@1.1.10"
166
177
  ]
167
178
  }
168
179
  ```
@@ -190,15 +201,15 @@ pi install git:github.com/1993yihuan/pi-ui-extension
190
201
  固定到 Git tag:
191
202
 
192
203
  ```bash
193
- pi install git:github.com/1993yihuan/pi-ui-extension@v1.1.1
204
+ pi install git:github.com/1993yihuan/pi-ui-extension@v1.1.10
194
205
  ```
195
206
 
196
- ### 临时试用
207
+ ### 临时加载本地源码
197
208
 
198
- 如果只想在一次 Pi 启动中测试,不修改 `settings.json`:
209
+ 如果只想在一次 Pi 启动中测试本地源码,不修改 `settings.json`,请在仓库根目录运行。禁用自动扩展发现可以避免与已经安装的 `pi-ui-extension` 重复加载:
199
210
 
200
211
  ```bash
201
- pi -e npm:pi-ui-extension
212
+ pi --no-extensions -e builtin:mcp -e ./extensions/index.ts --tui-mode fullscreen
202
213
  ```
203
214
 
204
215
  ### 项目级安装
@@ -337,7 +348,7 @@ Pi 的扩展入口在 `package.json` 中显式声明:
337
348
 
338
349
  ## CI / Release
339
350
 
340
- 提交到 `main` 或创建 PR 时,GitHub Actions 会自动执行:
351
+ 提交到 `main` 或创建 PR 时,GitHub Actions 会分别在 **Node.js 22.19.0 和 24** 上执行:
341
352
 
342
353
  ```text
343
354
  npm ci
@@ -357,6 +368,7 @@ git push --tags
357
368
 
358
369
  ```text
359
370
  Checkout
371
+ → 校验版本标签格式、确认提交位于 main 历史
360
372
  → Node.js 24
361
373
  → npm ci
362
374
  → npm run check
@@ -364,6 +376,7 @@ Checkout
364
376
  → npm pack --dry-run
365
377
  → npm Trusted Publishing (OIDC)
366
378
  → npm publish
379
+ → 重试检查 npm Registry 已可查询新版本
367
380
  → GitHub Release
368
381
  ```
369
382
 
@@ -382,7 +395,7 @@ npm 发布使用 GitHub Actions OIDC Trusted Publishing,不需要在 GitHub Re
382
395
 
383
396
  - Pi Coding Agent
384
397
  - Node.js `>= 22.19.0`(建议使用当前 Pi 支持的 Node 版本)
385
- - 开发与 CI 当前使用 Node.js 24
398
+ - CI 覆盖 Node.js 22.19.0 和 24;发布工作流使用 Node.js 24
386
399
 
387
400
  ## License
388
401
 
@@ -13,7 +13,7 @@ import { installContextEstimates } from "./ctx/data.ts";
13
13
  import { createActivityTracker, type ActivityTracker } from "./ctx/activity.ts";
14
14
  import { installFooter, type FooterController } from "./footer/index.ts";
15
15
  import { isFabricFullCodeMode } from "./fabric-detection.ts";
16
- import { mcpResourceEntriesFromConfig, mcpResourceEntriesFromStatus, mergeMcpResourceEntries } from "./mcp/resources.ts";
16
+ import { adapterMcpServers, buildMcpStatusSnapshot, describeMcpStatus, type McpStatusSnapshot } from "./mcp/status-center.ts";
17
17
  import {
18
18
  createDefaultSidebarPanels,
19
19
  SESSION_PANEL_ID,
@@ -103,6 +103,49 @@ function showResourceDialog(
103
103
  });
104
104
  }
105
105
 
106
+ /** Read-only MCP status picker; only safe metadata is displayed. */
107
+ export function showMcpStatusDialog(
108
+ ctx: { ui: { select(title: string, options: string[]): Promise<string | undefined>; editor(title: string, prefill?: string): Promise<string | undefined>; notify(message: string, level: "info" | "warning"): void } },
109
+ snapshot: McpStatusSnapshot,
110
+ ): void {
111
+ const rows = snapshot.servers.map((server) => ({
112
+ name: server.name,
113
+ description: describeMcpStatus(server),
114
+ details: [
115
+ `Server: ${server.name}`,
116
+ `Source: ${server.source}`,
117
+ `Enabled: ${server.enabled ? "yes" : "no"}`,
118
+ `Observed by adapter: ${server.loaded ? "yes" : "no"}`,
119
+ `Status: ${describeMcpStatus(server)}`,
120
+ "",
121
+ "Only an adapter's explicit connection state establishes connected/failed.",
122
+ "Pi native connection health is not exposed to UI extensions.",
123
+ "Use Pi's built-in /mcp manager for live connection details.",
124
+ ].join("\n"),
125
+ }));
126
+ const warnings = snapshot.warnings.map((warning) => ({
127
+ name: "⚠ Configuration warning",
128
+ description: warning,
129
+ details: `${warning}\nCheck your MCP configuration with Pi's built-in /mcp manager.`,
130
+ }));
131
+ const all = [...rows, ...warnings];
132
+ if (all.length === 0) {
133
+ ctx.ui.notify("No MCP servers found. Use /mcp to configure servers.", "info");
134
+ return;
135
+ }
136
+ const choices = resourceDialogOptions(all);
137
+ const enabledCount = snapshot.servers.filter((server) => server.enabled).length;
138
+ void ctx.ui.select(
139
+ `MCP Status · ${enabledCount} enabled / ${snapshot.servers.length} total · ↵ details`,
140
+ choices,
141
+ ).then((choice) => {
142
+ if (!choice) return;
143
+ const index = choices.indexOf(choice);
144
+ const item = all[index];
145
+ if (item) void ctx.ui.editor(`${item.name} · MCP (read-only)`, item.details).then(() => {});
146
+ });
147
+ }
148
+
106
149
  /** Load Agents.md contexts, Skills, and Extensions as display rows. */
107
150
  export async function loadResourceEntries(
108
151
  pi: ExtensionAPI,
@@ -185,9 +228,11 @@ async function clearOtherSessions(
185
228
  export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
186
229
  const toolView = await installToolViewController(pi);
187
230
  let mcpResources: readonly ResourceEntry[] = [];
188
- let liveMcpResources: readonly ResourceEntry[] = [];
189
- const currentMcpResources = (cwd: string, trusted: boolean): readonly ResourceEntry[] =>
190
- mergeMcpResourceEntries(mcpResourceEntriesFromConfig(getAgentDir(), cwd, trusted), liveMcpResources);
231
+ let liveMcpServers: NonNullable<ReturnType<typeof adapterMcpServers>> = [];
232
+ let registeredServers: unknown = [];
233
+ let mcpCwd = process.cwd();
234
+ let mcpTrusted = false;
235
+ let mcpStatus: McpStatusSnapshot = { servers: [], warnings: [] };
191
236
  let toolViewMode: ToolViewMode | undefined;
192
237
  let footer: FooterController | undefined;
193
238
  let sidebar: SidebarController | undefined;
@@ -221,12 +266,24 @@ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
221
266
  };
222
267
  let switchRequested = false;
223
268
  const getMcpServerNames = () => mcpResources.map((server) => server.name);
224
- const unsubscribeMcpStatus = pi.events.on("pi-mcp-adapter/status/v1", (value) => {
225
- liveMcpResources = mcpResourceEntriesFromStatus(value);
226
- mcpResources = mergeMcpResourceEntries(mcpResources, liveMcpResources);
269
+ const updateMcpStatus = (): void => {
270
+ mcpStatus = buildMcpStatusSnapshot(getAgentDir(), mcpCwd, mcpTrusted, registeredServers, liveMcpServers);
271
+ mcpResources = mcpStatus.servers
272
+ .filter((server) => server.enabled)
273
+ .map((server) => ({ name: server.name, description: describeMcpStatus(server) }));
227
274
  const branch = getCurrentBranch?.();
228
275
  if (branch) toolUsage?.resetSession(branch, getMcpServerNames(), { preserveExchange: true });
229
276
  sidebar?.refresh();
277
+ };
278
+ const unsubscribeMcpStatus = pi.events.on("pi-mcp-adapter/status/v1", (value) => {
279
+ const observation = adapterMcpServers(value);
280
+ if (!observation) return;
281
+ liveMcpServers = observation;
282
+ updateMcpStatus();
283
+ });
284
+ pi.on("mcp_servers_change", (event) => {
285
+ registeredServers = event.servers;
286
+ updateMcpStatus();
230
287
  });
231
288
 
232
289
  pi.registerCommand(SESSION_RENAME_COMMAND, {
@@ -456,7 +513,10 @@ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
456
513
  // Pi resets extension UI before starting/reloading a session. Do not call
457
514
  // stale controllers from the previous runtime after that reset.
458
515
  clearControllers();
459
- mcpResources = currentMcpResources(ctx.cwd, ctx.isProjectTrusted());
516
+ mcpCwd = ctx.cwd;
517
+ mcpTrusted = ctx.isProjectTrusted();
518
+ registeredServers = typeof pi.getMcpServers === "function" ? pi.getMcpServers() : [];
519
+ updateMcpStatus();
460
520
  toolViewMode = toolView.getMode();
461
521
  getCurrentBranch = () => ctx.sessionManager.getBranch();
462
522
  const branch = ctx.sessionManager.getBranch();
@@ -534,7 +594,12 @@ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
534
594
  ctx.ui.notify("Wait for the current run to finish before opening resources", "warning");
535
595
  return;
536
596
  }
537
- void loadResourceEntries(pi, ctx.cwd, button === "mcp" ? currentMcpResources(ctx.cwd, ctx.isProjectTrusted()) : mcpResources, ctx.isProjectTrusted(), button)
597
+ if (button === "mcp") {
598
+ updateMcpStatus();
599
+ showMcpStatusDialog(ctx, mcpStatus);
600
+ return;
601
+ }
602
+ void loadResourceEntries(pi, ctx.cwd, mcpResources, ctx.isProjectTrusted(), button)
538
603
  .then((rows) => {
539
604
  if (!rows || rows.length === 0) {
540
605
  ctx.ui.notify("No resources of this kind loaded", "info");
@@ -568,6 +633,7 @@ export default async function piUiExtension(pi: ExtensionAPI): Promise<void> {
568
633
  pi.on("session_shutdown", () => {
569
634
  unsubscribeToolViewMode();
570
635
  unsubscribeMcpStatus();
636
+ liveMcpServers = [];
571
637
 
572
638
  // The sidebar replaces the fullscreen TUI layout root directly, so Pi
573
639
  // cannot tear it down on our behalf during session replacement. Dispose
@@ -11,6 +11,7 @@ test("entry wires early/late MCP status into restored and live usage without dou
11
11
  const handlers = new Map<string, any[]>(), bus = new Map<string, any>();
12
12
  const listeners = new Set<any>();
13
13
  let branch: any[] = [], footer: any;
14
+ let registrations: any[] = [{ name: "native_mcp", config: { command: "not-executed" }, extensionPath: "/extension.ts" }];
14
15
  const tui: any = {
15
16
  [Symbol.for("@earendil-works/pi-tui/viewport")]: true,
16
17
  terminal: { columns: 240, rows: 100 }, layoutRoot: { render: () => [], invalidate() {} }, inputListeners: listeners,
@@ -21,6 +22,7 @@ test("entry wires early/late MCP status into restored and live usage without dou
21
22
  const pi: any = {
22
23
  registerTool() {}, registerCommand() {}, registerShortcut() {},
23
24
  getCommands: () => [], getActiveTools: () => [], getAllTools: () => [], getThinkingLevel: () => "off",
25
+ getMcpServers: () => registrations,
24
26
  on(name: string, fn: any) { handlers.set(name, [...(handlers.get(name) ?? []), fn]); },
25
27
  events: { on(name: string, fn: any) { bus.set(name, fn); return () => bus.delete(name); } },
26
28
  };
@@ -41,6 +43,12 @@ test("entry wires early/late MCP status into restored and live usage without dou
41
43
  await emit("session_start");
42
44
  assert.match(output(), /early\s+0\/1/);
43
45
  await emit("before_agent_start", { systemPromptOptions: {} });
46
+ branch.push({ type: "message", message: { role: "toolResult", toolName: "mcp__native_mcp__ping", details: {} } });
47
+ await emit("tool_execution_end", { toolCallId: "native-1", toolName: "mcp__native_mcp__ping", isError: false, result: { details: {} } });
48
+ assert.match(output(), /native_mcp\s+1\/1/);
49
+ registrations = [];
50
+ await emit("mcp_servers_change", { servers: [] });
51
+ assert.doesNotMatch(output(), /native_mcp\s+1\/1/);
44
52
  branch.push(saved("late"));
45
53
  await emit("tool_execution_end", { toolCallId: "call-1", toolName: "mcpScript", isError: false, result: { details: details("late") } });
46
54
  assert.doesNotMatch(output(), /late\s+1\/1/);
@@ -1,5 +1,4 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { adapterMcpServers, configuredMcpServers } from "./status-center.ts";
3
2
 
4
3
  export type McpResourceEntry = {
5
4
  readonly name: string;
@@ -7,26 +6,9 @@ export type McpResourceEntry = {
7
6
  };
8
7
 
9
8
  export function mcpResourceEntriesFromConfig(agentDir: string, cwd: string, trusted: boolean): readonly McpResourceEntry[] {
10
- const paths = [join(agentDir, "mcp.json")];
11
- if (trusted) paths.push(join(cwd, ".pi", "mcp.json"));
12
- const rows = new Map<string, McpResourceEntry>();
13
- for (const path of paths) {
14
- try {
15
- const config: unknown = JSON.parse(readFileSync(path, "utf8"));
16
- if (!config || typeof config !== "object" || Array.isArray(config)) continue;
17
- const servers = (config as { mcpServers?: unknown }).mcpServers;
18
- if (!servers || typeof servers !== "object" || Array.isArray(servers)) continue;
19
- for (const [name, value] of Object.entries(servers)) {
20
- if (!name.trim() || !value || typeof value !== "object" || Array.isArray(value)) continue;
21
- if ((value as { disabled?: unknown }).disabled === true) {
22
- rows.delete(name);
23
- continue;
24
- }
25
- rows.set(name, { name, description: "Configured MCP server (connection not verified)" });
26
- }
27
- } catch { /* Missing or invalid configuration is not fatal. */ }
28
- }
29
- return [...rows.values()];
9
+ return configuredMcpServers(agentDir, cwd, trusted).servers
10
+ .filter((server) => server.enabled)
11
+ .map(({ name }) => ({ name, description: "Configured MCP server (connection not verified)" }));
30
12
  }
31
13
 
32
14
  export function mergeMcpResourceEntries(configured: readonly McpResourceEntry[], live: readonly McpResourceEntry[]): readonly McpResourceEntry[] {
@@ -35,29 +17,9 @@ export function mergeMcpResourceEntries(configured: readonly McpResourceEntry[],
35
17
  return [...rows.values()];
36
18
  }
37
19
 
38
- type McpStatusServer = {
39
- readonly name?: unknown;
40
- readonly disabled?: unknown;
41
- };
42
-
43
20
  /** Convert the adapter's read-only status snapshot into configured, enabled MCP rows. */
44
21
  export function mcpResourceEntriesFromStatus(value: unknown): readonly McpResourceEntry[] {
45
- if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
46
- const snapshot = value as { version?: unknown; servers?: unknown };
47
- if (snapshot.version !== 1 || !Array.isArray(snapshot.servers)) return [];
48
-
49
- const seen = new Set<string>();
50
- const rows: McpResourceEntry[] = [];
51
- for (const rawServer of snapshot.servers) {
52
- if (typeof rawServer !== "object" || rawServer === null || Array.isArray(rawServer)) continue;
53
- const server = rawServer as McpStatusServer;
54
- const name = typeof server.name === "string" ? server.name.trim() : "";
55
- if (!name || server.disabled === true || seen.has(name)) continue;
56
- seen.add(name);
57
- rows.push({
58
- name,
59
- description: "MCP server loaded in the current Pi session",
60
- });
61
- }
62
- return rows;
22
+ return (adapterMcpServers(value) ?? [])
23
+ .filter((server) => server.enabled)
24
+ .map(({ name }) => ({ name, description: "MCP server loaded in the current Pi session" }));
63
25
  }
@@ -0,0 +1,147 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { showMcpStatusDialog } from "../index.ts";
7
+ import {
8
+ adapterMcpServers, buildMcpStatusSnapshot, configuredMcpServers,
9
+ describeMcpStatus, registeredMcpServers,
10
+ } from "./status-center.ts";
11
+
12
+ function withConfigs<Result>(run: (agent: string, project: string) => Result): Result {
13
+ const root = mkdtempSync(join(tmpdir(), "pi-mcp-status-"));
14
+ const agent = join(root, "agent");
15
+ const project = join(root, "project");
16
+ mkdirSync(agent);
17
+ mkdirSync(join(project, ".pi"), { recursive: true });
18
+ try { return run(agent, project); }
19
+ finally { rmSync(root, { recursive: true, force: true }); }
20
+ }
21
+
22
+ test("native Pi enabled:false and trusted project overrides win without leaking config", () => {
23
+ withConfigs((agent, project) => {
24
+ writeFileSync(join(agent, "mcp.json"), JSON.stringify({ mcpServers: {
25
+ docs: { url: "https://private.example/token-123", headers: { Authorization: "Bearer secret" } },
26
+ blocked: { command: "super-secret-executable", enabled: false },
27
+ reenable: { command: "secret-command", enabled: false },
28
+ } }));
29
+ writeFileSync(join(project, ".pi", "mcp.json"), JSON.stringify({ mcpServers: {
30
+ docs: { enabled: false },
31
+ reenable: { enabled: true, exposure: "direct" },
32
+ local: { command: "private-key-path" },
33
+ } }));
34
+
35
+ assert.deepEqual(configuredMcpServers(agent, project, false).servers.map((s) => [s.name, s.enabled]), [
36
+ ["docs", true], ["blocked", false], ["reenable", false],
37
+ ]);
38
+ const snapshot = buildMcpStatusSnapshot(agent, project, true, []);
39
+ assert.deepEqual(snapshot.servers.map((s) => [s.name, s.source, s.enabled]), [
40
+ ["docs", "project override", false],
41
+ ["blocked", "global", false],
42
+ ["reenable", "project override", true],
43
+ ["local", "project", true],
44
+ ]);
45
+ assert.doesNotMatch(JSON.stringify(snapshot), /token-123|secret|private-key-path/);
46
+ assert.match(describeMcpStatus(snapshot.servers[0]!), /Disabled/);
47
+ });
48
+ });
49
+
50
+ test("extension registrations are not mistaken for confirmed connections", () => {
51
+ withConfigs((agent, project) => {
52
+ const registered = [
53
+ { name: "extension_mcp", extensionPath: "/sensitive/path", config: { url: "https://secret.invalid" } },
54
+ { name: "disabled_ext", config: { command: "secret", enabled: false } },
55
+ { name: "\u001b[31munsafe", config: { command: "secret" } },
56
+ ];
57
+ assert.deepEqual(registeredMcpServers(registered).map((s) => s.name), ["extension_mcp", "disabled_ext"]);
58
+ const snapshot = buildMcpStatusSnapshot(agent, project, true, registered);
59
+ assert.deepEqual(snapshot.servers.map((s) => [s.name, s.source, s.connection]), [
60
+ ["extension_mcp", "extension", "unknown"],
61
+ ["disabled_ext", "extension", "unknown"],
62
+ ]);
63
+ assert.match(describeMcpStatus(snapshot.servers[0]!), /Registered.*unverified/);
64
+ assert.doesNotMatch(JSON.stringify(snapshot), /sensitive|secret|unsafe/);
65
+ });
66
+ });
67
+
68
+ test("adapter v1 reports loading separately from explicit health and ignores disabled entries", () => {
69
+ withConfigs((agent, project) => {
70
+ writeFileSync(join(agent, "mcp.json"), JSON.stringify({ mcpServers: {
71
+ docs: { command: "secret" },
72
+ blocked: { enabled: false, command: "secret" },
73
+ } }));
74
+ const observed = adapterMcpServers({ version: 1, servers: [
75
+ { name: "docs" },
76
+ { name: "logged_in", state: "connected", error: "secret" },
77
+ { name: "auth", status: "needs-auth", headers: { Authorization: "secret" } },
78
+ { name: "blocked", state: "connected" },
79
+ { name: "not_running", enabled: false, state: "connected" },
80
+ { name: "logged_in", state: "connected" },
81
+ { name: "\u001b[33muntrusted", state: "connected" },
82
+ ] });
83
+ assert.equal(observed?.length, 5);
84
+ const snapshot = buildMcpStatusSnapshot(agent, project, true, [], observed);
85
+ assert.deepEqual(snapshot.servers.map((s) => [s.name, s.enabled, s.loaded, s.connection]), [
86
+ ["docs", true, true, "unknown"],
87
+ ["blocked", false, false, "unknown"],
88
+ ["logged_in", true, true, "connected"],
89
+ ["auth", true, true, "needs-auth"],
90
+ ["not_running", false, false, "unknown"],
91
+ ]);
92
+ assert.match(describeMcpStatus(snapshot.servers[0]!), /Loaded.*unverified/);
93
+ assert.match(describeMcpStatus(snapshot.servers[2]!), /Connected.*adapter reported/);
94
+ assert.doesNotMatch(JSON.stringify(snapshot), /secret|untrusted/);
95
+ assert.deepEqual(
96
+ buildMcpStatusSnapshot(agent, project, true, [], []).servers.map((s) => s.name),
97
+ ["docs", "blocked"],
98
+ "a replacement adapter snapshot must not retain removed servers",
99
+ );
100
+ });
101
+ });
102
+
103
+ test("malformed adapter snapshots and invalid config files fail safely", () => {
104
+ assert.equal(adapterMcpServers({ version: 2, servers: [] }), undefined);
105
+ assert.equal(adapterMcpServers({ version: 1, servers: "bad" }), undefined);
106
+ assert.deepEqual(adapterMcpServers({ version: 1, servers: [{ name: "ok", state: "bogus" }] }), [
107
+ { name: "ok", enabled: true, state: "unknown" },
108
+ ]);
109
+ withConfigs((agent, project) => {
110
+ writeFileSync(join(agent, "mcp.json"), '{"mcpServers":{"api":{"headers":{"Authorization":"secret"}}');
111
+ const snapshot = buildMcpStatusSnapshot(agent, project, false, []);
112
+ assert.deepEqual(snapshot.servers, []);
113
+ assert.deepEqual(snapshot.warnings, ["Invalid global MCP configuration"]);
114
+ assert.doesNotMatch(JSON.stringify(snapshot), /secret/);
115
+ });
116
+ });
117
+
118
+ test("MCP status center opens a safe read-only detail without leaking secrets", async () => {
119
+ const snapshot = withConfigs((agent, project) => {
120
+ writeFileSync(join(agent, "mcp.json"), JSON.stringify({ mcpServers: {
121
+ docs: { command: "token-very-secret" },
122
+ blocked: { url: "https://private.invalid", enabled: false },
123
+ } }));
124
+ return buildMcpStatusSnapshot(agent, project, true, [], adapterMcpServers({
125
+ version: 1, servers: [{ name: "docs", state: "connected", token: "token-very-secret" }],
126
+ }));
127
+ });
128
+ const selected: string[] = [];
129
+ const details: string[] = [];
130
+ showMcpStatusDialog({ ui: {
131
+ select(title: string, choices: string[]) {
132
+ selected.push(title, ...choices);
133
+ return Promise.resolve(choices[0]);
134
+ },
135
+ editor(title: string, content = "") {
136
+ details.push(title, content);
137
+ return Promise.resolve(undefined);
138
+ },
139
+ notify() {},
140
+ } }, snapshot);
141
+ await new Promise<void>((resolve) => setImmediate(resolve));
142
+ assert.match(selected.join("\n"), /MCP Status.*1 enabled \/ 2 total/);
143
+ assert.match(selected.join("\n"), /docs.*Connected/);
144
+ assert.match(selected.join("\n"), /blocked.*Disabled/);
145
+ assert.match(details.join("\n"), /Use Pi's built-in \/mcp/);
146
+ assert.doesNotMatch(JSON.stringify({ selected, details }), /token-very-secret|private.invalid/);
147
+ });
@@ -0,0 +1,178 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export type McpConnectionState =
5
+ | "unknown" | "connecting" | "connected" | "disconnected" | "needs-auth" | "failed";
6
+ export type McpStatusSource = "global" | "project" | "project override" | "extension" | "adapter";
7
+ export type McpServerStatus = {
8
+ readonly name: string;
9
+ readonly source: McpStatusSource;
10
+ readonly enabled: boolean;
11
+ readonly loaded: boolean;
12
+ /** Only an explicit runtime observation can establish connection state. */
13
+ readonly connection: McpConnectionState;
14
+ };
15
+ export type McpStatusSnapshot = {
16
+ readonly servers: readonly McpServerStatus[];
17
+ readonly warnings: readonly string[];
18
+ };
19
+
20
+ type ConfigServer = { name: string; source: McpStatusSource; enabled: boolean };
21
+ type ParsedConfig = { servers: Map<string, Record<string, unknown>> };
22
+ type RuntimeServer = { name: string; enabled: boolean; state: McpConnectionState };
23
+
24
+ function record(value: unknown): Record<string, unknown> | undefined {
25
+ return value !== null && typeof value === "object" && !Array.isArray(value)
26
+ ? value as Record<string, unknown> : undefined;
27
+ }
28
+
29
+ /** Pi MCP names are deliberately constrained; never render arbitrary terminal input from events. */
30
+ function serverName(value: unknown): string | undefined {
31
+ return typeof value === "string" && /^[a-zA-Z0-9_-]+$/.test(value) ? value : undefined;
32
+ }
33
+
34
+ function isEnabled(config: Record<string, unknown>): boolean {
35
+ // Pi uses enabled:false. Disabled:true is accepted for older adapter configs.
36
+ return config.enabled !== false && config.disabled !== true;
37
+ }
38
+
39
+ function readConfig(path: string, warnings: string[], scope: "global" | "project"): ParsedConfig {
40
+ let raw: string;
41
+ try {
42
+ raw = readFileSync(path, "utf8");
43
+ } catch (error) {
44
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") warnings.push(`Cannot read ${scope} MCP configuration`);
45
+ return { servers: new Map() };
46
+ }
47
+ try {
48
+ const config = record(JSON.parse(raw));
49
+ if (!config) throw new Error("invalid configuration shape");
50
+ const servers = config.mcpServers === undefined ? {} : record(config.mcpServers);
51
+ if (!servers) throw new Error("invalid server map");
52
+ const entries = new Map<string, Record<string, unknown>>();
53
+ for (const [name, value] of Object.entries(servers)) {
54
+ const server = record(value);
55
+ if (!serverName(name) || !server) {
56
+ warnings.push(`Invalid server entry in ${scope} MCP configuration`);
57
+ continue;
58
+ }
59
+ entries.set(name, server);
60
+ }
61
+ return { servers: entries };
62
+ } catch {
63
+ // Never include JSON contents, URLs, command lines, headers or parser excerpts.
64
+ warnings.push(`Invalid ${scope} MCP configuration`);
65
+ return { servers: new Map() };
66
+ }
67
+ }
68
+
69
+ /** Read native Pi global/project MCP configuration without touching credentials or transports. */
70
+ export function configuredMcpServers(
71
+ agentDir: string, cwd: string, trusted: boolean,
72
+ ): { readonly servers: readonly ConfigServer[]; readonly warnings: readonly string[] } {
73
+ const warnings: string[] = [];
74
+ const global = readConfig(join(agentDir, "mcp.json"), warnings, "global");
75
+ const entries = new Map<string, ConfigServer>();
76
+ for (const [name, value] of global.servers) {
77
+ entries.set(name, { name, source: "global", enabled: isEnabled(value) });
78
+ }
79
+ if (trusted) {
80
+ const project = readConfig(join(cwd, ".pi", "mcp.json"), warnings, "project");
81
+ for (const [name, value] of project.servers) {
82
+ const overridesGlobal = entries.has(name) &&
83
+ !("command" in value || "url" in value || "type" in value);
84
+ const previous = entries.get(name);
85
+ // Pi permits project-only enabled/exposure overrides of global servers.
86
+ entries.set(name, {
87
+ name,
88
+ source: overridesGlobal ? "project override" : "project",
89
+ enabled: overridesGlobal && value.enabled === undefined && value.disabled === undefined
90
+ ? previous!.enabled : isEnabled(value),
91
+ });
92
+ }
93
+ }
94
+ return { servers: [...entries.values()], warnings };
95
+ }
96
+
97
+ /** Extract extension-registered MCP names. Registration does not prove connection. */
98
+ export function registeredMcpServers(value: unknown): readonly RuntimeServer[] {
99
+ if (!Array.isArray(value)) return [];
100
+ const rows = new Map<string, RuntimeServer>();
101
+ for (const item of value) {
102
+ const server = record(item);
103
+ const name = serverName(server?.name);
104
+ const config = record(server?.config);
105
+ if (name && config) rows.set(name, { name, enabled: isEnabled(config), state: "unknown" });
106
+ }
107
+ return [...rows.values()];
108
+ }
109
+
110
+ /** Adapter v1 snapshots are observations, not an authoritative native Pi state API. */
111
+ export function adapterMcpServers(snapshot: unknown): readonly RuntimeServer[] | undefined {
112
+ const value = record(snapshot);
113
+ if (value?.version !== 1 || !Array.isArray(value.servers)) return undefined;
114
+ const rows = new Map<string, RuntimeServer>();
115
+ for (const entry of value.servers) {
116
+ const server = record(entry);
117
+ const name = serverName(server?.name);
118
+ if (!name) continue;
119
+ const explicit = server?.state ?? server?.status;
120
+ const state: McpConnectionState =
121
+ explicit === "connected" || explicit === "connecting" ||
122
+ explicit === "disconnected" || explicit === "needs-auth" || explicit === "failed"
123
+ ? explicit
124
+ : server?.connected === true ? "connected"
125
+ : server?.connected === false ? "disconnected" : "unknown";
126
+ rows.set(name, { name, enabled: isEnabled(server!), state });
127
+ }
128
+ return [...rows.values()];
129
+ }
130
+
131
+ export function buildMcpStatusSnapshot(
132
+ agentDir: string,
133
+ cwd: string,
134
+ trusted: boolean,
135
+ registered: unknown,
136
+ adapter: readonly RuntimeServer[] = [],
137
+ ): McpStatusSnapshot {
138
+ const config = configuredMcpServers(agentDir, cwd, trusted);
139
+ const rows = new Map<string, McpServerStatus>();
140
+ for (const server of config.servers) {
141
+ rows.set(server.name, { ...server, loaded: false, connection: "unknown" });
142
+ }
143
+ for (const server of registeredMcpServers(registered)) {
144
+ if (!rows.has(server.name)) {
145
+ rows.set(server.name, { name: server.name, source: "extension", enabled: server.enabled, loaded: false, connection: "unknown" });
146
+ }
147
+ }
148
+ for (const server of adapter) {
149
+ const configured = rows.get(server.name);
150
+ if (configured) {
151
+ // An explicit project disable must not be overwritten by an adapter's stale snapshot.
152
+ rows.set(server.name, {
153
+ ...configured,
154
+ loaded: server.enabled && configured.enabled,
155
+ connection: server.enabled && configured.enabled ? server.state : "unknown",
156
+ });
157
+ } else {
158
+ rows.set(server.name, {
159
+ name: server.name, source: "adapter", enabled: server.enabled,
160
+ loaded: server.enabled, connection: server.enabled ? server.state : "unknown",
161
+ });
162
+ }
163
+ }
164
+ return { servers: [...rows.values()], warnings: config.warnings };
165
+ }
166
+
167
+ export function describeMcpStatus(server: McpServerStatus): string {
168
+ const state = !server.enabled ? "Disabled"
169
+ : server.connection === "connected" ? "Connected (adapter reported)"
170
+ : server.connection === "connecting" ? "Connecting (adapter reported)"
171
+ : server.connection === "disconnected" ? "Disconnected (adapter reported)"
172
+ : server.connection === "needs-auth" ? "Sign-in required (adapter reported)"
173
+ : server.connection === "failed" ? "Failed (adapter reported)"
174
+ : server.loaded ? "Loaded (connection unverified)"
175
+ : server.source === "extension" ? "Registered (connection unverified)"
176
+ : "Configured (connection unverified)";
177
+ return `${state} · ${server.source}`;
178
+ }
@@ -80,6 +80,29 @@ test("adds the MCP server for proxy calls without hiding the gateway count", ()
80
80
  ]);
81
81
  });
82
82
 
83
+ test("attributes Pi native MCP tools to known servers, including sanitized names", () => {
84
+ const tracker = createToolUsageTracker([], () => ["docs", "chrome-devtools"]);
85
+ tracker.startExchange();
86
+ tracker.record(ended("mcp__docs__search"));
87
+ tracker.record(ended("mcp__chrome_devtools__open_page"));
88
+ assert.deepEqual(tracker.getSnapshot().counts.filter((s) => s.kind === "mcp"), [
89
+ { name: "chrome-devtools", kind: "mcp", exchange: 1, session: 1 },
90
+ { name: "docs", kind: "mcp", exchange: 1, session: 1 },
91
+ ]);
92
+ const restored = [{ type: "message", message: { role: "toolResult", toolName: "mcp__docs__search", details: {} } }];
93
+ tracker.resetSession(restored, ["docs"]);
94
+ assert.deepEqual(tracker.getSnapshot().counts.filter((s) => s.kind === "mcp"), [
95
+ { name: "docs", kind: "mcp", exchange: 0, session: 1 },
96
+ ]);
97
+ });
98
+
99
+ test("does not guess an MCP server from ambiguous normalized names", () => {
100
+ const tracker = createToolUsageTracker([], () => ["team-core", "team_core"]);
101
+ tracker.startExchange();
102
+ tracker.record(ended("mcp__team_core__search"));
103
+ assert.deepEqual(tracker.getSnapshot().counts.filter((s) => s.kind === "mcp"), []);
104
+ });
105
+
83
106
  test("counts each MCP call made by mcpScript", () => {
84
107
  const tracker = createToolUsageTracker([], () => ["codebase-memory-mcp", "context-mode"]);
85
108
  tracker.startExchange();
@@ -109,6 +109,16 @@ function resolveScriptServer(path: string, knownServers: readonly string[]): str
109
109
  return candidates.size === 1 ? [...candidates][0] : undefined;
110
110
  }
111
111
 
112
+ /** Pi native MCP tools use mcp__<sanitized-server>__<tool>. Never guess on collisions. */
113
+ function nativeMcpServer(toolName: string, knownServers: readonly string[]): string | undefined {
114
+ if (!toolName.startsWith("mcp__")) return undefined;
115
+ const matches = knownServers.filter((server) => {
116
+ const normalized = server.replace(/[^A-Za-z0-9_]/g, "_");
117
+ return toolName.startsWith(`mcp__${normalized}__`);
118
+ });
119
+ return matches.length === 1 ? matches[0] : undefined;
120
+ }
121
+
112
122
  function scriptServers(result: unknown, knownServers: readonly string[]): string[] {
113
123
  const details = asRecord(asRecord(result)?.details);
114
124
  if (details?.mode !== "script" || !Array.isArray(details.calls)) return [];
@@ -145,6 +155,9 @@ function parseUsage(toolName: string, result: unknown, knownServers: readonly st
145
155
  const contextServer = contextModeServer(topLevelName);
146
156
  if (contextServer) return { topLevelName, mcpServers: [contextServer] };
147
157
 
158
+ const nativeServer = nativeMcpServer(topLevelName, knownServers);
159
+ if (nativeServer) return { topLevelName, mcpServers: [nativeServer] };
160
+
148
161
  if (topLevelName === "mcpScript") {
149
162
  return { topLevelName, mcpServers: scriptServers(result, knownServers) };
150
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extension",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "type": "module",
5
5
  "description": "Pi terminal UI extension with session management, context inspection, Tool/MCP usage tracking, sidebar panels, and a compact footer",
6
6
  "keywords": [
@@ -37,8 +37,8 @@
37
37
  }
38
38
  },
39
39
  "devDependencies": {
40
- "@earendil-works/pi-coding-agent": "1.0.2",
41
- "@earendil-works/pi-tui": "1.0.2",
40
+ "@earendil-works/pi-coding-agent": "1.1.0",
41
+ "@earendil-works/pi-tui": "1.1.0",
42
42
  "@types/node": "^26.6.4",
43
43
  "tsx": "^4.20.6",
44
44
  "typescript": "^7.0.2"