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.
@@ -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
- problems.push(`${name}: no command`);
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. stdin is written but kept
156
- * OPEN: mcp-cron reads stdin asynchronously and exits cleanly (status 0,
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
- return new Promise((resolve) => {
160
- const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
161
- if (command.length === 0) {
162
- resolve({ ok: false, detail: "no command" });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Enforce AI agent skills before every file edit: prompt classifier, skill catalog, and pre-edit gate.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
- problems.push(`${name}: no command`);
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. stdin is written but kept
156
- * OPEN: mcp-cron reads stdin asynchronously and exits cleanly (status 0,
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
- return new Promise((resolve) => {
160
- const command = Array.isArray(entry.command) ? (entry.command as unknown[]).map(String) : [];
161
- if (command.length === 0) {
162
- resolve({ ok: false, detail: "no command" });
163
- return;
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]})` });