@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 +130 -0
- package/package.json +27 -0
- package/src/config/mcp-config.ts +521 -0
- package/src/index.ts +93 -0
- package/src/runtime/mcp-manager.ts +419 -0
- package/src/runtime/mcp-runtime.ts +823 -0
- package/src/tools/mcp-tool-bridge.ts +325 -0
- package/src/tools/mcp-tools.ts +257 -0
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
|
+
}
|