novahiz 0.3.6 → 0.3.7
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/dist/commands/doctor.js +49 -14
- package/package.json +1 -1
- package/src/commands/doctor.ts +47 -13
package/dist/commands/doctor.js
CHANGED
|
@@ -102,18 +102,28 @@ export function mcpEntryProblems(name, entry, catalogPackages, env) {
|
|
|
102
102
|
const notes = [];
|
|
103
103
|
if (entry.enabled === false)
|
|
104
104
|
return { problems, notes };
|
|
105
|
+
// ${VAR} references must resolve in the environment the CLI runs with.
|
|
106
|
+
for (const match of JSON.stringify(entry).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
107
|
+
if (!env[match[1]])
|
|
108
|
+
problems.push(`${name}: env ${match[1]} not set`);
|
|
109
|
+
}
|
|
105
110
|
const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
|
|
106
111
|
if (command.length === 0) {
|
|
107
|
-
|
|
112
|
+
// Remote MCP servers (type: remote) are URL-based and legitimately carry
|
|
113
|
+
// no command — validate the URL instead of failing them as broken locals.
|
|
114
|
+
const url = typeof entry.url === "string" ? entry.url : "";
|
|
115
|
+
if (!url) {
|
|
116
|
+
problems.push(`${name}: no command`);
|
|
117
|
+
return { problems, notes };
|
|
118
|
+
}
|
|
119
|
+
if (!/^https:\/\//.test(url))
|
|
120
|
+
problems.push(`${name}: remote url is not https`);
|
|
121
|
+
else
|
|
122
|
+
notes.push(`${name}: remote (${new URL(url).host})`);
|
|
108
123
|
return { problems, notes };
|
|
109
124
|
}
|
|
110
125
|
if (!resolveExecutable(command[0]))
|
|
111
126
|
problems.push(`${name}: executable not found (${command[0]})`);
|
|
112
|
-
// ${VAR} references must resolve in the environment the CLI runs with.
|
|
113
|
-
for (const match of JSON.stringify(entry).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
114
|
-
if (!env[match[1]])
|
|
115
|
-
problems.push(`${name}: env ${match[1]} not set`);
|
|
116
|
-
}
|
|
117
127
|
const configPkg = command.find((arg) => /@\d/.test(arg) && !arg.startsWith("-"));
|
|
118
128
|
const catalogPkg = catalogPackages.get(name);
|
|
119
129
|
if (catalogPkg && configPkg && configPkg !== catalogPkg) {
|
|
@@ -152,16 +162,41 @@ function killTree(child) {
|
|
|
152
162
|
}
|
|
153
163
|
}
|
|
154
164
|
/** One live JSON-RPC `initialize` probe per server. Opt-in via --deep, up to
|
|
155
|
-
* 10 s per server, all servers probed in parallel.
|
|
156
|
-
* OPEN: mcp-cron reads stdin asynchronously and exits cleanly
|
|
157
|
-
* no output) if the pipe closes before it starts reading.
|
|
165
|
+
* 10 s per server, all servers probed in parallel. Local entries are spawned
|
|
166
|
+
* with stdin kept OPEN: mcp-cron reads stdin asynchronously and exits cleanly
|
|
167
|
+
* (status 0, no output) if the pipe closes before it starts reading. Remote
|
|
168
|
+
* entries (type: remote) get one HTTP initialize round-trip instead. */
|
|
158
169
|
export function probeMcpServer(entry) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return;
|
|
170
|
+
const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
|
|
171
|
+
if (command.length === 0) {
|
|
172
|
+
const url = typeof entry.url === "string" ? entry.url : "";
|
|
173
|
+
if (!url)
|
|
174
|
+
return Promise.resolve({ ok: false, detail: "no command" });
|
|
175
|
+
const headers = {
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
Accept: "application/json, text/event-stream"
|
|
178
|
+
};
|
|
179
|
+
for (const [key, value] of Object.entries(entry.headers ?? {})) {
|
|
180
|
+
headers[key] = String(value);
|
|
164
181
|
}
|
|
182
|
+
const started = Date.now();
|
|
183
|
+
return fetch(url, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers,
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
jsonrpc: "2.0",
|
|
188
|
+
id: 1,
|
|
189
|
+
method: "initialize",
|
|
190
|
+
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "novahiz-doctor", version: "0" } }
|
|
191
|
+
}),
|
|
192
|
+
signal: AbortSignal.timeout(10_000)
|
|
193
|
+
})
|
|
194
|
+
.then((response) => response.ok
|
|
195
|
+
? { ok: true, detail: `remote initialize ${response.status} (${Date.now() - started} ms)` }
|
|
196
|
+
: { ok: false, detail: `remote initialize HTTP ${response.status}` })
|
|
197
|
+
.catch((error) => ({ ok: false, detail: `remote: ${error.message}` }));
|
|
198
|
+
}
|
|
199
|
+
return new Promise((resolve) => {
|
|
165
200
|
const resolved = resolveExecutable(command[0]);
|
|
166
201
|
if (!resolved) {
|
|
167
202
|
resolve({ ok: false, detail: `executable not found (${command[0]})` });
|
package/package.json
CHANGED
package/src/commands/doctor.ts
CHANGED
|
@@ -107,16 +107,24 @@ export function mcpEntryProblems(
|
|
|
107
107
|
const problems: string[] = [];
|
|
108
108
|
const notes: string[] = [];
|
|
109
109
|
if (entry.enabled === false) return { problems, notes };
|
|
110
|
+
// ${VAR} references must resolve in the environment the CLI runs with.
|
|
111
|
+
for (const match of JSON.stringify(entry).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
112
|
+
if (!env[match[1]]) problems.push(`${name}: env ${match[1]} not set`);
|
|
113
|
+
}
|
|
110
114
|
const command = Array.isArray(entry.command) ? (entry.command as unknown[]).map(String) : [];
|
|
111
115
|
if (command.length === 0) {
|
|
112
|
-
|
|
116
|
+
// Remote MCP servers (type: remote) are URL-based and legitimately carry
|
|
117
|
+
// no command — validate the URL instead of failing them as broken locals.
|
|
118
|
+
const url = typeof entry.url === "string" ? entry.url : "";
|
|
119
|
+
if (!url) {
|
|
120
|
+
problems.push(`${name}: no command`);
|
|
121
|
+
return { problems, notes };
|
|
122
|
+
}
|
|
123
|
+
if (!/^https:\/\//.test(url)) problems.push(`${name}: remote url is not https`);
|
|
124
|
+
else notes.push(`${name}: remote (${new URL(url).host})`);
|
|
113
125
|
return { problems, notes };
|
|
114
126
|
}
|
|
115
127
|
if (!resolveExecutable(command[0])) problems.push(`${name}: executable not found (${command[0]})`);
|
|
116
|
-
// ${VAR} references must resolve in the environment the CLI runs with.
|
|
117
|
-
for (const match of JSON.stringify(entry).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
118
|
-
if (!env[match[1]]) problems.push(`${name}: env ${match[1]} not set`);
|
|
119
|
-
}
|
|
120
128
|
const configPkg = command.find((arg) => /@\d/.test(arg) && !arg.startsWith("-"));
|
|
121
129
|
const catalogPkg = catalogPackages.get(name);
|
|
122
130
|
if (catalogPkg && configPkg && configPkg !== catalogPkg) {
|
|
@@ -152,16 +160,42 @@ function killTree(child: ReturnType<typeof spawn>): void {
|
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
/** One live JSON-RPC `initialize` probe per server. Opt-in via --deep, up to
|
|
155
|
-
* 10 s per server, all servers probed in parallel.
|
|
156
|
-
* OPEN: mcp-cron reads stdin asynchronously and exits cleanly
|
|
157
|
-
* no output) if the pipe closes before it starts reading.
|
|
163
|
+
* 10 s per server, all servers probed in parallel. Local entries are spawned
|
|
164
|
+
* with stdin kept OPEN: mcp-cron reads stdin asynchronously and exits cleanly
|
|
165
|
+
* (status 0, no output) if the pipe closes before it starts reading. Remote
|
|
166
|
+
* entries (type: remote) get one HTTP initialize round-trip instead. */
|
|
158
167
|
export function probeMcpServer(entry: Record<string, unknown>): Promise<{ ok: boolean; detail: string }> {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
const command = Array.isArray(entry.command) ? (entry.command as unknown[]).map(String) : [];
|
|
169
|
+
if (command.length === 0) {
|
|
170
|
+
const url = typeof entry.url === "string" ? entry.url : "";
|
|
171
|
+
if (!url) return Promise.resolve({ ok: false, detail: "no command" });
|
|
172
|
+
const headers: Record<string, string> = {
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
Accept: "application/json, text/event-stream"
|
|
175
|
+
};
|
|
176
|
+
for (const [key, value] of Object.entries((entry.headers as Record<string, unknown> | undefined) ?? {})) {
|
|
177
|
+
headers[key] = String(value);
|
|
164
178
|
}
|
|
179
|
+
const started = Date.now();
|
|
180
|
+
return fetch(url, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers,
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
jsonrpc: "2.0",
|
|
185
|
+
id: 1,
|
|
186
|
+
method: "initialize",
|
|
187
|
+
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "novahiz-doctor", version: "0" } }
|
|
188
|
+
}),
|
|
189
|
+
signal: AbortSignal.timeout(10_000)
|
|
190
|
+
})
|
|
191
|
+
.then((response) =>
|
|
192
|
+
response.ok
|
|
193
|
+
? { ok: true, detail: `remote initialize ${response.status} (${Date.now() - started} ms)` }
|
|
194
|
+
: { ok: false, detail: `remote initialize HTTP ${response.status}` }
|
|
195
|
+
)
|
|
196
|
+
.catch((error: unknown) => ({ ok: false, detail: `remote: ${(error as Error).message}` }));
|
|
197
|
+
}
|
|
198
|
+
return new Promise((resolve) => {
|
|
165
199
|
const resolved = resolveExecutable(command[0]);
|
|
166
200
|
if (!resolved) {
|
|
167
201
|
resolve({ ok: false, detail: `executable not found (${command[0]})` });
|