@eovidiu/pi-extensions 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ovidiu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,379 @@
1
+ # @eovidiu/pi-extensions
2
+
3
+ Personal [Pi](https://pi.dev) extension package.
4
+
5
+ This repository currently contains one extension: **`mcp-bridge`**, an explicit opt-in MCP compatibility bridge for Pi.
6
+
7
+ > Security note: Pi extensions run as local TypeScript with your user permissions. MCP servers started by this extension also run with your user permissions. Review the source and every MCP server command before enabling it.
8
+
9
+ ## Why this exists
10
+
11
+ Pi intentionally does not ship built-in MCP support. The usual Pi approach is to integrate tools directly through skills, CLI wrappers, or extensions.
12
+
13
+ This package is a compatibility bridge for users who already have trusted MCP server configs in other tools and want to reuse them from Pi without giving up Pi's explicit opt-in model.
14
+
15
+ The bridge never starts newly discovered MCP servers automatically. Discovery only records servers as disabled. A server must be explicitly enabled before its process starts or its tools are exposed to Pi.
16
+
17
+ ## Features
18
+
19
+ - Discovers MCP server configs from common Claude Desktop, Claude Code, and Codex locations.
20
+ - Syncs discovered servers into a Pi-owned config file: `~/.pi/mcp.json`.
21
+ - Preserves manually added Pi MCP server entries.
22
+ - Preserves existing `enabled` values across resyncs.
23
+ - Defaults newly discovered servers to `enabled: false`.
24
+ - Starts only explicitly enabled MCP stdio servers.
25
+ - Initializes MCP sessions and calls `tools/list`.
26
+ - Converts a conservative MCP JSON Schema subset into Pi/typebox tool schemas.
27
+ - Registers supported MCP tools as Pi tools.
28
+ - Forwards Pi tool calls to MCP `tools/call`.
29
+ - Stops and deactivates tools on disable, restart, session shutdown, and reload.
30
+ - Supports interactive `/mcp-enable` and `/mcp-disable` selectors in Pi's TUI.
31
+ - Supports project-local `.pi/mcp.json` overrides.
32
+ - Supports `allowServers`, `denyServers`, and `maxOutputChars` hardening settings.
33
+ - Writes detailed redacted diagnostics to `~/.pi/mcp-bridge.log` instead of stdout.
34
+
35
+ ## Installation
36
+
37
+ ### Local development install
38
+
39
+ ```bash
40
+ pi install /Users/fameftimie/work/pi-extensions
41
+ ```
42
+
43
+ After editing the extension, run this inside Pi:
44
+
45
+ ```text
46
+ /reload
47
+ ```
48
+
49
+ ### Temporary one-off load
50
+
51
+ ```bash
52
+ pi -e /Users/fameftimie/work/pi-extensions
53
+ ```
54
+
55
+ Use this for quick testing only. A normal `pi install` is better for ongoing use because the package remains in Pi's resource set.
56
+
57
+ ### npm install, after publishing
58
+
59
+ ```bash
60
+ pi install npm:@eovidiu/pi-extensions
61
+ ```
62
+
63
+ To pin a published version:
64
+
65
+ ```bash
66
+ pi install npm:@eovidiu/pi-extensions@0.1.0
67
+ ```
68
+
69
+ ## Quick start
70
+
71
+ 1. Install the package.
72
+ 2. Start or reload Pi.
73
+ 3. Sync detected MCP configs:
74
+
75
+ ```text
76
+ /mcp-sync
77
+ ```
78
+
79
+ 4. Inspect what was found:
80
+
81
+ ```text
82
+ /mcp-status
83
+ ```
84
+
85
+ 5. Enable trusted servers explicitly.
86
+
87
+ Interactive TUI selector:
88
+
89
+ ```text
90
+ /mcp-enable
91
+ ```
92
+
93
+ Direct enable by name:
94
+
95
+ ```text
96
+ /mcp-enable claude_desktop__filesystem
97
+ ```
98
+
99
+ 6. Ask Pi to use one of the registered MCP-backed tools.
100
+
101
+ 7. Disable servers when you no longer need them.
102
+
103
+ Interactive TUI selector:
104
+
105
+ ```text
106
+ /mcp-disable
107
+ ```
108
+
109
+ Direct disable by name:
110
+
111
+ ```text
112
+ /mcp-disable claude_desktop__filesystem
113
+ ```
114
+
115
+ ## Commands
116
+
117
+ | Command | Behavior |
118
+ |---|---|
119
+ | `/mcp-sync` | Rescan known Claude/Codex MCP config locations and update `~/.pi/mcp.json`. Newly discovered servers remain disabled. Enabled servers are started/registered after sync. |
120
+ | `/mcp-status` | Show tracked servers, enabled/disabled state, connection summary, config paths, and log path. |
121
+ | `/mcp-enable` | In interactive Pi sessions, rescan configs and open a selector for disabled detected servers. Selected servers are enabled, started, and registered. |
122
+ | `/mcp-enable <server>` | Enable one server directly, then start/register enabled MCP tools. |
123
+ | `/mcp-disable` | In interactive Pi sessions, open a selector for currently enabled servers. Selected servers are disabled, stopped, and their tools are deactivated. |
124
+ | `/mcp-disable <server>` | Disable one server directly, stop it, and deactivate its tools. |
125
+ | `/mcp-restart` | Restart all enabled MCP servers. |
126
+ | `/mcp-restart <server>` | Restart one MCP server. |
127
+
128
+ Interactive selectors use Pi's `ctx.ui.select()` API. They work in TUI/RPC-capable interactive contexts. In non-UI print/JSON mode, the extension avoids dialogs and reports direct command usage instead.
129
+
130
+ ## Config files
131
+
132
+ ### Global config
133
+
134
+ The extension owns this file:
135
+
136
+ ```text
137
+ ~/.pi/mcp.json
138
+ ```
139
+
140
+ Example:
141
+
142
+ ```json
143
+ {
144
+ "version": 1,
145
+ "autoStart": false,
146
+ "servers": {
147
+ "claude_desktop__filesystem": {
148
+ "enabled": false,
149
+ "managedBy": "pi-mcp-bridge",
150
+ "source": "claude-desktop",
151
+ "sourceName": "filesystem",
152
+ "command": "npx",
153
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/work"],
154
+ "env": {}
155
+ }
156
+ },
157
+ "maxOutputChars": 20000
158
+ }
159
+ ```
160
+
161
+ Rules:
162
+
163
+ - Managed discovered entries have `managedBy: "pi-mcp-bridge"`.
164
+ - Managed entries may be updated or removed by `/mcp-sync` when source configs change.
165
+ - Manual entries without that `managedBy` value are preserved.
166
+ - Existing managed `enabled` values are preserved during sync.
167
+ - Newly discovered managed entries are always added with `enabled: false`.
168
+
169
+ ### Project override
170
+
171
+ A project can provide an override at:
172
+
173
+ ```text
174
+ .pi/mcp.json
175
+ ```
176
+
177
+ Project entries override global entries with the same server name for the current working directory. Project settings can also provide policy controls:
178
+
179
+ ```json
180
+ {
181
+ "version": 1,
182
+ "autoStart": false,
183
+ "servers": {},
184
+ "allowServers": ["claude_desktop__filesystem", "codex__*"],
185
+ "denyServers": ["*production*"],
186
+ "maxOutputChars": 20000
187
+ }
188
+ ```
189
+
190
+ Use project overrides carefully. Review `.pi/mcp.json` before running Pi in repositories you do not trust.
191
+
192
+ ## Discovery sources
193
+
194
+ The extension probes common locations for MCP server definitions.
195
+
196
+ ### Claude Desktop
197
+
198
+ ```text
199
+ ~/Library/Application Support/Claude/claude_desktop_config.json
200
+ ```
201
+
202
+ ### Claude Code
203
+
204
+ ```text
205
+ ~/.claude.json
206
+ ~/.claude/settings.json
207
+ ~/.config/claude-code/config.json
208
+ ~/.config/claude-code/settings.json
209
+ ```
210
+
211
+ ### Codex
212
+
213
+ ```text
214
+ ~/.codex/config.toml
215
+ ~/.codex/config.json
216
+ ~/.config/codex/config.toml
217
+ ~/.config/codex/config.json
218
+ ```
219
+
220
+ Supported config maps include common `mcpServers`, `mcp_servers`, and `mcpServersConfig` shapes. Unsupported or malformed entries are skipped and logged.
221
+
222
+ ## Implementation details
223
+
224
+ The extension is implemented as a Pi package with this manifest in `package.json`:
225
+
226
+ ```json
227
+ {
228
+ "keywords": ["pi-package", "pi-extension", "mcp"],
229
+ "pi": {
230
+ "extensions": ["./extensions/mcp-bridge"]
231
+ }
232
+ }
233
+ ```
234
+
235
+ Main modules:
236
+
237
+ | File | Purpose |
238
+ |---|---|
239
+ | `extensions/mcp-bridge/index.ts` | Pi extension entry point. Registers lifecycle hooks and slash commands. Starts/stops enabled servers and registers tools. |
240
+ | `config-discovery.ts` | Finds and parses known third-party MCP config files. Normalizes discovered server definitions. |
241
+ | `config-sync.ts` | Reads/writes `~/.pi/mcp.json`, merges discovered servers, preserves manual entries, validates names, and performs atomic writes. |
242
+ | `mcp-client.ts` | Starts MCP stdio server processes, initializes MCP clients, lists tools, forwards calls, and stops processes. |
243
+ | `schema-conversion.ts` | Converts supported MCP JSON Schema input schemas to Pi/typebox schemas. |
244
+ | `tool-registration.ts` | Generates Pi tool names, registers MCP-backed tools, truncates large outputs, and deactivates tools when servers stop. |
245
+ | `logger.ts` | Writes redacted diagnostic logs to `~/.pi/mcp-bridge.log`. |
246
+ | `types.ts` | Shared config and discovery types. |
247
+
248
+ ### Startup/session lifecycle
249
+
250
+ On `session_start`:
251
+
252
+ 1. Discover external MCP configs.
253
+ 2. Sync discovered servers into `~/.pi/mcp.json`.
254
+ 3. Read the effective config, including optional project overrides.
255
+ 4. Stop servers that are no longer enabled.
256
+ 5. Start enabled servers only.
257
+ 6. Register supported tools from running servers.
258
+ 7. Notify a short summary in the TUI.
259
+
260
+ On `session_shutdown`:
261
+
262
+ 1. Deactivate all registered MCP-backed tools.
263
+ 2. Stop all running MCP server processes.
264
+
265
+ ### Enable/disable lifecycle
266
+
267
+ `/mcp-enable` with no argument:
268
+
269
+ 1. Runs discovery/sync first so newly detected servers appear.
270
+ 2. Reads the global config.
271
+ 3. Lists disabled servers.
272
+ 4. Opens a selector in interactive Pi sessions.
273
+ 5. Enables selected servers in `~/.pi/mcp.json`.
274
+ 6. Starts enabled servers and registers tools.
275
+
276
+ `/mcp-disable` with no argument:
277
+
278
+ 1. Reads the effective config.
279
+ 2. Lists enabled servers.
280
+ 3. Opens a selector in interactive Pi sessions.
281
+ 4. Disables selected servers in `~/.pi/mcp.json`.
282
+ 5. Stops selected servers and deactivates their tools.
283
+
284
+ Direct commands, such as `/mcp-enable <server>` and `/mcp-disable <server>`, skip the selector.
285
+
286
+ ### Tool naming
287
+
288
+ Generated Pi tool names are prefixed with `mcp_` and include the normalized source/server/tool identity. Example:
289
+
290
+ ```text
291
+ mcp_claude_desktop_filesystem_read_file
292
+ ```
293
+
294
+ If a generated name collides, the registrar appends a deterministic short suffix.
295
+
296
+ ### Schema conversion
297
+
298
+ The initial schema converter intentionally supports a conservative subset:
299
+
300
+ - object schemas
301
+ - required fields
302
+ - strings
303
+ - numbers
304
+ - integers
305
+ - booleans
306
+ - arrays
307
+ - nested objects
308
+ - simple string enums
309
+
310
+ Unsupported complex schemas are skipped for that specific MCP tool rather than crashing the extension.
311
+
312
+ ### Output handling
313
+
314
+ MCP tool outputs can be large. The extension respects `maxOutputChars` and truncates returned tool text so Pi conversations do not balloon unexpectedly.
315
+
316
+ ### Logging and secrets
317
+
318
+ The extension does not log to stdout because Pi owns stdout/TUI rendering. Detailed diagnostics go to:
319
+
320
+ ```text
321
+ ~/.pi/mcp-bridge.log
322
+ ```
323
+
324
+ Logs redact common secret-bearing keys such as token, key, secret, password, auth, and credential. Still, avoid storing literal secrets in config files when possible.
325
+
326
+ ## Examples and fixtures
327
+
328
+ - `examples/mcp.json.example` — example global Pi MCP config.
329
+ - `examples/project-mcp.override.example.json` — example project override.
330
+ - `fixtures/` — known third-party config shapes used by tests.
331
+
332
+ ## Development
333
+
334
+ Install dependencies:
335
+
336
+ ```bash
337
+ npm install
338
+ ```
339
+
340
+ Run checks:
341
+
342
+ ```bash
343
+ npm run typecheck
344
+ npm test
345
+ ```
346
+
347
+ Preview npm package contents before publishing:
348
+
349
+ ```bash
350
+ npm pack --dry-run
351
+ ```
352
+
353
+ ## Publishing
354
+
355
+ For a scoped public npm package:
356
+
357
+ ```bash
358
+ npm run typecheck
359
+ npm test
360
+ npm pack --dry-run
361
+ npm publish --access public
362
+ ```
363
+
364
+ The package includes only the files listed in `package.json`'s `files` field.
365
+
366
+ ## Security checklist before enabling a server
367
+
368
+ 1. Run `/mcp-status` and inspect the server name, source, and command.
369
+ 2. Open `~/.pi/mcp.json` and review `command`, `args`, and `env`.
370
+ 3. Prefer environment variable references over literal secret values.
371
+ 4. Use `allowServers` and `denyServers` for project-level hardening.
372
+ 5. Review project-local `.pi/mcp.json` files before using Pi in untrusted repositories.
373
+ 6. Disable servers you are not actively using.
374
+
375
+ See `SECURITY.md` for the short-form security policy.
376
+
377
+ ## License
378
+
379
+ MIT. See `LICENSE`.
package/SECURITY.md ADDED
@@ -0,0 +1,13 @@
1
+ # Security
2
+
3
+ Pi extensions execute with your user permissions. MCP servers started by this package also execute with your user permissions.
4
+
5
+ Before enabling an MCP server:
6
+
7
+ 1. Review the command and arguments in `~/.pi/mcp.json`.
8
+ 2. Prefer environment-variable references over literal secrets.
9
+ 3. Use `enabled: false` until you intentionally want to start a server.
10
+ 4. Use `allowServers` / `denyServers` to restrict startup when needed.
11
+ 5. Review project-local `.pi/mcp.json` files before running Pi in untrusted repositories.
12
+
13
+ This extension redacts common secret key names in logs, but you should still avoid storing literal secrets in config files.
@@ -0,0 +1,23 @@
1
+ {
2
+ "version": 1,
3
+ "autoStart": false,
4
+ "servers": {
5
+ "claude_desktop__filesystem": {
6
+ "enabled": false,
7
+ "managedBy": "pi-mcp-bridge",
8
+ "source": "claude-desktop",
9
+ "sourceName": "filesystem",
10
+ "command": "npx",
11
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/fameftimie/work"],
12
+ "env": {}
13
+ },
14
+ "manual__example": {
15
+ "enabled": false,
16
+ "source": "manual",
17
+ "sourceName": "example",
18
+ "command": "example-mcp-server",
19
+ "args": [],
20
+ "env": {}
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "version": 1,
3
+ "autoStart": false,
4
+ "maxOutputChars": 20000,
5
+ "allowServers": ["claude_desktop__filesystem", "codex__playwright"],
6
+ "denyServers": ["*production*"],
7
+ "servers": {
8
+ "manual__project_local": {
9
+ "enabled": false,
10
+ "source": "manual",
11
+ "sourceName": "project-local",
12
+ "command": "example-mcp-server",
13
+ "args": [],
14
+ "env": {}
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,51 @@
1
+ # mcp-bridge
2
+
3
+ Explicit opt-in MCP compatibility bridge for Pi.
4
+
5
+ Current implementation covers Phase 1 through Phase 4:
6
+
7
+ - discovers MCP config candidates from Claude Desktop, Claude Code, and Codex
8
+ - syncs supported server entries into `~/.pi/mcp.json`
9
+ - preserves manually added Pi MCP server entries
10
+ - preserves `enabled` values for previously discovered managed entries
11
+ - defaults newly discovered servers to `enabled: false`
12
+ - writes a redacted debug log to `~/.pi/mcp-bridge.log`
13
+ - provides explicit enable/disable controls with server-name validation and completions
14
+ - serializes in-process config mutations to avoid command-handler races
15
+ - starts enabled MCP stdio servers only
16
+ - initializes MCP client sessions and lists tools
17
+ - converts a conservative JSON Schema subset to Pi/typebox tool schemas
18
+ - registers supported MCP tools as Pi tools
19
+ - forwards Pi tool calls to MCP `tools/call`
20
+ - stops/deactivates tools on disable, restart, and session shutdown
21
+ - supports project-local `.pi/mcp.json` overrides
22
+ - supports `allowServers`, `denyServers`, and `maxOutputChars`
23
+ - includes fixtures/tests for hardening
24
+
25
+ ## Commands
26
+
27
+ ```text
28
+ /mcp-sync
29
+ /mcp-status
30
+ /mcp-enable [server]
31
+ /mcp-disable [server]
32
+ /mcp-restart
33
+ ```
34
+
35
+ `/mcp-enable <server>` updates `~/.pi/mcp.json` and immediately attempts to start that enabled server. In interactive Pi sessions, `/mcp-enable` with no argument rescans detected MCP configs, opens a small selector for disabled servers, and enables the selected servers. `/mcp-disable <server>` stops the server and deactivates its Pi tools. In interactive Pi sessions, `/mcp-disable` with no argument opens a small selector for enabled servers and disables the selected servers. `/mcp-restart` restarts one enabled server or all enabled servers.
36
+
37
+ ## Config hardening
38
+
39
+ Optional settings in `~/.pi/mcp.json` or project-local `.pi/mcp.json`:
40
+
41
+ ```json
42
+ {
43
+ "allowServers": ["claude_desktop__filesystem", "codex__*"],
44
+ "denyServers": ["*production*"],
45
+ "maxOutputChars": 20000
46
+ }
47
+ ```
48
+
49
+ ## Safety invariant
50
+
51
+ Installing or reloading this extension must never execute newly discovered MCP server commands. Discovered servers are synced as disabled by default and must be explicitly enabled before future bridge phases may start them.
@@ -0,0 +1,143 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import TOML from "@iarna/toml";
6
+ import { MANAGED_BY, type DiscoveredMcpServer, type DiscoveryEvent, type DiscoveryResult, type McpSource } from "./types.js";
7
+
8
+ interface Candidate {
9
+ source: McpSource;
10
+ path: string;
11
+ format: "json" | "toml";
12
+ }
13
+
14
+ const CANDIDATES: Candidate[] = [
15
+ { source: "claude-desktop", path: "~/Library/Application Support/Claude/claude_desktop_config.json", format: "json" },
16
+ { source: "claude-code", path: "~/.claude.json", format: "json" },
17
+ { source: "claude-code", path: "~/.claude/settings.json", format: "json" },
18
+ { source: "claude-code", path: "~/.config/claude-code/config.json", format: "json" },
19
+ { source: "claude-code", path: "~/.config/claude-code/settings.json", format: "json" },
20
+ { source: "codex", path: "~/.codex/config.toml", format: "toml" },
21
+ { source: "codex", path: "~/.codex/config.json", format: "json" },
22
+ { source: "codex", path: "~/.config/codex/config.toml", format: "toml" },
23
+ { source: "codex", path: "~/.config/codex/config.json", format: "json" },
24
+ ];
25
+
26
+ export function expandHome(path: string): string {
27
+ return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
28
+ }
29
+
30
+ export async function discoverMcpServers(): Promise<DiscoveryResult> {
31
+ const servers: Record<string, DiscoveredMcpServer> = {};
32
+ const events: DiscoveryEvent[] = [];
33
+
34
+ for (const candidate of CANDIDATES) {
35
+ const path = expandHome(candidate.path);
36
+ try {
37
+ await access(path, constants.R_OK);
38
+ events.push({ path, source: candidate.source, status: "found" });
39
+ } catch {
40
+ events.push({ path, source: candidate.source, status: "missing" });
41
+ continue;
42
+ }
43
+
44
+ let parsed: unknown;
45
+ try {
46
+ const text = await readFile(path, "utf8");
47
+ parsed = candidate.format === "toml" ? TOML.parse(text) : JSON.parse(text);
48
+ events.push({ path, source: candidate.source, status: "parsed" });
49
+ } catch (error) {
50
+ events.push({ path, source: candidate.source, status: "parse-error", message: errorMessage(error) });
51
+ continue;
52
+ }
53
+
54
+ const maps = findMcpServerMaps(parsed);
55
+ if (maps.length === 0) {
56
+ events.push({ path, source: candidate.source, status: "unsupported", message: "No mcpServers or mcp_servers map found" });
57
+ continue;
58
+ }
59
+
60
+ let accepted = 0;
61
+ for (const map of maps) {
62
+ for (const [sourceName, rawServer] of Object.entries(map)) {
63
+ const normalized = normalizeServer(candidate.source, sourceName, rawServer, path);
64
+ if (!normalized) continue;
65
+ servers[makeServerKey(candidate.source, sourceName)] = normalized;
66
+ accepted++;
67
+ }
68
+ }
69
+
70
+ events.push({
71
+ path,
72
+ source: candidate.source,
73
+ status: accepted > 0 ? "contains-mcp" : "unsupported",
74
+ message: accepted > 0 ? `${accepted} MCP server(s)` : "MCP map found, but no supported server entries",
75
+ });
76
+ }
77
+
78
+ return { servers, events };
79
+ }
80
+
81
+ function findMcpServerMaps(value: unknown, depth = 0): Array<Record<string, unknown>> {
82
+ if (!value || typeof value !== "object" || Array.isArray(value) || depth > 5) return [];
83
+ const obj = value as Record<string, unknown>;
84
+ const maps: Array<Record<string, unknown>> = [];
85
+
86
+ for (const key of ["mcpServers", "mcp_servers", "mcpServersConfig"]) {
87
+ const maybe = obj[key];
88
+ if (maybe && typeof maybe === "object" && !Array.isArray(maybe)) {
89
+ maps.push(maybe as Record<string, unknown>);
90
+ }
91
+ }
92
+
93
+ for (const child of Object.values(obj)) {
94
+ if (child && typeof child === "object") {
95
+ maps.push(...findMcpServerMaps(child, depth + 1));
96
+ }
97
+ }
98
+
99
+ return maps;
100
+ }
101
+
102
+ function normalizeServer(source: McpSource, sourceName: string, raw: unknown, configPath: string): DiscoveredMcpServer | null {
103
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
104
+ const obj = raw as Record<string, unknown>;
105
+ const command = typeof obj.command === "string" ? obj.command : undefined;
106
+ if (!command) return null;
107
+
108
+ const args = Array.isArray(obj.args) ? obj.args.filter((arg): arg is string => typeof arg === "string") : [];
109
+ const env = normalizeEnv(obj.env);
110
+
111
+ return {
112
+ enabled: false,
113
+ managedBy: MANAGED_BY,
114
+ source,
115
+ sourceName,
116
+ command,
117
+ args,
118
+ env,
119
+ configPath,
120
+ };
121
+ }
122
+
123
+ function normalizeEnv(raw: unknown): Record<string, string> {
124
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
125
+ const out: Record<string, string> = {};
126
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
127
+ if (typeof value === "string") out[key] = value;
128
+ else if (typeof value === "number" || typeof value === "boolean") out[key] = String(value);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function makeServerKey(source: McpSource, sourceName: string): string {
134
+ return `${normalizeName(source)}__${normalizeName(sourceName)}`;
135
+ }
136
+
137
+ function normalizeName(value: string): string {
138
+ return value.replace(/[^A-Za-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "").toLowerCase() || "server";
139
+ }
140
+
141
+ function errorMessage(error: unknown): string {
142
+ return error instanceof Error ? error.message : String(error);
143
+ }