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 +22 -9
- package/README.zh-CN.md +22 -9
- package/extensions/index.ts +75 -9
- package/extensions/mcp/integration.test.ts +8 -0
- package/extensions/mcp/resources.ts +7 -45
- package/extensions/mcp/status-center.test.ts +147 -0
- package/extensions/mcp/status-center.ts +178 -0
- package/extensions/tool-view/usage.test.ts +23 -0
- package/extensions/tool-view/usage.ts +13 -0
- package/package.json +3 -3
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
|
|
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.
|
|
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.
|
|
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.
|
|
204
|
+
pi install git:github.com/1993yihuan/pi-ui-extension@v1.1.10
|
|
194
205
|
```
|
|
195
206
|
|
|
196
|
-
### Temporary
|
|
207
|
+
### Temporary local-source testing
|
|
197
208
|
|
|
198
|
-
To
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
204
|
+
pi install git:github.com/1993yihuan/pi-ui-extension@v1.1.10
|
|
194
205
|
```
|
|
195
206
|
|
|
196
|
-
###
|
|
207
|
+
### 临时加载本地源码
|
|
197
208
|
|
|
198
|
-
如果只想在一次 Pi
|
|
209
|
+
如果只想在一次 Pi 启动中测试本地源码,不修改 `settings.json`,请在仓库根目录运行。禁用自动扩展发现可以避免与已经安装的 `pi-ui-extension` 重复加载:
|
|
199
210
|
|
|
200
211
|
```bash
|
|
201
|
-
pi -e
|
|
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
|
-
-
|
|
398
|
+
- CI 覆盖 Node.js 22.19.0 和 24;发布工作流使用 Node.js 24
|
|
386
399
|
|
|
387
400
|
## License
|
|
388
401
|
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
mcpResources =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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
|
|
41
|
-
"@earendil-works/pi-tui": "1.0
|
|
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"
|