@pi-unipi/mcp 2.4.1 → 2.6.0
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 +13 -0
- package/package.json +5 -2
- package/src/bridge/registry.ts +244 -137
- package/src/bridge/translator.ts +96 -21
- package/src/index.ts +43 -67
- package/src/tui/settings-overlay.ts +3 -9
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ The add command opens a split-pane overlay: server browser on the left, JSON con
|
|
|
12
12
|
| `/unipi:mcp-settings` | Interactive settings with enable/disable/edit |
|
|
13
13
|
| `/unipi:mcp-sync` | Force sync server catalog from GitHub |
|
|
14
14
|
| `/unipi:mcp-status` | Text summary of all configured servers |
|
|
15
|
+
| `/unipi:mcp-reload` | Remind you to restart Pi so tool schemas reload as a clean cache epoch |
|
|
15
16
|
|
|
16
17
|
### Setup Flow
|
|
17
18
|
|
|
@@ -30,6 +31,18 @@ MCP registers with the info-screen dashboard, showing server count, active serve
|
|
|
30
31
|
|
|
31
32
|
MCP tools are registered dynamically based on configured servers. Once a server is added and Pi restarts, its tools become available to the agent.
|
|
32
33
|
|
|
34
|
+
### Deterministic Definitions and Cache Behavior
|
|
35
|
+
|
|
36
|
+
At session startup, enabled servers connect and discover tools in parallel. Registration waits for all discoveries to settle, then registers the successful combined tool set in canonical `{serverName}__{toolName}` order. Duplicate final names are rejected explicitly instead of allowing one definition to overwrite another.
|
|
37
|
+
|
|
38
|
+
MCP input properties are cloned and recursively canonicalized before registration: schema object keys use locale-independent UTF-16 code-unit order, valid schema `required` string arrays are sorted and deduplicated, a missing top-level `required` becomes `[]`, and literal-value arrays keep their source order. Each tool also receives a stable label matching its final Pi name. These stable definitions and registration order prevent equivalent MCP configurations from changing the serialized tool list between runs, improving provider prompt-cache reuse. A server that fails discovery is excluded from the combined set; a registration error fails startup for that prepared set and is not reported as successful.
|
|
39
|
+
|
|
40
|
+
Pi 0.80 cannot remove dynamically registered tools. Enabling, disabling, deleting, or changing MCP servers is therefore applied on the next Pi restart rather than mutating the tool list mid-session. This prevents stale schemas and makes the restart an explicit cache-epoch boundary.
|
|
41
|
+
|
|
42
|
+
### Bounded Results
|
|
43
|
+
|
|
44
|
+
MCP text results are model-visible up to a hard 64 KiB ceiling. A larger result keeps a bounded head/tail preview and, when the raw result is at most 16 MiB, writes the complete text to a private mode-0600 artifact under `~/.unipi/tool-results/`. Existing result directories are tightened to mode 0700. The returned result includes the path and directs the agent to use `read` with offset/limit. Results above the raw safety cap or filesystem write failures still return a bounded preview with an explicit non-retention warning. MCP image bytes are not written by this text bridge; image blocks remain represented by MIME metadata as before.
|
|
45
|
+
|
|
33
46
|
Example tool calls:
|
|
34
47
|
```
|
|
35
48
|
github__search_code({ query: "authentication middleware" })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
"skills/**/*",
|
|
27
27
|
"README.md"
|
|
28
28
|
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "npx tsx --test tests/**/*.test.ts"
|
|
31
|
+
},
|
|
29
32
|
"dependencies": {
|
|
30
|
-
"@pi-unipi/core": "2.
|
|
33
|
+
"@pi-unipi/core": "2.6.0"
|
|
31
34
|
},
|
|
32
35
|
"peerDependencies": {
|
|
33
36
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
package/src/bridge/registry.ts
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
* @pi-unipi/mcp — Server registry
|
|
3
3
|
*
|
|
4
4
|
* Manages MCP server lifecycle: start, stop, restart, status tracking.
|
|
5
|
-
* Coordinates McpClient instances and tool registration with pi.
|
|
5
|
+
* Coordinates McpClient instances and deterministic tool registration with pi.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { UNIPI_EVENTS, MCP_DEFAULTS } from "@pi-unipi/core";
|
|
9
9
|
import type {
|
|
10
10
|
ResolvedServer,
|
|
11
11
|
ServerState,
|
|
12
|
-
ServerStatus,
|
|
13
|
-
McpTool,
|
|
14
12
|
McpRegistryEntry,
|
|
15
13
|
} from "../types.js";
|
|
16
14
|
import { McpClient } from "./client.js";
|
|
17
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
compareCodeUnits,
|
|
17
|
+
translateMcpTool,
|
|
18
|
+
type PiExternalTool,
|
|
19
|
+
} from "./translator.js";
|
|
18
20
|
|
|
19
21
|
/** Callback for emitting events */
|
|
20
22
|
export type EventEmitFn = (
|
|
@@ -28,6 +30,12 @@ export type RegisterToolFn = (tool: PiExternalTool) => void;
|
|
|
28
30
|
/** Callback for unregistering a tool with pi */
|
|
29
31
|
export type UnregisterToolFn = (toolName: string) => void;
|
|
30
32
|
|
|
33
|
+
/** Minimal client surface used by the registry. */
|
|
34
|
+
export type RegistryClient = Pick<
|
|
35
|
+
McpClient,
|
|
36
|
+
"connect" | "disconnect" | "listTools" | "callTool" | "pid"
|
|
37
|
+
>;
|
|
38
|
+
|
|
31
39
|
/** Options for ServerRegistry */
|
|
32
40
|
export interface ServerRegistryOptions {
|
|
33
41
|
/** Function to emit events via pi.events */
|
|
@@ -36,156 +44,273 @@ export interface ServerRegistryOptions {
|
|
|
36
44
|
registerTool: RegisterToolFn;
|
|
37
45
|
/** Function to unregister a tool from pi */
|
|
38
46
|
unregisterTool: UnregisterToolFn;
|
|
47
|
+
/** Whether the host can actually remove dynamically registered tools. */
|
|
48
|
+
canUnregisterTools?: boolean;
|
|
39
49
|
/** Per-server startup timeout in ms */
|
|
40
50
|
timeoutMs?: number;
|
|
51
|
+
/** Client factory, primarily for tests. */
|
|
52
|
+
createClient?: () => RegistryClient;
|
|
41
53
|
}
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
interface PreparedServer {
|
|
56
|
+
entry: McpRegistryEntry;
|
|
57
|
+
client: RegistryClient;
|
|
58
|
+
tools: PiExternalTool[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Server registry — tracks all MCP server connections and their tools. */
|
|
46
62
|
export class ServerRegistry {
|
|
47
63
|
private entries = new Map<string, McpRegistryEntry>();
|
|
48
64
|
private readonly emitEvent: EventEmitFn;
|
|
49
65
|
private readonly registerTool: RegisterToolFn;
|
|
50
66
|
private readonly unregisterTool: UnregisterToolFn;
|
|
67
|
+
private readonly canUnregisterTools: boolean;
|
|
51
68
|
private readonly timeoutMs: number;
|
|
69
|
+
private readonly createClient: () => RegistryClient;
|
|
52
70
|
|
|
53
71
|
constructor(options: ServerRegistryOptions) {
|
|
54
72
|
this.emitEvent = options.emitEvent;
|
|
55
73
|
this.registerTool = options.registerTool;
|
|
56
74
|
this.unregisterTool = options.unregisterTool;
|
|
75
|
+
this.canUnregisterTools = options.canUnregisterTools ?? true;
|
|
57
76
|
this.timeoutMs = options.timeoutMs ?? MCP_DEFAULTS.STARTUP_TIMEOUT_MS;
|
|
77
|
+
this.createClient = options.createClient ?? (() => new McpClient({ timeoutMs: this.timeoutMs }));
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
/**
|
|
61
|
-
* Start
|
|
81
|
+
* Start one MCP server. Discovery completes before its tools are registered,
|
|
82
|
+
* and registration always follows final Pi tool-name order.
|
|
62
83
|
*/
|
|
63
84
|
async startServer(resolved: ResolvedServer): Promise<void> {
|
|
64
|
-
|
|
85
|
+
await this.startServers([resolved]);
|
|
86
|
+
const state = this.getServerState(resolved.name);
|
|
87
|
+
if (state?.status === "error") {
|
|
88
|
+
throw new Error(state.error ?? `MCP server "${resolved.name}" failed to start`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
65
91
|
|
|
66
|
-
|
|
67
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Start MCP servers behind a discovery barrier.
|
|
94
|
+
*
|
|
95
|
+
* Connections and tool discovery run in parallel. Once every server has
|
|
96
|
+
* either prepared or failed, tools from all successful servers are checked
|
|
97
|
+
* for duplicate final names, sorted, and only then registered with Pi.
|
|
98
|
+
* Individual connection failures remain represented by error registry
|
|
99
|
+
* entries and do not prevent tools from other servers being registered.
|
|
100
|
+
*/
|
|
101
|
+
async startServers(resolvedServers: ResolvedServer[]): Promise<void> {
|
|
102
|
+
if (resolvedServers.length === 0) return;
|
|
103
|
+
|
|
104
|
+
const names = new Set<string>();
|
|
105
|
+
for (const { name } of resolvedServers) {
|
|
106
|
+
if (names.has(name)) {
|
|
107
|
+
throw new Error(`Duplicate MCP server name in startup batch: "${name}"`);
|
|
108
|
+
}
|
|
109
|
+
names.add(name);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const replacementCount = [...names].filter((name) => this.entries.has(name)).length;
|
|
113
|
+
if (this.entries.size - replacementCount + resolvedServers.length > MCP_DEFAULTS.MAX_SERVERS) {
|
|
68
114
|
throw new Error(
|
|
69
115
|
`Maximum number of MCP servers (${MCP_DEFAULTS.MAX_SERVERS}) reached. ` +
|
|
70
116
|
`Stop a server before starting a new one.`,
|
|
71
117
|
);
|
|
72
118
|
}
|
|
73
119
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
120
|
+
// Existing instances must release their names before replacements prepare.
|
|
121
|
+
await Promise.all(
|
|
122
|
+
[...names]
|
|
123
|
+
.filter((name) => this.entries.has(name))
|
|
124
|
+
.map((name) => this.stopServer(name)),
|
|
125
|
+
);
|
|
78
126
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
127
|
+
const settled = await Promise.allSettled(
|
|
128
|
+
resolvedServers.map((resolved) => this.prepareServer(resolved)),
|
|
129
|
+
);
|
|
130
|
+
const prepared = settled
|
|
131
|
+
.filter((result): result is PromiseFulfilledResult<PreparedServer> => result.status === "fulfilled")
|
|
132
|
+
.map((result) => result.value);
|
|
85
133
|
|
|
86
|
-
|
|
87
|
-
name,
|
|
88
|
-
resolved,
|
|
89
|
-
state,
|
|
90
|
-
client: null,
|
|
91
|
-
toolNames: [],
|
|
92
|
-
};
|
|
134
|
+
if (prepared.length === 0) return;
|
|
93
135
|
|
|
94
|
-
|
|
136
|
+
const registrations = prepared
|
|
137
|
+
.flatMap(({ entry, tools }) => tools.map((tool) => ({ entry, tool })))
|
|
138
|
+
.sort((left, right) => compareCodeUnits(left.tool.name, right.tool.name));
|
|
95
139
|
|
|
96
140
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
141
|
+
this.assertUniqueFinalToolNames(registrations.map(({ tool }) => tool.name));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
await this.failPreparedServers(prepared, error);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const registeredNames: string[] = [];
|
|
148
|
+
try {
|
|
149
|
+
for (const { tool } of registrations) {
|
|
150
|
+
this.registerTool(tool);
|
|
151
|
+
registeredNames.push(tool.name);
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (this.canUnregisterTools) {
|
|
155
|
+
for (const toolName of registeredNames.reverse()) {
|
|
156
|
+
try {
|
|
157
|
+
this.unregisterTool(toolName);
|
|
158
|
+
} catch {
|
|
159
|
+
// Preserve the original registration error.
|
|
108
160
|
}
|
|
109
|
-
}
|
|
110
|
-
|
|
161
|
+
}
|
|
162
|
+
await this.failPreparedServers(prepared, error);
|
|
163
|
+
} else {
|
|
164
|
+
// Pi 0.80 cannot roll back dynamic registration. Keep the successfully
|
|
165
|
+
// registered subset and its clients alive, and report the partial set
|
|
166
|
+
// truthfully instead of pretending it was removed.
|
|
167
|
+
const message = `${error instanceof Error ? error.message : String(error)}; ` +
|
|
168
|
+
"some MCP tools remain registered until Pi restarts";
|
|
169
|
+
for (const { entry, client, tools } of prepared) {
|
|
170
|
+
const toolNames = tools
|
|
171
|
+
.map((tool) => tool.name)
|
|
172
|
+
.filter((name) => registeredNames.includes(name))
|
|
173
|
+
.sort(compareCodeUnits);
|
|
174
|
+
entry.toolNames = toolNames;
|
|
175
|
+
entry.state = {
|
|
176
|
+
...entry.state,
|
|
177
|
+
status: "error",
|
|
178
|
+
pid: client.pid,
|
|
179
|
+
toolCount: toolNames.length,
|
|
180
|
+
error: message,
|
|
181
|
+
};
|
|
182
|
+
this.emitEvent(UNIPI_EVENTS.MCP_SERVER_ERROR, {
|
|
183
|
+
name: entry.name,
|
|
184
|
+
error: message,
|
|
185
|
+
});
|
|
111
186
|
}
|
|
112
187
|
}
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
113
190
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// Discover tools
|
|
121
|
-
const mcpTools = await client.listTools();
|
|
122
|
-
|
|
123
|
-
// Translate and register tools
|
|
124
|
-
const toolNames: string[] = [];
|
|
125
|
-
for (const mcpTool of mcpTools) {
|
|
126
|
-
const piTool = translateMcpTool(mcpTool, name, client);
|
|
127
|
-
this.registerTool(piTool);
|
|
128
|
-
toolNames.push(piTool.name);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Update state
|
|
191
|
+
// State and success events are published only after every registration
|
|
192
|
+
// succeeds. Server event order is deterministic as well.
|
|
193
|
+
for (const { entry, client, tools } of [...prepared].sort((left, right) =>
|
|
194
|
+
compareCodeUnits(left.entry.name, right.entry.name))) {
|
|
195
|
+
const toolNames = tools.map((tool) => tool.name).sort(compareCodeUnits);
|
|
196
|
+
entry.toolNames = toolNames;
|
|
132
197
|
entry.state = {
|
|
133
|
-
...state,
|
|
198
|
+
...entry.state,
|
|
134
199
|
status: "running",
|
|
135
200
|
pid: client.pid,
|
|
136
201
|
toolCount: toolNames.length,
|
|
137
202
|
};
|
|
138
|
-
entry.toolNames = toolNames;
|
|
139
203
|
|
|
140
|
-
// Emit events
|
|
141
204
|
this.emitEvent(UNIPI_EVENTS.MCP_SERVER_STARTED, {
|
|
142
|
-
name,
|
|
205
|
+
name: entry.name,
|
|
143
206
|
toolCount: toolNames.length,
|
|
144
207
|
});
|
|
145
|
-
|
|
146
208
|
if (toolNames.length > 0) {
|
|
147
209
|
this.emitEvent(UNIPI_EVENTS.MCP_TOOLS_REGISTERED, {
|
|
148
|
-
serverName: name,
|
|
210
|
+
serverName: entry.name,
|
|
149
211
|
toolNames,
|
|
150
212
|
});
|
|
151
213
|
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
err instanceof Error ? err.message : String(err);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
155
216
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
217
|
+
private async prepareServer(resolved: ResolvedServer): Promise<PreparedServer> {
|
|
218
|
+
const { name, def } = resolved;
|
|
219
|
+
const state: ServerState = {
|
|
220
|
+
name,
|
|
221
|
+
status: "starting",
|
|
222
|
+
toolCount: 0,
|
|
223
|
+
startedAt: new Date().toISOString(),
|
|
224
|
+
};
|
|
225
|
+
const entry: McpRegistryEntry = {
|
|
226
|
+
name,
|
|
227
|
+
resolved,
|
|
228
|
+
state,
|
|
229
|
+
client: null,
|
|
230
|
+
toolNames: [],
|
|
231
|
+
};
|
|
232
|
+
this.entries.set(name, entry);
|
|
233
|
+
|
|
234
|
+
let client: RegistryClient | null = null;
|
|
235
|
+
try {
|
|
236
|
+
const safeCommand = typeof def.command === "string" ? def.command : String(def.command);
|
|
237
|
+
const safeArgs = Array.isArray(def.args) ? def.args : [];
|
|
238
|
+
let safeEnv: Record<string, string> | undefined;
|
|
239
|
+
if (def.env !== undefined && def.env !== null && typeof def.env === "object" && !Array.isArray(def.env)) {
|
|
240
|
+
safeEnv = {};
|
|
241
|
+
for (const [key, value] of Object.entries(def.env)) {
|
|
242
|
+
safeEnv[key] = typeof value === "string" ? value : String(value);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
161
245
|
|
|
162
|
-
|
|
163
|
-
|
|
246
|
+
client = this.createClient();
|
|
247
|
+
const connectedClient = client;
|
|
248
|
+
await connectedClient.connect(safeCommand, safeArgs, safeEnv);
|
|
249
|
+
entry.client = connectedClient;
|
|
250
|
+
|
|
251
|
+
const mcpTools = await connectedClient.listTools();
|
|
252
|
+
const tools = mcpTools
|
|
253
|
+
.map((tool) => translateMcpTool(tool, name, connectedClient))
|
|
254
|
+
.sort((left, right) => compareCodeUnits(left.name, right.name));
|
|
255
|
+
return { entry, client: connectedClient, tools };
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
258
|
+
entry.state = { ...state, status: "error", error: message };
|
|
259
|
+
if (client) {
|
|
164
260
|
try {
|
|
165
|
-
await
|
|
261
|
+
await client.disconnect();
|
|
166
262
|
} catch {
|
|
167
|
-
// Ignore cleanup errors
|
|
263
|
+
// Ignore cleanup errors.
|
|
168
264
|
}
|
|
169
|
-
entry.client = null;
|
|
170
265
|
}
|
|
266
|
+
entry.client = null;
|
|
267
|
+
this.emitEvent(UNIPI_EVENTS.MCP_SERVER_ERROR, { name, error: message });
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
171
271
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
272
|
+
private assertUniqueFinalToolNames(newNames: string[]): void {
|
|
273
|
+
const existingNames = this.getActive().flatMap((state) =>
|
|
274
|
+
this.entries.get(state.name)?.toolNames ?? []);
|
|
275
|
+
const seen = new Set<string>();
|
|
276
|
+
const duplicates = new Set<string>();
|
|
277
|
+
for (const name of [...existingNames, ...newNames]) {
|
|
278
|
+
if (seen.has(name)) duplicates.add(name);
|
|
279
|
+
seen.add(name);
|
|
280
|
+
}
|
|
281
|
+
if (duplicates.size > 0) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Duplicate final MCP tool name(s): ${[...duplicates].sort(compareCodeUnits).join(", ")}`,
|
|
284
|
+
);
|
|
178
285
|
}
|
|
179
286
|
}
|
|
180
287
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
288
|
+
private async failPreparedServers(prepared: PreparedServer[], error: unknown): Promise<void> {
|
|
289
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
290
|
+
await Promise.all(prepared.map(async ({ entry, client }) => {
|
|
291
|
+
entry.state = { ...entry.state, status: "error", toolCount: 0, error: message };
|
|
292
|
+
entry.toolNames = [];
|
|
293
|
+
try {
|
|
294
|
+
await client.disconnect();
|
|
295
|
+
} catch {
|
|
296
|
+
// Ignore cleanup errors.
|
|
297
|
+
}
|
|
298
|
+
entry.client = null;
|
|
299
|
+
this.emitEvent(UNIPI_EVENTS.MCP_SERVER_ERROR, { name: entry.name, error: message });
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Stop an MCP server: unregister tools, disconnect client. */
|
|
184
304
|
async stopServer(name: string): Promise<void> {
|
|
185
305
|
const entry = this.entries.get(name);
|
|
186
306
|
if (!entry) return;
|
|
187
307
|
|
|
188
|
-
|
|
308
|
+
if (entry.toolNames.length > 0 && !this.canUnregisterTools) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
"This Pi version cannot remove MCP tools at runtime; restart Pi to change the MCP tool set.",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
189
314
|
for (const toolName of entry.toolNames) {
|
|
190
315
|
this.unregisterTool(toolName);
|
|
191
316
|
}
|
|
@@ -197,101 +322,83 @@ export class ServerRegistry {
|
|
|
197
322
|
});
|
|
198
323
|
}
|
|
199
324
|
|
|
200
|
-
// Disconnect client
|
|
201
325
|
if (entry.client) {
|
|
202
326
|
try {
|
|
203
|
-
await (entry.client as
|
|
327
|
+
await (entry.client as RegistryClient).disconnect();
|
|
204
328
|
} catch {
|
|
205
|
-
// Ignore disconnect errors
|
|
329
|
+
// Ignore disconnect errors.
|
|
206
330
|
}
|
|
207
331
|
entry.client = null;
|
|
208
332
|
}
|
|
209
333
|
|
|
210
|
-
|
|
211
|
-
entry.state = {
|
|
212
|
-
...entry.state,
|
|
213
|
-
status: "stopped",
|
|
214
|
-
toolCount: 0,
|
|
215
|
-
};
|
|
334
|
+
entry.state = { ...entry.state, status: "stopped", toolCount: 0 };
|
|
216
335
|
entry.toolNames = [];
|
|
217
|
-
|
|
218
336
|
this.emitEvent(UNIPI_EVENTS.MCP_SERVER_STOPPED, { name });
|
|
219
337
|
}
|
|
220
338
|
|
|
221
|
-
/**
|
|
222
|
-
* Restart an MCP server: stop then start.
|
|
223
|
-
*/
|
|
339
|
+
/** Restart an MCP server: stop then start. */
|
|
224
340
|
async restartServer(name: string): Promise<void> {
|
|
225
341
|
const entry = this.entries.get(name);
|
|
226
|
-
if (!entry) {
|
|
227
|
-
throw new Error(`Server '${name}' not found in registry`);
|
|
228
|
-
}
|
|
229
|
-
|
|
342
|
+
if (!entry) throw new Error(`Server '${name}' not found in registry`);
|
|
230
343
|
const resolved = entry.resolved;
|
|
231
344
|
await this.stopServer(name);
|
|
232
345
|
await this.startServer(resolved);
|
|
233
346
|
}
|
|
234
347
|
|
|
235
|
-
/**
|
|
236
|
-
* Stop all running servers.
|
|
237
|
-
*/
|
|
348
|
+
/** Stop all running servers when runtime unregistration is supported. */
|
|
238
349
|
async stopAll(): Promise<void> {
|
|
239
350
|
const names = Array.from(this.entries.keys());
|
|
240
351
|
await Promise.allSettled(names.map((name) => this.stopServer(name)));
|
|
241
352
|
}
|
|
242
353
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
354
|
+
/** Disconnect clients during extension shutdown without claiming tools were removed. */
|
|
355
|
+
async disconnectAll(): Promise<void> {
|
|
356
|
+
await Promise.allSettled([...this.entries.values()].map(async (entry) => {
|
|
357
|
+
if (!entry.client) return;
|
|
358
|
+
try {
|
|
359
|
+
await (entry.client as RegistryClient).disconnect();
|
|
360
|
+
} finally {
|
|
361
|
+
entry.client = null;
|
|
362
|
+
}
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Get all registered server states. */
|
|
246
367
|
getAll(): ServerState[] {
|
|
247
|
-
return Array.from(this.entries.values()).map((
|
|
368
|
+
return Array.from(this.entries.values()).map((entry) => entry.state);
|
|
248
369
|
}
|
|
249
370
|
|
|
250
|
-
/**
|
|
251
|
-
* Get states of running servers.
|
|
252
|
-
*/
|
|
371
|
+
/** Get states of running servers. */
|
|
253
372
|
getActive(): ServerState[] {
|
|
254
|
-
return this.getAll().filter((
|
|
373
|
+
return this.getAll().filter((state) => state.status === "running");
|
|
255
374
|
}
|
|
256
375
|
|
|
257
|
-
/**
|
|
258
|
-
* Get states of servers in error state.
|
|
259
|
-
*/
|
|
376
|
+
/** Get states of servers in error state. */
|
|
260
377
|
getFailed(): ServerState[] {
|
|
261
|
-
return this.getAll().filter((
|
|
378
|
+
return this.getAll().filter((state) => state.status === "error");
|
|
262
379
|
}
|
|
263
380
|
|
|
264
|
-
/**
|
|
265
|
-
* Get total number of tools across all active servers.
|
|
266
|
-
*/
|
|
381
|
+
/** Get total number of tools across all active servers. */
|
|
267
382
|
getTotalToolCount(): number {
|
|
268
|
-
return this.getActive().reduce((sum,
|
|
383
|
+
return this.getActive().reduce((sum, state) => sum + state.toolCount, 0);
|
|
269
384
|
}
|
|
270
385
|
|
|
271
|
-
/**
|
|
272
|
-
* Get the state of a specific server.
|
|
273
|
-
*/
|
|
386
|
+
/** Get the state of a specific server. */
|
|
274
387
|
getServerState(name: string): ServerState | null {
|
|
275
388
|
return this.entries.get(name)?.state ?? null;
|
|
276
389
|
}
|
|
277
390
|
|
|
278
|
-
/**
|
|
279
|
-
* Get the full registry entry for a server.
|
|
280
|
-
*/
|
|
391
|
+
/** Get the full registry entry for a server. */
|
|
281
392
|
getEntry(name: string): McpRegistryEntry | null {
|
|
282
393
|
return this.entries.get(name) ?? null;
|
|
283
394
|
}
|
|
284
395
|
|
|
285
|
-
/**
|
|
286
|
-
* Check if a server exists in the registry.
|
|
287
|
-
*/
|
|
396
|
+
/** Check if a server exists in the registry. */
|
|
288
397
|
hasServer(name: string): boolean {
|
|
289
398
|
return this.entries.has(name);
|
|
290
399
|
}
|
|
291
400
|
|
|
292
|
-
/**
|
|
293
|
-
* Get the number of registered servers.
|
|
294
|
-
*/
|
|
401
|
+
/** Get the number of registered servers. */
|
|
295
402
|
get size(): number {
|
|
296
403
|
return this.entries.size;
|
|
297
404
|
}
|
package/src/bridge/translator.ts
CHANGED
|
@@ -5,15 +5,19 @@
|
|
|
5
5
|
* Naming convention: {serverName}__{toolName}
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { MCP_DEFAULTS } from "@pi-unipi/core";
|
|
8
|
+
import { MCP_DEFAULTS, boundModelOutput } from "@pi-unipi/core";
|
|
9
9
|
import type { McpTool, McpToolResult } from "../types.js";
|
|
10
10
|
import type { McpClient } from "./client.js";
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/** Client operation needed by translated tools. */
|
|
13
|
+
export type ToolCallClient = Pick<McpClient, "callTool">;
|
|
14
|
+
|
|
15
|
+
/** JSON object used as a Pi-compatible tool parameter schema. */
|
|
13
16
|
interface ToolParameters {
|
|
17
|
+
[key: string]: unknown;
|
|
14
18
|
type: "object";
|
|
15
19
|
properties: Record<string, unknown>;
|
|
16
|
-
required
|
|
20
|
+
required: unknown;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/** Content block returned by a pi tool */
|
|
@@ -31,6 +35,7 @@ interface PiToolResult {
|
|
|
31
35
|
/** Pi-compatible external tool */
|
|
32
36
|
export interface PiExternalTool {
|
|
33
37
|
name: string;
|
|
38
|
+
label: string;
|
|
34
39
|
description: string;
|
|
35
40
|
parameters: ToolParameters;
|
|
36
41
|
execute: (
|
|
@@ -41,6 +46,66 @@ export interface PiExternalTool {
|
|
|
41
46
|
) => Promise<PiToolResult>;
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Compare strings by JavaScript/Unicode UTF-16 code units.
|
|
51
|
+
*
|
|
52
|
+
* Unlike localeCompare(), this ordering does not depend on the host locale.
|
|
53
|
+
*/
|
|
54
|
+
export function compareCodeUnits(left: string, right: string): number {
|
|
55
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const LITERAL_VALUE_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
|
|
63
|
+
|
|
64
|
+
function canonicalizeValue(
|
|
65
|
+
value: unknown,
|
|
66
|
+
key?: string,
|
|
67
|
+
normalizeSchemaKeywords = true,
|
|
68
|
+
): unknown {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
if (
|
|
71
|
+
normalizeSchemaKeywords &&
|
|
72
|
+
key === "required" &&
|
|
73
|
+
value.every((item) => typeof item === "string")
|
|
74
|
+
) {
|
|
75
|
+
return [...new Set(value as string[])].sort(compareCodeUnits);
|
|
76
|
+
}
|
|
77
|
+
return value.map((item) => canonicalizeValue(item, undefined, normalizeSchemaKeywords));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!isObject(value)) return value;
|
|
81
|
+
|
|
82
|
+
const canonical: Record<string, unknown> = {};
|
|
83
|
+
for (const objectKey of Object.keys(value).sort(compareCodeUnits)) {
|
|
84
|
+
// Values under these JSON Schema keywords are literal application data,
|
|
85
|
+
// not nested schemas. A property named `required` inside that data must
|
|
86
|
+
// retain array order (for example under `const`).
|
|
87
|
+
const childNormalizesSchemaKeywords =
|
|
88
|
+
normalizeSchemaKeywords && !LITERAL_VALUE_KEYWORDS.has(objectKey);
|
|
89
|
+
canonical[objectKey] = canonicalizeValue(
|
|
90
|
+
value[objectKey],
|
|
91
|
+
objectKey,
|
|
92
|
+
childNormalizesSchemaKeywords,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return canonical;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Recursively clone and canonicalize a JSON Schema value.
|
|
100
|
+
*
|
|
101
|
+
* Object keys use locale-independent code-unit order. Arrays retain their
|
|
102
|
+
* original order, except valid `required` arrays (arrays containing only
|
|
103
|
+
* strings), which are sorted and deduplicated.
|
|
104
|
+
*/
|
|
105
|
+
export function canonicalizeJsonSchema(schema: unknown): unknown {
|
|
106
|
+
return canonicalizeValue(schema);
|
|
107
|
+
}
|
|
108
|
+
|
|
44
109
|
/**
|
|
45
110
|
* Translate an MCP tool definition to a pi-compatible external tool.
|
|
46
111
|
*
|
|
@@ -52,19 +117,21 @@ export interface PiExternalTool {
|
|
|
52
117
|
export function translateMcpTool(
|
|
53
118
|
mcpTool: McpTool,
|
|
54
119
|
serverName: string,
|
|
55
|
-
client:
|
|
120
|
+
client: ToolCallClient,
|
|
56
121
|
): PiExternalTool {
|
|
57
122
|
const separator = MCP_DEFAULTS.TOOL_NAME_SEPARATOR;
|
|
58
123
|
const toolName = `${serverName}${separator}${mcpTool.name}`;
|
|
59
124
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
125
|
+
// Preserve the existing Pi-facing top-level shape while cloning and
|
|
126
|
+
// canonicalizing all nested property schemas. Forwarding additional MCP
|
|
127
|
+
// top-level keywords is a separate provider-compatibility decision.
|
|
128
|
+
const inputSchema = isObject(mcpTool.inputSchema) ? mcpTool.inputSchema : {};
|
|
129
|
+
const normalizedSchema: Record<string, unknown> = {
|
|
63
130
|
type: "object",
|
|
64
|
-
properties:
|
|
65
|
-
|
|
66
|
-
required: inputSchema.required as string[] | undefined,
|
|
131
|
+
properties: isObject(inputSchema.properties) ? inputSchema.properties : {},
|
|
132
|
+
required: Array.isArray(inputSchema.required) ? inputSchema.required : [],
|
|
67
133
|
};
|
|
134
|
+
const parameters = canonicalizeJsonSchema(normalizedSchema) as ToolParameters;
|
|
68
135
|
|
|
69
136
|
const description = [
|
|
70
137
|
mcpTool.description || `MCP tool: ${mcpTool.name}`,
|
|
@@ -103,21 +170,28 @@ export function translateMcpTool(
|
|
|
103
170
|
}
|
|
104
171
|
}
|
|
105
172
|
|
|
106
|
-
if (result.isError) {
|
|
107
|
-
const errorText = blocks.map((b) => b.text).join("\n") || "Unknown error";
|
|
108
|
-
return {
|
|
109
|
-
content: [{ type: "text", text: `MCP tool error from ${serverName}: ${errorText}` }],
|
|
110
|
-
details: { error: true, server: serverName, tool: mcpTool.name },
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
173
|
if (blocks.length === 0) {
|
|
115
|
-
blocks.push({ type: "text", text: "(no output)" });
|
|
174
|
+
blocks.push({ type: "text", text: result.isError ? "Unknown error" : "(no output)" });
|
|
116
175
|
}
|
|
117
176
|
|
|
177
|
+
const rawText = blocks.map((block) => block.text).join("\n");
|
|
178
|
+
const wrapper = result.isError ? `MCP tool error from ${serverName}: ` : "";
|
|
179
|
+
const output = boundModelOutput(rawText, {
|
|
180
|
+
maxBytes: Math.max(1024, MCP_DEFAULTS.MAX_MODEL_OUTPUT_BYTES - Buffer.byteLength(wrapper, "utf8")),
|
|
181
|
+
artifactPrefix: `mcp-${serverName}-${mcpTool.name}`,
|
|
182
|
+
});
|
|
183
|
+
const visibleText = `${wrapper}${output.text}`;
|
|
184
|
+
|
|
118
185
|
return {
|
|
119
|
-
content:
|
|
120
|
-
details: {
|
|
186
|
+
content: [{ type: "text", text: visibleText }],
|
|
187
|
+
details: {
|
|
188
|
+
error: result.isError || undefined,
|
|
189
|
+
server: serverName,
|
|
190
|
+
tool: mcpTool.name,
|
|
191
|
+
truncated: output.truncated,
|
|
192
|
+
originalBytes: output.originalBytes,
|
|
193
|
+
artifactPath: output.artifactPath,
|
|
194
|
+
},
|
|
121
195
|
};
|
|
122
196
|
} catch (err) {
|
|
123
197
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -137,6 +211,7 @@ export function translateMcpTool(
|
|
|
137
211
|
|
|
138
212
|
return {
|
|
139
213
|
name: toolName,
|
|
214
|
+
label: toolName,
|
|
140
215
|
description,
|
|
141
216
|
parameters,
|
|
142
217
|
execute,
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { ResolvedServer } from "./types.js";
|
|
|
18
18
|
import { loadAndResolve, getGlobalConfigDir } from "./config/manager.js";
|
|
19
19
|
import { syncCatalog, loadCatalog } from "./config/sync.js";
|
|
20
20
|
import { ServerRegistry } from "./bridge/registry.js";
|
|
21
|
+
import { compareCodeUnits } from "./bridge/translator.js";
|
|
21
22
|
import { renderMcpAddOverlay } from "./tui/add-overlay.js";
|
|
22
23
|
import { renderMcpSettingsOverlay } from "./tui/settings-overlay.js";
|
|
23
24
|
|
|
@@ -42,24 +43,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
42
43
|
// Session start — load configs, start servers
|
|
43
44
|
pi.on("session_start", async (_event, ctx) => {
|
|
44
45
|
// Create registry with pi integration callbacks
|
|
46
|
+
const toolApi = pi as ExtensionAPI & {
|
|
47
|
+
registerExternalTool?: (tool: unknown) => void;
|
|
48
|
+
unregisterTool?: (toolName: string) => void;
|
|
49
|
+
unregisterExternalTool?: (toolName: string) => void;
|
|
50
|
+
};
|
|
51
|
+
const registerTool = typeof toolApi.registerTool === "function"
|
|
52
|
+
? (tool: unknown) => toolApi.registerTool(tool as Parameters<typeof toolApi.registerTool>[0])
|
|
53
|
+
: typeof toolApi.registerExternalTool === "function"
|
|
54
|
+
? (tool: unknown) => toolApi.registerExternalTool!(tool)
|
|
55
|
+
: () => {
|
|
56
|
+
throw new Error("Pi does not expose a supported MCP tool registration API");
|
|
57
|
+
};
|
|
58
|
+
const canUnregisterTools =
|
|
59
|
+
typeof toolApi.unregisterTool === "function" ||
|
|
60
|
+
typeof toolApi.unregisterExternalTool === "function";
|
|
61
|
+
const unregisterTool = typeof toolApi.unregisterTool === "function"
|
|
62
|
+
? (toolName: string) => toolApi.unregisterTool!(toolName)
|
|
63
|
+
: typeof toolApi.unregisterExternalTool === "function"
|
|
64
|
+
? (toolName: string) => toolApi.unregisterExternalTool!(toolName)
|
|
65
|
+
: () => {};
|
|
66
|
+
|
|
45
67
|
registry = new ServerRegistry({
|
|
46
68
|
emitEvent: (event, payload) => emitEvent(pi, event, payload),
|
|
47
|
-
registerTool
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
(pi as any).registerExternalTool?.(tool);
|
|
51
|
-
} catch {
|
|
52
|
-
// Tool registration may not be available in all contexts
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
unregisterTool: (toolName) => {
|
|
56
|
-
try {
|
|
57
|
-
(pi as any).unregisterTool?.(toolName) ??
|
|
58
|
-
(pi as any).unregisterExternalTool?.(toolName);
|
|
59
|
-
} catch {
|
|
60
|
-
// Ignore
|
|
61
|
-
}
|
|
62
|
-
},
|
|
69
|
+
registerTool,
|
|
70
|
+
unregisterTool,
|
|
71
|
+
canUnregisterTools,
|
|
63
72
|
});
|
|
64
73
|
|
|
65
74
|
// Load and resolve server configs
|
|
@@ -73,21 +82,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
73
82
|
// Config load failure — servers will be empty, visible via /unipi:mcp-status.
|
|
74
83
|
}
|
|
75
84
|
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Server status visible via /unipi:mcp-status or info screen.
|
|
84
|
-
} catch (err) {
|
|
85
|
-
// Removed console.error — errors surfaced via info-screen MCP group.
|
|
86
|
-
// Server failure tracked in registry state.
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
await Promise.allSettled(startPromises);
|
|
85
|
+
// Connect/discover in parallel, then register the successful combined set
|
|
86
|
+
// after a barrier so tool order is stable across runs.
|
|
87
|
+
try {
|
|
88
|
+
await registry.startServers(servers.filter((server) => server.enabled));
|
|
89
|
+
} catch (_err) {
|
|
90
|
+
// Errors are tracked in registry state and surfaced by the info screen.
|
|
91
|
+
}
|
|
91
92
|
|
|
92
93
|
// Register info-screen group
|
|
93
94
|
const infoRegistry = getInfoRegistry();
|
|
@@ -153,16 +154,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
153
154
|
`unipi:${MCP_COMMANDS.STATUS}`,
|
|
154
155
|
`unipi:${MCP_COMMANDS.RELOAD}`,
|
|
155
156
|
],
|
|
156
|
-
tools: activeServers
|
|
157
|
-
registry?.getEntry(
|
|
158
|
-
|
|
157
|
+
tools: activeServers
|
|
158
|
+
.flatMap((server) => registry?.getEntry(server.name)?.toolNames ?? [])
|
|
159
|
+
.sort(compareCodeUnits),
|
|
159
160
|
});
|
|
160
161
|
});
|
|
161
162
|
|
|
162
|
-
// Session shutdown
|
|
163
|
+
// Session shutdown tears down clients. Pi tears down this extension's tool
|
|
164
|
+
// registry itself, so do not claim per-tool unregistration here.
|
|
163
165
|
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
164
166
|
if (registry) {
|
|
165
|
-
await registry.
|
|
167
|
+
await registry.disconnectAll();
|
|
166
168
|
registry = null;
|
|
167
169
|
}
|
|
168
170
|
});
|
|
@@ -305,38 +307,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
305
307
|
|
|
306
308
|
// /unipi:mcp-reload — restart all MCP servers
|
|
307
309
|
pi.registerCommand(`unipi:${MCP_COMMANDS.RELOAD}`, {
|
|
308
|
-
description: "
|
|
310
|
+
description: "Explain how to reload MCP servers safely",
|
|
309
311
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const all = reg.getAll();
|
|
317
|
-
if (all.length === 0) {
|
|
318
|
-
ctx.ui.notify("No MCP servers configured. Use /unipi:mcp-add to add one.", "info");
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
ctx.ui.notify(`Reloading ${all.length} MCP server(s)...`, "info");
|
|
323
|
-
|
|
324
|
-
let restarted = 0;
|
|
325
|
-
let failed = 0;
|
|
326
|
-
for (const state of all) {
|
|
327
|
-
try {
|
|
328
|
-
await reg.restartServer(state.name);
|
|
329
|
-
restarted++;
|
|
330
|
-
} catch (_err) {
|
|
331
|
-
failed++;
|
|
332
|
-
// Silently ignore — restart failure tracked in failed count.
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const msg = failed > 0
|
|
337
|
-
? `Reloaded: ${restarted} ok, ${failed} failed`
|
|
338
|
-
: `Reloaded ${restarted} MCP server(s) successfully`;
|
|
339
|
-
ctx.ui.notify(msg, failed > 0 ? "warning" : "info");
|
|
312
|
+
// Pi 0.80 does not expose dynamic tool removal. Restarting in place can
|
|
313
|
+
// leave stale schemas in the provider-visible tool list, so require a
|
|
314
|
+
// process/extension restart to establish a clean cache epoch.
|
|
315
|
+
ctx.ui.notify("Restart Pi to reload MCP servers and tool schemas safely.", "info");
|
|
340
316
|
},
|
|
341
317
|
});
|
|
342
318
|
}
|
|
@@ -141,15 +141,9 @@ export function renderMcpSettingsOverlay(params?: {
|
|
|
141
141
|
};
|
|
142
142
|
saveMetadata(configDir, meta);
|
|
143
143
|
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await registry.stopServer(server.name);
|
|
148
|
-
} catch {
|
|
149
|
-
// Ignore stop errors
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
144
|
+
// Pi 0.80 cannot remove a registered tool definition at runtime.
|
|
145
|
+
// Persist the setting now; the next Pi restart applies the new set as
|
|
146
|
+
// one deterministic cache epoch.
|
|
153
147
|
refreshServers();
|
|
154
148
|
refresh();
|
|
155
149
|
} catch (err) {
|