@yofriadi/pi-mcp 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/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # MCP Extension
2
+
3
+ Standalone MCP extension package for `pi` and the Bun fork workflow.
4
+
5
+ This package provides:
6
+
7
+ - MCP config discovery and validation
8
+ - MCP runtime with stdio and HTTP JSON-RPC transport support
9
+ - MCP manager lifecycle orchestration (startup/reload/shutdown)
10
+ - MCP command/tool utilities and discovered-tool bridge registration
11
+
12
+ ## Install and Load
13
+
14
+ ### Upstream `pi`
15
+
16
+ ```bash
17
+ # Load extension for one run
18
+ pi -e ./packages/coding-agent/examples/extensions/mcp
19
+
20
+ # Persist extension as an installed package source
21
+ pi install ./packages/coding-agent/examples/extensions/mcp
22
+ ```
23
+
24
+ ### Bun fork source workflow
25
+
26
+ ```bash
27
+ # Run coding-agent CLI directly via Bun with extension loaded
28
+ bun packages/coding-agent/src/cli.ts -e ./packages/coding-agent/examples/extensions/mcp
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ ### Native config merge order
34
+
35
+ The resolver loads files in this order (later entries override earlier by server name):
36
+
37
+ 1. `~/.pi/agent/mcp.json`
38
+ 2. `<cwd>/.mcp.json`
39
+ 3. `<cwd>/.pi/mcp.json`
40
+
41
+ Supported top-level shapes:
42
+
43
+ - `mcpServers` object
44
+ - `servers` object
45
+ - `servers` array (`[{ "name": "...", ... }]`)
46
+
47
+ Example:
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "context7": {
53
+ "transport": "stdio",
54
+ "command": "npx",
55
+ "args": ["-y", "@upstash/context7-mcp"],
56
+ "timeoutMs": 30000
57
+ },
58
+ "mcp.grep.app": {
59
+ "transport": "http",
60
+ "url": "https://mcp.grep.app",
61
+ "timeoutMs": 30000
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ### Optional external discovery adapters (opt-in)
68
+
69
+ By default, external Claude/Cursor configs are ignored.
70
+
71
+ To opt in:
72
+
73
+ ```bash
74
+ export PI_MCP_DISCOVERY_ADAPTERS=claude,cursor
75
+ ```
76
+
77
+ Supported adapter values:
78
+
79
+ - `claude`
80
+ - `cursor`
81
+ - `none` (disables adapter loading)
82
+
83
+ Adapter-derived servers are loaded before native pi config files, so native pi files can override imported definitions.
84
+
85
+ ## Commands
86
+
87
+ - `/mcp-status` show manager/runtime/config/tool-cache health
88
+ - `/mcp-tools <server>` list MCP tools from one server
89
+ - `/mcp-call <server> <method> [jsonParams]` issue JSON-RPC call
90
+ - `/mcp-reload` reload config and restart MCP runtime
91
+
92
+ ## Agent Tools
93
+
94
+ - `mcp_list_tools`
95
+ - `mcp_call`
96
+
97
+ At startup/reload, discovered MCP tools are bridged into regular agent tools with stable names (for example: `mcp_context7_resolve_library_id`).
98
+
99
+ ## Security Notes
100
+
101
+ - Only configure MCP servers you trust. MCP tools can execute external processes or requests.
102
+ - Review local config files before enabling adapters (`PI_MCP_DISCOVERY_ADAPTERS`) because this imports external definitions.
103
+ - Prefer pinned commands/versions (for example explicit npm package versions) when possible.
104
+ - Treat MCP server output as untrusted input in downstream prompts and scripts.
105
+
106
+ ## Troubleshooting
107
+
108
+ ### No MCP servers appear
109
+
110
+ 1. Run `/mcp-status`.
111
+ 2. Check `Configured servers` and `Diagnostics` output.
112
+ 3. Verify file paths and JSON validity for `~/.pi/agent/mcp.json`, `.mcp.json`, or `.pi/mcp.json`.
113
+
114
+ ### Server is configured but not active
115
+
116
+ 1. Run `/mcp-status` and inspect the server reason.
117
+ 2. For stdio servers, verify command + args locally.
118
+ 3. For HTTP servers, verify endpoint accepts JSON-RPC POST and returns valid responses.
119
+
120
+ ### Tool bridge did not register expected tools
121
+
122
+ 1. Run `/mcp-status` and inspect `Discovered MCP tools` and `Bridged MCP tools`.
123
+ 2. Run `/mcp-tools <server>` to confirm server `tools/list` output.
124
+ 3. Run `/mcp-reload` after config/server changes.
125
+
126
+ ### Adapter-based discovery not working
127
+
128
+ 1. Confirm `PI_MCP_DISCOVERY_ADAPTERS` is set in the runtime environment.
129
+ 2. Use supported values only: `claude,cursor`.
130
+ 3. Re-run `/mcp-reload` and inspect `/mcp-status` diagnostics for unknown adapter warnings.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@yofriadi/pi-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Standalone MCP extension package for pi installs",
5
+ "type": "module",
6
+ "files": [
7
+ "src",
8
+ "README.md"
9
+ ],
10
+ "keywords": [
11
+ "pi-package",
12
+ "mcp"
13
+ ],
14
+ "scripts": {
15
+ "clean": "echo 'nothing to clean'",
16
+ "build": "echo 'nothing to build'",
17
+ "check": "echo 'nothing to check'"
18
+ },
19
+ "pi": {
20
+ "extensions": [
21
+ "./src/index.ts"
22
+ ]
23
+ },
24
+ "peerDependencies": {
25
+ "@mariozechner/pi-coding-agent": "*"
26
+ }
27
+ }
@@ -0,0 +1,521 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+
5
+ const DEFAULT_TIMEOUT_MS = 15_000;
6
+ const DISCOVERY_ADAPTERS_ENV_KEY = "PI_MCP_DISCOVERY_ADAPTERS";
7
+
8
+ export type McpTransport = "stdio" | "http";
9
+ export type McpDiscoveryAdapter = "claude" | "cursor";
10
+
11
+ export interface McpServerConfig {
12
+ name: string;
13
+ transport: McpTransport;
14
+ command?: string;
15
+ args: string[];
16
+ url?: string;
17
+ headers: Record<string, string>;
18
+ env: Record<string, string>;
19
+ timeoutMs: number;
20
+ disabled: boolean;
21
+ sourcePath: string;
22
+ }
23
+
24
+ export interface McpConfigDiagnostic {
25
+ level: "warning" | "error";
26
+ code: string;
27
+ message: string;
28
+ sourcePath?: string;
29
+ serverName?: string;
30
+ }
31
+
32
+ export interface McpResolvedConfig {
33
+ servers: McpServerConfig[];
34
+ diagnostics: McpConfigDiagnostic[];
35
+ sourcePaths: string[];
36
+ }
37
+
38
+ export interface McpConfigResolverOptions {
39
+ cwd?: string;
40
+ homeDir?: string;
41
+ env?: NodeJS.ProcessEnv;
42
+ explicitConfigPath?: string;
43
+ discoveryAdapters?: McpDiscoveryAdapter[];
44
+ warn?: (diagnostic: McpConfigDiagnostic) => void;
45
+ }
46
+
47
+ export interface McpConfigResolver {
48
+ resolve(explicitConfigPath?: string): McpResolvedConfig;
49
+ }
50
+
51
+ type JsonObject = Record<string, unknown>;
52
+
53
+ type RawServerEntry = {
54
+ name: string;
55
+ value: unknown;
56
+ };
57
+
58
+ interface ConfigSourceCandidate {
59
+ path: string;
60
+ adapter?: McpDiscoveryAdapter;
61
+ }
62
+
63
+ export function createMcpConfigResolver(options: McpConfigResolverOptions = {}): McpConfigResolver {
64
+ return {
65
+ resolve(explicitConfigPath?: string): McpResolvedConfig {
66
+ const cwd = options.cwd ?? process.cwd();
67
+ const homeDir = options.homeDir ?? homedir();
68
+ const diagnostics: McpConfigDiagnostic[] = [];
69
+ const warn = options.warn ?? ((diagnostic: McpConfigDiagnostic) => diagnostics.push(diagnostic));
70
+ const sourcePaths: string[] = [];
71
+ const serversByName = new Map<string, McpServerConfig>();
72
+ const resolvedExplicit = explicitConfigPath ?? options.explicitConfigPath;
73
+ const discoveryAdapters = resolveDiscoveryAdapters(
74
+ options.discoveryAdapters,
75
+ options.env ?? process.env,
76
+ warn,
77
+ );
78
+
79
+ for (const source of getCandidateSources(cwd, homeDir, resolvedExplicit, discoveryAdapters)) {
80
+ if (!existsSync(source.path)) {
81
+ continue;
82
+ }
83
+
84
+ sourcePaths.push(source.path);
85
+ const rawConfig = parseJsonFile(source.path, warn);
86
+ if (!rawConfig) {
87
+ continue;
88
+ }
89
+
90
+ const configObjects =
91
+ source.adapter === undefined ? [rawConfig] : extractAdapterConfigObjects(rawConfig, source.adapter, cwd);
92
+
93
+ for (const configObject of configObjects) {
94
+ for (const entry of extractServerEntries(configObject, source.path, warn)) {
95
+ const parsed = parseServerConfig(entry, source.path, warn);
96
+ if (!parsed) {
97
+ continue;
98
+ }
99
+ if (parsed.disabled) {
100
+ serversByName.delete(parsed.name);
101
+ continue;
102
+ }
103
+ serversByName.set(parsed.name, parsed);
104
+ }
105
+ }
106
+ }
107
+
108
+ return {
109
+ servers: [...serversByName.values()],
110
+ diagnostics,
111
+ sourcePaths,
112
+ };
113
+ },
114
+ };
115
+ }
116
+
117
+ export function createMcpConfig(options: McpConfigResolverOptions = {}): McpResolvedConfig {
118
+ return createMcpConfigResolver(options).resolve();
119
+ }
120
+
121
+ function getCandidateSources(
122
+ cwd: string,
123
+ homeDir: string,
124
+ explicitConfigPath: string | undefined,
125
+ discoveryAdapters: McpDiscoveryAdapter[],
126
+ ): ConfigSourceCandidate[] {
127
+ const nativeCandidates: ConfigSourceCandidate[] = [
128
+ { path: join(homeDir, ".pi", "agent", "mcp.json") },
129
+ { path: join(cwd, ".mcp.json") },
130
+ { path: join(cwd, ".pi", "mcp.json") },
131
+ ];
132
+ const adapterCandidates = getAdapterCandidates(cwd, homeDir, discoveryAdapters);
133
+ const explicitCandidates = explicitConfigPath?.trim() ? [{ path: resolveConfigPath(explicitConfigPath, cwd) }] : [];
134
+
135
+ return dedupeCandidates([...adapterCandidates, ...nativeCandidates, ...explicitCandidates]);
136
+ }
137
+
138
+ function getAdapterCandidates(
139
+ cwd: string,
140
+ homeDir: string,
141
+ discoveryAdapters: McpDiscoveryAdapter[],
142
+ ): ConfigSourceCandidate[] {
143
+ const candidates: ConfigSourceCandidate[] = [];
144
+ for (const adapter of discoveryAdapters) {
145
+ if (adapter === "claude") {
146
+ candidates.push(
147
+ { path: join(homeDir, ".claude.json"), adapter },
148
+ { path: join(homeDir, ".config", "claude", "claude_desktop_config.json"), adapter },
149
+ { path: join(cwd, ".claude.json"), adapter },
150
+ );
151
+ continue;
152
+ }
153
+
154
+ if (adapter === "cursor") {
155
+ candidates.push(
156
+ { path: join(homeDir, ".cursor", "mcp.json"), adapter },
157
+ { path: join(cwd, ".cursor", "mcp.json"), adapter },
158
+ { path: join(cwd, ".vscode", "mcp.json"), adapter },
159
+ );
160
+ }
161
+ }
162
+ return candidates;
163
+ }
164
+
165
+ function dedupeCandidates(candidates: ConfigSourceCandidate[]): ConfigSourceCandidate[] {
166
+ const unique = new Map<string, ConfigSourceCandidate>();
167
+ for (const candidate of candidates) {
168
+ if (!unique.has(candidate.path)) {
169
+ unique.set(candidate.path, candidate);
170
+ }
171
+ }
172
+ return [...unique.values()];
173
+ }
174
+
175
+ function resolveDiscoveryAdapters(
176
+ explicitAdapters: McpDiscoveryAdapter[] | undefined,
177
+ env: NodeJS.ProcessEnv,
178
+ warn: (diagnostic: McpConfigDiagnostic) => void,
179
+ ): McpDiscoveryAdapter[] {
180
+ const raw =
181
+ explicitAdapters ??
182
+ (env[DISCOVERY_ADAPTERS_ENV_KEY]
183
+ ? env[DISCOVERY_ADAPTERS_ENV_KEY]
184
+ .split(",")
185
+ .map((entry) => entry.trim().toLowerCase())
186
+ .filter((entry) => entry.length > 0)
187
+ : []);
188
+
189
+ const normalized = new Set<McpDiscoveryAdapter>();
190
+ for (const entry of raw) {
191
+ const value = typeof entry === "string" ? entry.trim().toLowerCase() : "";
192
+ if (!value || value === "none") {
193
+ continue;
194
+ }
195
+ if (value === "claude" || value === "cursor") {
196
+ normalized.add(value);
197
+ continue;
198
+ }
199
+ warn({
200
+ level: "warning",
201
+ code: "discovery_adapter_unknown",
202
+ message: `Ignoring unknown MCP discovery adapter "${entry}". Supported values: claude,cursor.`,
203
+ });
204
+ }
205
+
206
+ return [...normalized];
207
+ }
208
+
209
+ function extractAdapterConfigObjects(rawConfig: JsonObject, adapter: McpDiscoveryAdapter, cwd: string): JsonObject[] {
210
+ const objects: JsonObject[] = [rawConfig];
211
+ const nested = adapter === "claude" ? rawConfig.projects : (rawConfig.workspaces ?? rawConfig.projects);
212
+ if (!isObject(nested)) {
213
+ return objects;
214
+ }
215
+
216
+ for (const [projectPath, projectConfig] of Object.entries(nested)) {
217
+ if (!isObject(projectConfig)) {
218
+ continue;
219
+ }
220
+ if (!pathMatchesCwd(projectPath, cwd)) {
221
+ continue;
222
+ }
223
+ objects.push(projectConfig);
224
+ }
225
+
226
+ return objects;
227
+ }
228
+
229
+ function pathMatchesCwd(configPath: string, cwd: string): boolean {
230
+ const resolvedConfig = resolve(configPath);
231
+ const resolvedCwd = resolve(cwd);
232
+ return resolvedCwd === resolvedConfig || resolvedCwd.startsWith(`${resolvedConfig}/`);
233
+ }
234
+
235
+ function resolveConfigPath(inputPath: string, cwd: string): string {
236
+ return isAbsolute(inputPath) ? inputPath : resolve(cwd, inputPath);
237
+ }
238
+
239
+ function parseJsonFile(sourcePath: string, warn: (diagnostic: McpConfigDiagnostic) => void): JsonObject | undefined {
240
+ try {
241
+ const content = readFileSync(sourcePath, "utf8");
242
+ const parsed = JSON.parse(content);
243
+ if (!isObject(parsed)) {
244
+ warn({
245
+ level: "warning",
246
+ code: "config_not_object",
247
+ message: "Ignoring MCP config because top-level JSON value is not an object.",
248
+ sourcePath,
249
+ });
250
+ return undefined;
251
+ }
252
+ return parsed;
253
+ } catch (error) {
254
+ warn({
255
+ level: "error",
256
+ code: "config_parse_failed",
257
+ message: `Failed to parse MCP config: ${formatError(error)}`,
258
+ sourcePath,
259
+ });
260
+ return undefined;
261
+ }
262
+ }
263
+
264
+ function extractServerEntries(
265
+ rawConfig: JsonObject,
266
+ sourcePath: string,
267
+ warn: (diagnostic: McpConfigDiagnostic) => void,
268
+ ): RawServerEntry[] {
269
+ const entries: RawServerEntry[] = [];
270
+
271
+ const objectShapes: Array<[string, unknown]> = [
272
+ ["mcpServers", rawConfig.mcpServers],
273
+ ["servers", rawConfig.servers],
274
+ ];
275
+
276
+ for (const [key, value] of objectShapes) {
277
+ if (value === undefined) {
278
+ continue;
279
+ }
280
+
281
+ if (Array.isArray(value)) {
282
+ for (const candidate of value) {
283
+ if (!isObject(candidate)) {
284
+ warn({
285
+ level: "warning",
286
+ code: "server_entry_invalid",
287
+ message: `Ignoring ${key} entry because it is not an object.`,
288
+ sourcePath,
289
+ });
290
+ continue;
291
+ }
292
+ const name = typeof candidate.name === "string" ? candidate.name.trim() : "";
293
+ if (!name) {
294
+ warn({
295
+ level: "warning",
296
+ code: "server_name_missing",
297
+ message: `Ignoring ${key} entry because "name" is missing.`,
298
+ sourcePath,
299
+ });
300
+ continue;
301
+ }
302
+ entries.push({ name, value: candidate });
303
+ }
304
+ continue;
305
+ }
306
+
307
+ if (isObject(value)) {
308
+ for (const [name, entry] of Object.entries(value)) {
309
+ entries.push({ name, value: entry });
310
+ }
311
+ continue;
312
+ }
313
+
314
+ warn({
315
+ level: "warning",
316
+ code: "server_shape_invalid",
317
+ message: `Ignoring ${key} because expected object or array but got ${typeof value}.`,
318
+ sourcePath,
319
+ });
320
+ }
321
+
322
+ return entries;
323
+ }
324
+
325
+ function parseServerConfig(
326
+ entry: RawServerEntry,
327
+ sourcePath: string,
328
+ warn: (diagnostic: McpConfigDiagnostic) => void,
329
+ ): McpServerConfig | undefined {
330
+ if (!isObject(entry.value)) {
331
+ warn({
332
+ level: "warning",
333
+ code: "server_entry_not_object",
334
+ message: `Ignoring server "${entry.name}" because its value is not an object.`,
335
+ sourcePath,
336
+ serverName: entry.name,
337
+ });
338
+ return undefined;
339
+ }
340
+
341
+ const raw = entry.value;
342
+ const transport = normalizeTransport(raw.transport, raw.command, raw.url);
343
+ const command = typeof raw.command === "string" && raw.command.trim() ? raw.command.trim() : undefined;
344
+ const args = normalizeStringArray(raw.args, "args", entry.name, sourcePath, warn);
345
+ const url = typeof raw.url === "string" && raw.url.trim() ? raw.url.trim() : undefined;
346
+ const headers = normalizeStringMap(raw.headers, "headers", entry.name, sourcePath, warn);
347
+ const env = normalizeStringMap(raw.env, "env", entry.name, sourcePath, warn);
348
+ const timeoutMs = normalizeTimeout(raw.timeoutMs, sourcePath, entry.name, warn);
349
+ const disabled = Boolean(raw.disabled);
350
+
351
+ if (transport === "stdio" && !command) {
352
+ warn({
353
+ level: "error",
354
+ code: "stdio_command_missing",
355
+ message: `Ignoring server "${entry.name}" because transport is stdio but "command" is missing.`,
356
+ sourcePath,
357
+ serverName: entry.name,
358
+ });
359
+ return undefined;
360
+ }
361
+
362
+ if (transport === "http") {
363
+ if (!url) {
364
+ warn({
365
+ level: "error",
366
+ code: "http_url_missing",
367
+ message: `Ignoring server "${entry.name}" because transport is http but "url" is missing.`,
368
+ sourcePath,
369
+ serverName: entry.name,
370
+ });
371
+ return undefined;
372
+ }
373
+ if (!isHttpUrl(url)) {
374
+ warn({
375
+ level: "error",
376
+ code: "http_url_invalid",
377
+ message: `Ignoring server "${entry.name}" because url is not a valid http(s) URL: ${url}`,
378
+ sourcePath,
379
+ serverName: entry.name,
380
+ });
381
+ return undefined;
382
+ }
383
+ }
384
+
385
+ return {
386
+ name: entry.name,
387
+ transport,
388
+ command,
389
+ args,
390
+ url,
391
+ headers,
392
+ env,
393
+ timeoutMs,
394
+ disabled,
395
+ sourcePath,
396
+ };
397
+ }
398
+
399
+ function normalizeTransport(transport: unknown, command: unknown, url: unknown): McpTransport {
400
+ if (transport === "http" || transport === "stdio") {
401
+ return transport;
402
+ }
403
+ if (typeof url === "string" && url.trim()) {
404
+ return "http";
405
+ }
406
+ if (typeof command === "string" && command.trim()) {
407
+ return "stdio";
408
+ }
409
+ return "stdio";
410
+ }
411
+
412
+ function normalizeStringArray(
413
+ value: unknown,
414
+ key: string,
415
+ serverName: string,
416
+ sourcePath: string,
417
+ warn: (diagnostic: McpConfigDiagnostic) => void,
418
+ ): string[] {
419
+ if (value === undefined) {
420
+ return [];
421
+ }
422
+ if (!Array.isArray(value)) {
423
+ warn({
424
+ level: "warning",
425
+ code: "string_array_invalid",
426
+ message: `Ignoring "${key}" for server "${serverName}" because it is not an array.`,
427
+ sourcePath,
428
+ serverName,
429
+ });
430
+ return [];
431
+ }
432
+ return value
433
+ .filter((item) => typeof item === "string")
434
+ .map((item) => item.trim())
435
+ .filter((item) => item.length > 0);
436
+ }
437
+
438
+ function normalizeStringMap(
439
+ value: unknown,
440
+ key: string,
441
+ serverName: string,
442
+ sourcePath: string,
443
+ warn: (diagnostic: McpConfigDiagnostic) => void,
444
+ ): Record<string, string> {
445
+ if (value === undefined) {
446
+ return {};
447
+ }
448
+ if (!isObject(value)) {
449
+ warn({
450
+ level: "warning",
451
+ code: "string_map_invalid",
452
+ message: `Ignoring "${key}" for server "${serverName}" because it is not an object.`,
453
+ sourcePath,
454
+ serverName,
455
+ });
456
+ return {};
457
+ }
458
+
459
+ const output: Record<string, string> = {};
460
+ for (const [mapKey, mapValue] of Object.entries(value)) {
461
+ if (typeof mapValue !== "string") {
462
+ warn({
463
+ level: "warning",
464
+ code: "string_map_value_invalid",
465
+ message: `Ignoring non-string value for "${key}.${mapKey}" on server "${serverName}".`,
466
+ sourcePath,
467
+ serverName,
468
+ });
469
+ continue;
470
+ }
471
+ const trimmed = mapValue.trim();
472
+ if (!trimmed) {
473
+ continue;
474
+ }
475
+ output[mapKey] = trimmed;
476
+ }
477
+
478
+ return output;
479
+ }
480
+
481
+ function normalizeTimeout(
482
+ value: unknown,
483
+ sourcePath: string,
484
+ serverName: string,
485
+ warn: (diagnostic: McpConfigDiagnostic) => void,
486
+ ): number {
487
+ if (value === undefined) {
488
+ return DEFAULT_TIMEOUT_MS;
489
+ }
490
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
491
+ warn({
492
+ level: "warning",
493
+ code: "timeout_invalid",
494
+ message: `Ignoring timeoutMs for server "${serverName}" because it is not a positive number.`,
495
+ sourcePath,
496
+ serverName,
497
+ });
498
+ return DEFAULT_TIMEOUT_MS;
499
+ }
500
+ return Math.round(value);
501
+ }
502
+
503
+ function isHttpUrl(value: string): boolean {
504
+ try {
505
+ const parsed = new URL(value);
506
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
511
+
512
+ function isObject(value: unknown): value is JsonObject {
513
+ return !!value && typeof value === "object" && !Array.isArray(value);
514
+ }
515
+
516
+ function formatError(error: unknown): string {
517
+ if (error instanceof Error) {
518
+ return error.message;
519
+ }
520
+ return String(error);
521
+ }