@ghl-ai/aw 0.1.44-beta.1 → 0.1.44-beta.2

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/c4/index.mjs ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * c4/index.mjs — public barrel.
3
+ *
4
+ * Re-exports every named symbol from every c4/ module so the orchestrator
5
+ * (and tests, and downstream tools that want to compose c4 primitives)
6
+ * can do:
7
+ *
8
+ * import * as c4 from './c4/index.mjs';
9
+ * c4.detectHarness();
10
+ * c4.runPreflight({...});
11
+ * c4.summarizeAsOneLine({...});
12
+ *
13
+ * Strict re-exports only — no logic, no defaults, no aggregations. Adding
14
+ * a new c4 primitive means adding one line here.
15
+ *
16
+ * Contract: tasks.md::4.3.
17
+ */
18
+
19
+ export { detectHarness, HARNESSES } from './detect.mjs';
20
+ export { getGithubToken, maskToken } from './secrets.mjs';
21
+ export { ensureGhCli } from './ghCli.mjs';
22
+ export {
23
+ clearAllGitHubAuthState,
24
+ installPatInsteadOf,
25
+ installCredentialHelperStore,
26
+ ghAuthLoginWithToken,
27
+ ghAuthSetupGit,
28
+ ensureOriginRemote,
29
+ verifyAuth,
30
+ preflightPlatformDocs,
31
+ } from './gitAuth.mjs';
32
+ export { applyEccRegistryBridge } from './eccRegistryBridge.mjs';
33
+ export {
34
+ SLIM_CARD_HARD_CEILING_BYTES,
35
+ REQUIRED_ENFORCEMENT_PHRASES,
36
+ REQUIRED_STAGE_MARKERS_CLAUDE,
37
+ REQUIRED_STAGE_MARKERS_CURSOR,
38
+ SLIM_CARD_CLAUDE,
39
+ SLIM_CARD_CURSOR,
40
+ buildSlimRouterCard,
41
+ installSlimRouter,
42
+ } from './slimRouter.mjs';
43
+ export { defaultHookPath, installCodexPromptInjector } from './codexPromptInjector.mjs';
44
+ export {
45
+ MCP_URL_DEFAULT as CODEX_MCP_URL_DEFAULT,
46
+ ensureCodexHooksFlag,
47
+ ensureCodexMcpServer,
48
+ } from './codexConfig.mjs';
49
+ export { MCP_URL_DEFAULT, registerGhlAiMcp } from './mcpServer.mjs';
50
+ export { probeMcpServer } from './mcpSmokeProbe.mjs';
51
+ export { ensureClaudeMarketplace } from './claudePluginRegistry.mjs';
52
+ export { ensureCommandSurface, diagnoseCommandResolution } from './commandSurface.mjs';
53
+ export { ensureRepoLocalClaudeSettings } from './repoLocalClaudeSettings.mjs';
54
+ export { copyRepoRootInstructions } from './repoRootInstructions.mjs';
55
+ export { ensureRepoLocalIgnore } from './repoLocalIgnore.mjs';
56
+ export { jsonMergeWithDedup, claudeHooksMerge } from './jsonMerge.mjs';
57
+ export { runPreflight } from './preflight.mjs';
58
+ export {
59
+ summarizeAsOneLine,
60
+ diagnoseAwRouterView,
61
+ diagnosePromptRouterInjection,
62
+ diagnoseSkillResolution,
63
+ dumpPostInitState,
64
+ } from './diagnostics.mjs';
@@ -0,0 +1,229 @@
1
+ /**
2
+ * c4/jsonMerge.mjs — JSON-config merge helpers used by harness installers.
3
+ *
4
+ * Two flavors are exported because harness shapes diverge:
5
+ * - jsonMergeWithDedup — flat array-of-entries (Cursor hooks.json, mcp.json,
6
+ * Claude mcpServers, permissions.allow extension).
7
+ * - claudeHooksMerge — Claude's nested hook-groups: each event maps to an
8
+ * array of GROUPS, each group has matcher? + inner
9
+ * hooks[]. Stripping AW entries means filtering inner
10
+ * hooks[] and dropping groups that become empty.
11
+ *
12
+ * Both perform an atomic write (tmp file + rename) and chmod the final file
13
+ * to 0600. Idempotency is achieved by stripping pre-existing entries that
14
+ * match a `commandPatterns` substring list before appending the new entry,
15
+ * then comparing serialized JSON before writing.
16
+ */
17
+
18
+ import { existsSync, readFileSync, writeFileSync, renameSync, chmodSync, unlinkSync } from 'node:fs';
19
+ import { dirname, basename, join } from 'node:path';
20
+
21
+ const FILE_MODE = 0o600;
22
+
23
+ /**
24
+ * @param {string} pointer RFC 6901 pointer, e.g. "/hooks/sessionStart"
25
+ * @returns {string[]} Path tokens (empty array for "/")
26
+ */
27
+ function parseJsonPointer(pointer) {
28
+ if (pointer === '' || pointer === '/') return [];
29
+ if (!pointer.startsWith('/')) {
30
+ throw new Error(`Invalid JSON pointer: ${pointer} (must start with "/")`);
31
+ }
32
+ return pointer
33
+ .slice(1)
34
+ .split('/')
35
+ .map((token) => token.replace(/~1/g, '/').replace(/~0/g, '~'));
36
+ }
37
+
38
+ /**
39
+ * Walk a JSON pointer to its parent and key, creating intermediate objects.
40
+ * Returns { parent, key } so the caller can read/write `parent[key]`.
41
+ */
42
+ function walkAndCreate(root, tokens) {
43
+ if (tokens.length === 0) {
44
+ throw new Error('jsonMergeWithDedup: pointer must target a child key, not the root');
45
+ }
46
+ let cursor = root;
47
+ for (let i = 0; i < tokens.length - 1; i += 1) {
48
+ const t = tokens[i];
49
+ if (cursor[t] == null || typeof cursor[t] !== 'object' || Array.isArray(cursor[t])) {
50
+ cursor[t] = {};
51
+ }
52
+ cursor = cursor[t];
53
+ }
54
+ return { parent: cursor, key: tokens[tokens.length - 1] };
55
+ }
56
+
57
+ /**
58
+ * Atomically write JSON to `filePath` with mode 0600.
59
+ * No-op if `serialized` matches the existing file content byte-for-byte.
60
+ *
61
+ * @returns {boolean} true if the file changed, false if no write occurred.
62
+ */
63
+ function atomicWriteIfChanged(filePath, serialized) {
64
+ if (existsSync(filePath)) {
65
+ const prev = readFileSync(filePath, 'utf8');
66
+ if (prev === serialized) return false;
67
+ }
68
+
69
+ const tmp = join(
70
+ dirname(filePath),
71
+ `.${basename(filePath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
72
+ );
73
+
74
+ try {
75
+ writeFileSync(tmp, serialized, { mode: FILE_MODE });
76
+ renameSync(tmp, filePath);
77
+ } catch (err) {
78
+ if (existsSync(tmp)) {
79
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
80
+ }
81
+ throw err;
82
+ }
83
+
84
+ try { chmodSync(filePath, FILE_MODE); } catch { /* best-effort */ }
85
+ return true;
86
+ }
87
+
88
+ function readJsonOrEmpty(filePath) {
89
+ if (!existsSync(filePath)) return {};
90
+ const raw = readFileSync(filePath, 'utf8');
91
+ if (raw.trim() === '') return {};
92
+ try {
93
+ return JSON.parse(raw);
94
+ } catch (err) {
95
+ throw new Error(`Failed to parse JSON at ${filePath}: ${err.message}`);
96
+ }
97
+ }
98
+
99
+ function commandMatchesAnyPattern(command, patterns) {
100
+ if (typeof command !== 'string') return false;
101
+ return patterns.some((p) => typeof p === 'string' && p.length > 0 && command.includes(p));
102
+ }
103
+
104
+ /**
105
+ * Merge a flat array-of-entries JSON config with command-pattern dedup.
106
+ *
107
+ * @param {object} opts
108
+ * @param {string} opts.filePath
109
+ * @param {string} opts.jsonPointer RFC 6901 pointer to the array.
110
+ * @param {object} opts.newEntry Entry to append after dedup.
111
+ * @param {string[]} opts.commandPatterns Substrings; existing entries with
112
+ * a matching `command` field are stripped.
113
+ * @returns {{ changed: boolean, prevEntriesRemoved: number }}
114
+ */
115
+ export function jsonMergeWithDedup({ filePath, jsonPointer, newEntry, commandPatterns }) {
116
+ if (!filePath || typeof filePath !== 'string') throw new Error('filePath is required');
117
+ if (typeof jsonPointer !== 'string') throw new Error('jsonPointer is required');
118
+ if (newEntry == null || typeof newEntry !== 'object') throw new Error('newEntry must be an object');
119
+ const patterns = Array.isArray(commandPatterns) ? commandPatterns : [];
120
+
121
+ const root = readJsonOrEmpty(filePath);
122
+ const tokens = parseJsonPointer(jsonPointer);
123
+ const { parent, key } = walkAndCreate(root, tokens);
124
+
125
+ const existing = parent[key];
126
+ let arr;
127
+ if (existing == null) {
128
+ arr = [];
129
+ } else if (Array.isArray(existing)) {
130
+ arr = existing;
131
+ } else {
132
+ throw new Error(`jsonMergeWithDedup: expected array at pointer ${jsonPointer}, got ${typeof existing}`);
133
+ }
134
+
135
+ const before = arr.length;
136
+ const filtered = arr.filter((entry) => {
137
+ if (entry == null || typeof entry !== 'object') return true;
138
+ return !commandMatchesAnyPattern(entry.command, patterns);
139
+ });
140
+ const prevEntriesRemoved = before - filtered.length;
141
+
142
+ filtered.push(newEntry);
143
+ parent[key] = filtered;
144
+
145
+ const serialized = JSON.stringify(root, null, 2) + '\n';
146
+ const changed = atomicWriteIfChanged(filePath, serialized);
147
+
148
+ return { changed, prevEntriesRemoved };
149
+ }
150
+
151
+ /**
152
+ * Merge a hook-group entry into Claude's nested settings.json shape.
153
+ *
154
+ * settings.hooks[eventName] = [
155
+ * { matcher?, description?, hooks: [{ type:'command', command, description }, ...] },
156
+ * ...
157
+ * ]
158
+ *
159
+ * Strips inner hooks[] entries by command-pattern, drops groups whose inner
160
+ * hooks[] becomes empty, then appends a fresh group for the new entry.
161
+ *
162
+ * @param {object} opts
163
+ * @param {string} opts.settingsPath
164
+ * @param {string} opts.eventName 'SessionStart' | 'UserPromptSubmit' | ...
165
+ * @param {string} [opts.matcher] Optional matcher string for the new group.
166
+ * @param {string} opts.command Command for the new inner hook entry.
167
+ * @param {string} [opts.description] Description (used at both group + inner levels).
168
+ * @param {string[]} opts.commandPatterns Substrings to strip prior entries.
169
+ * @returns {{ changed: boolean, prevGroupsRemoved: number }}
170
+ */
171
+ export function claudeHooksMerge({
172
+ settingsPath,
173
+ eventName,
174
+ matcher,
175
+ command,
176
+ description,
177
+ commandPatterns,
178
+ }) {
179
+ if (!settingsPath || typeof settingsPath !== 'string') throw new Error('settingsPath is required');
180
+ if (!eventName || typeof eventName !== 'string') throw new Error('eventName is required');
181
+ if (!command || typeof command !== 'string') throw new Error('command is required');
182
+ const patterns = Array.isArray(commandPatterns) ? commandPatterns : [];
183
+
184
+ const root = readJsonOrEmpty(settingsPath);
185
+ if (root.hooks == null || typeof root.hooks !== 'object' || Array.isArray(root.hooks)) {
186
+ root.hooks = {};
187
+ }
188
+
189
+ const existingGroups = Array.isArray(root.hooks[eventName]) ? root.hooks[eventName] : [];
190
+
191
+ let prevGroupsRemoved = 0;
192
+ const survivingGroups = [];
193
+ for (const group of existingGroups) {
194
+ if (group == null || typeof group !== 'object') {
195
+ survivingGroups.push(group);
196
+ continue;
197
+ }
198
+ const innerHooks = Array.isArray(group.hooks) ? group.hooks : [];
199
+ const filteredInner = innerHooks.filter((h) => {
200
+ if (h == null || typeof h !== 'object') return true;
201
+ return !commandMatchesAnyPattern(h.command, patterns);
202
+ });
203
+
204
+ if (filteredInner.length === 0 && innerHooks.length > 0) {
205
+ prevGroupsRemoved += 1;
206
+ continue;
207
+ }
208
+ survivingGroups.push({ ...group, hooks: filteredInner });
209
+ }
210
+
211
+ const newGroup = {
212
+ ...(matcher !== undefined ? { matcher } : {}),
213
+ ...(description !== undefined ? { description } : {}),
214
+ hooks: [
215
+ {
216
+ type: 'command',
217
+ command,
218
+ ...(description !== undefined ? { description } : {}),
219
+ },
220
+ ],
221
+ };
222
+ survivingGroups.push(newGroup);
223
+ root.hooks[eventName] = survivingGroups;
224
+
225
+ const serialized = JSON.stringify(root, null, 2) + '\n';
226
+ const changed = atomicWriteIfChanged(settingsPath, serialized);
227
+
228
+ return { changed, prevGroupsRemoved };
229
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * c4/mcpServer.mjs — register the `ghl-ai` MCP server across all three
3
+ * harnesses with the canonical MCP_URL_DEFAULT and Bearer auth.
4
+ *
5
+ * Per harness, the MCP config lives in a different file:
6
+ * - claude-web → ~/.claude/settings.json::mcpServers["ghl-ai"]
7
+ * - cursor-cloud → ~/.cursor/mcp.json::mcpServers["ghl-ai"] (separate file!)
8
+ * - codex-web → ~/.codex/config.toml::[mcp_servers.ghl-ai] (delegates to codexConfig)
9
+ *
10
+ * URL is overridable via env MCP_URL for staging environments. Bearer token
11
+ * is the harness's GitHub PAT (resolved upstream by secrets.mjs). Idempotent
12
+ * via deep-equality byte compare in the underlying writer.
13
+ *
14
+ * Contract: spec.md::§"c4/mcpServer.mjs", tasks.md::3.4.
15
+ */
16
+
17
+ import {
18
+ existsSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ renameSync,
22
+ chmodSync,
23
+ mkdirSync,
24
+ unlinkSync,
25
+ } from 'node:fs';
26
+ import { dirname, basename, join } from 'node:path';
27
+ import { ensureCodexMcpServer } from './codexConfig.mjs';
28
+
29
+ export const MCP_URL_DEFAULT =
30
+ 'https://services.leadconnectorhq.com/agentic-workspace/mcp';
31
+
32
+ const FILE_MODE = 0o600;
33
+
34
+ function resolveUrl(opts) {
35
+ return opts?.mcpUrl ?? process.env.MCP_URL ?? MCP_URL_DEFAULT;
36
+ }
37
+
38
+ function ensureDir(d) {
39
+ mkdirSync(d, { recursive: true });
40
+ }
41
+
42
+ function readJsonOrEmpty(filePath) {
43
+ if (!existsSync(filePath)) return {};
44
+ const raw = readFileSync(filePath, 'utf8');
45
+ if (raw.trim() === '') return {};
46
+ try {
47
+ return JSON.parse(raw);
48
+ } catch (err) {
49
+ throw new Error(`Failed to parse JSON at ${filePath}: ${err.message}`);
50
+ }
51
+ }
52
+
53
+ function atomicWriteIfChanged(filePath, serialized) {
54
+ if (existsSync(filePath)) {
55
+ const prev = readFileSync(filePath, 'utf8');
56
+ if (prev === serialized) return false;
57
+ }
58
+
59
+ ensureDir(dirname(filePath));
60
+
61
+ const tmp = join(
62
+ dirname(filePath),
63
+ `.${basename(filePath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
64
+ );
65
+
66
+ try {
67
+ writeFileSync(tmp, serialized, { mode: FILE_MODE });
68
+ renameSync(tmp, filePath);
69
+ } catch (err) {
70
+ if (existsSync(tmp)) {
71
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
72
+ }
73
+ throw err;
74
+ }
75
+
76
+ try { chmodSync(filePath, FILE_MODE); } catch { /* best-effort */ }
77
+ return true;
78
+ }
79
+
80
+ /**
81
+ * Mutate root.mcpServers["ghl-ai"] for Claude/Cursor JSON-shaped configs.
82
+ * Other entries in mcpServers are preserved verbatim.
83
+ */
84
+ function setGhlAiInJsonConfig({ filePath, url, token }) {
85
+ const root = readJsonOrEmpty(filePath);
86
+ if (
87
+ root.mcpServers == null ||
88
+ typeof root.mcpServers !== 'object' ||
89
+ Array.isArray(root.mcpServers)
90
+ ) {
91
+ root.mcpServers = {};
92
+ }
93
+ const prevHeaders = root.mcpServers['ghl-ai']?.headers;
94
+ root.mcpServers['ghl-ai'] = {
95
+ type: 'http',
96
+ url,
97
+ headers: {
98
+ ...(typeof prevHeaders === 'object' && prevHeaders !== null && !Array.isArray(prevHeaders)
99
+ ? prevHeaders
100
+ : {}),
101
+ Authorization: `Bearer ${token}`,
102
+ },
103
+ };
104
+
105
+ const serialized = JSON.stringify(root, null, 2) + '\n';
106
+ return atomicWriteIfChanged(filePath, serialized);
107
+ }
108
+
109
+ /**
110
+ * Register the ghl-ai MCP server in the harness-appropriate config file.
111
+ *
112
+ * @param {'claude-web'|'cursor-cloud'|'codex-web'} harness
113
+ * @param {string} home
114
+ * @param {string} token
115
+ * @param {{ mcpUrl?: string }} [opts]
116
+ * @returns {{ configPath: string, changed: boolean }}
117
+ */
118
+ export function registerGhlAiMcp(harness, home, token, opts = {}) {
119
+ if (!home || typeof home !== 'string') {
120
+ throw new Error('registerGhlAiMcp: home is required');
121
+ }
122
+ if (!token || typeof token !== 'string') {
123
+ throw new Error('registerGhlAiMcp: token is required');
124
+ }
125
+
126
+ const url = resolveUrl(opts);
127
+
128
+ if (harness === 'claude-web') {
129
+ const configPath = join(home, '.claude/settings.json');
130
+ const changed = setGhlAiInJsonConfig({ filePath: configPath, url, token });
131
+ return { configPath, changed };
132
+ }
133
+
134
+ if (harness === 'cursor-cloud') {
135
+ const configPath = join(home, '.cursor/mcp.json');
136
+ const changed = setGhlAiInJsonConfig({ filePath: configPath, url, token });
137
+ return { configPath, changed };
138
+ }
139
+
140
+ if (harness === 'codex-web') {
141
+ const configPath = join(home, '.codex/config.toml');
142
+ // codexConfig honors process.env.MCP_URL itself; if a caller passed
143
+ // opts.mcpUrl, propagate via env temporarily to keep the contract simple.
144
+ if (opts.mcpUrl !== undefined) {
145
+ const prev = process.env.MCP_URL;
146
+ process.env.MCP_URL = opts.mcpUrl;
147
+ try {
148
+ const { changed } = ensureCodexMcpServer(home, token);
149
+ return { configPath, changed };
150
+ } finally {
151
+ if (prev === undefined) delete process.env.MCP_URL;
152
+ else process.env.MCP_URL = prev;
153
+ }
154
+ }
155
+ const { changed } = ensureCodexMcpServer(home, token);
156
+ return { configPath, changed };
157
+ }
158
+
159
+ throw new Error(`registerGhlAiMcp: unsupported harness "${harness}"`);
160
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * c4/mcpSmokeProbe.mjs — best-effort live MCP Bearer probe (G5).
3
+ *
4
+ * Why: writing the MCP config (mcpServer.mjs) confirms the JSON shape, not
5
+ * that the server validates the Bearer header against this PAT's scopes. A
6
+ * user with a scope-limited PAT sees green install and red MCP calls at
7
+ * runtime. This probe surfaces that gap in the install log.
8
+ *
9
+ * Best-effort: NEVER throws on transport / response failures. Argument
10
+ * validation errors (missing url/token) DO throw because they are caller
11
+ * bugs, not server failures.
12
+ *
13
+ * Returns one of:
14
+ * - 'ok' 200 + valid JSON-RPC tools/list response
15
+ * - 'invalid-token' 401
16
+ * - 'unauthorized' 403 (PAT lacks scope)
17
+ * - 'unreachable' timeout / network error
18
+ * - 'unknown' 200 with unexpected body, 426 Upgrade, SSE handshake,
19
+ * generic non-2xx, or any unrecognized condition
20
+ *
21
+ * Contract: spec.md::§"c4/mcpSmokeProbe.mjs", tasks.md::3.8.
22
+ */
23
+
24
+ const DEFAULT_TIMEOUT_MS = 5000;
25
+
26
+ /**
27
+ * @typedef {object} McpSmokeResult
28
+ * @property {string} url
29
+ * @property {boolean} reachable HTTP response was received (any status).
30
+ * @property {number} [toolCount] Number of tools when authStatus === 'ok'.
31
+ * @property {'ok'|'invalid-token'|'unauthorized'|'unreachable'|'unknown'} authStatus
32
+ * @property {string} [error] Protocol-mismatch / status excerpt.
33
+ */
34
+
35
+ /**
36
+ * Probe an MCP HTTP endpoint with a JSON-RPC tools/list request.
37
+ *
38
+ * @param {object} opts
39
+ * @param {string} opts.url
40
+ * @param {string} opts.token
41
+ * @param {number} [opts.timeoutMs]
42
+ * @param {(input: string, init?: RequestInit) => Promise<Response>} [opts.fetchImpl]
43
+ * Optional fetch override; defaults to globalThis.fetch.
44
+ * @returns {Promise<McpSmokeResult>}
45
+ */
46
+ export async function probeMcpServer(opts) {
47
+ if (!opts || typeof opts !== 'object') {
48
+ throw new Error('probeMcpServer: opts object is required');
49
+ }
50
+ const { url, token } = opts;
51
+ if (!url || typeof url !== 'string') {
52
+ throw new Error('probeMcpServer: opts.url is required');
53
+ }
54
+ if (!token || typeof token !== 'string') {
55
+ throw new Error('probeMcpServer: opts.token is required');
56
+ }
57
+
58
+ const timeoutMs = typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0
59
+ ? opts.timeoutMs
60
+ : DEFAULT_TIMEOUT_MS;
61
+ const fetchImpl = typeof opts.fetchImpl === 'function'
62
+ ? opts.fetchImpl
63
+ : globalThis.fetch;
64
+ if (typeof fetchImpl !== 'function') {
65
+ return { url, reachable: false, authStatus: 'unreachable', error: 'fetch unavailable' };
66
+ }
67
+
68
+ const ac = new AbortController();
69
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
70
+
71
+ let response;
72
+ try {
73
+ response = await fetchImpl(url, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ Authorization: `Bearer ${token}`,
78
+ },
79
+ body: JSON.stringify({
80
+ jsonrpc: '2.0',
81
+ id: 1,
82
+ method: 'tools/list',
83
+ params: {},
84
+ }),
85
+ signal: ac.signal,
86
+ });
87
+ } catch (err) {
88
+ clearTimeout(timer);
89
+ return classifyTransportError(url, err);
90
+ }
91
+ clearTimeout(timer);
92
+
93
+ if (response == null || typeof response !== 'object') {
94
+ return { url, reachable: false, authStatus: 'unknown', error: 'no response object' };
95
+ }
96
+
97
+ return await classifyResponse(url, response);
98
+ }
99
+
100
+ /**
101
+ * Map a thrown transport error to a structured result.
102
+ */
103
+ function classifyTransportError(url, err) {
104
+ const name = err?.name ?? '';
105
+ const msg = err?.message ?? String(err);
106
+ if (name === 'AbortError') {
107
+ return { url, reachable: false, authStatus: 'unreachable', error: `timeout: ${msg}` };
108
+ }
109
+ // Most undici / native fetch network errors throw TypeError("fetch failed").
110
+ return { url, reachable: false, authStatus: 'unreachable', error: msg };
111
+ }
112
+
113
+ /**
114
+ * Map an HTTP response to a structured result.
115
+ */
116
+ async function classifyResponse(url, response) {
117
+ const status = typeof response.status === 'number' ? response.status : 0;
118
+ const contentType = readHeader(response, 'content-type') ?? '';
119
+
120
+ // Streamable-HTTP / SSE: documented fallback per spec §"Streamable-HTTP fallback path".
121
+ if (status === 426 || contentType.includes('text/event-stream')) {
122
+ return {
123
+ url,
124
+ reachable: true,
125
+ authStatus: 'unknown',
126
+ error: `streamable-http transport detected (status=${status}; content-type=${contentType || 'unknown'})`,
127
+ };
128
+ }
129
+
130
+ if (status === 401) return { url, reachable: true, authStatus: 'invalid-token' };
131
+ if (status === 403) return { url, reachable: true, authStatus: 'unauthorized' };
132
+
133
+ if (status >= 200 && status < 300) {
134
+ let bodyText = '';
135
+ try {
136
+ bodyText = await response.text();
137
+ } catch (err) {
138
+ return {
139
+ url,
140
+ reachable: true,
141
+ authStatus: 'unknown',
142
+ error: `body read failed: ${err?.message ?? String(err)}`,
143
+ };
144
+ }
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(bodyText);
148
+ } catch {
149
+ return {
150
+ url,
151
+ reachable: true,
152
+ authStatus: 'unknown',
153
+ error: `unexpected body shape (not JSON): ${truncate(bodyText, 80)}`,
154
+ };
155
+ }
156
+ const tools = parsed?.result?.tools;
157
+ if (!Array.isArray(tools)) {
158
+ return {
159
+ url,
160
+ reachable: true,
161
+ authStatus: 'unknown',
162
+ error: `unexpected body shape (missing result.tools)`,
163
+ };
164
+ }
165
+ return { url, reachable: true, authStatus: 'ok', toolCount: tools.length };
166
+ }
167
+
168
+ // Any other status is unknown — surface what we have for the operator.
169
+ return {
170
+ url,
171
+ reachable: true,
172
+ authStatus: 'unknown',
173
+ error: `status=${status}; content-type=${contentType || 'unknown'}`,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Read a header from a Response-like object regardless of whether headers is
179
+ * a Headers instance, a Map, or a plain object.
180
+ */
181
+ function readHeader(response, name) {
182
+ const target = name.toLowerCase();
183
+ const h = response.headers;
184
+ if (!h) return null;
185
+ if (typeof h.get === 'function') {
186
+ const v = h.get(target) ?? h.get(name);
187
+ return v == null ? null : String(v);
188
+ }
189
+ if (h instanceof Map) {
190
+ return h.get(target) ?? h.get(name) ?? null;
191
+ }
192
+ for (const k of Object.keys(h)) {
193
+ if (k.toLowerCase() === target) return String(h[k]);
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function truncate(s, n) {
199
+ if (typeof s !== 'string') return '';
200
+ return s.length > n ? `${s.slice(0, n)}…` : s;
201
+ }