@aiwg/cli 2026.8.3 → 2026.8.4

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.
@@ -60,6 +60,17 @@ const DEFAULT_REGISTRY = {
60
60
  servers: {},
61
61
  };
62
62
 
63
+ const ENV_REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
64
+
65
+ function validateCredentialReferences(def) {
66
+ for (const [header, envName] of Object.entries(def.headerEnv || {})) {
67
+ if (!header.trim()) throw new Error('MCP header-env header name must not be empty');
68
+ if (!ENV_REFERENCE_NAME.test(envName)) {
69
+ throw new Error(`Invalid MCP header environment variable reference "${envName}"`);
70
+ }
71
+ }
72
+ }
73
+
63
74
  export class McpServerRegistry {
64
75
  #configDir;
65
76
  #cache = null;
@@ -99,6 +110,7 @@ export class McpServerRegistry {
99
110
  }
100
111
 
101
112
  async add(def) {
113
+ validateCredentialReferences(def);
102
114
  const data = await this.load();
103
115
 
104
116
  if (data.servers[def.name]) {
@@ -133,12 +145,14 @@ export class McpServerRegistry {
133
145
  throw new Error(`Server "${name}" not found.`);
134
146
  }
135
147
 
136
- data.servers[name] = {
148
+ const next = {
137
149
  ...data.servers[name],
138
150
  ...updates,
139
151
  name,
140
152
  updatedAt: new Date().toISOString(),
141
153
  };
154
+ validateCredentialReferences(next);
155
+ data.servers[name] = next;
142
156
 
143
157
  await this.save();
144
158
  }
@@ -40,7 +40,7 @@ function usage() {
40
40
  return [
41
41
  "Usage: aiwg research-query <question> [--backend fortemi-core|local] [--graph <name>]",
42
42
  " [--depth quick|thorough] [--sources-only] [--max-sources N]",
43
- " [--json] [--save]",
43
+ " [--include-diagnostics] [--json] [--save]",
44
44
  ].join("\n");
45
45
  }
46
46
  function flagValue(args, flag, errorMessage) {
@@ -73,7 +73,12 @@ function stripFlags(args) {
73
73
  "--depth",
74
74
  "--max-sources",
75
75
  ]);
76
- const bareFlags = new Set(["--sources-only", "--json", "--save"]);
76
+ const bareFlags = new Set([
77
+ "--sources-only",
78
+ "--include-diagnostics",
79
+ "--json",
80
+ "--save",
81
+ ]);
77
82
  const question = [];
78
83
  for (let index = 0; index < args.length; index++) {
79
84
  const arg = args[index];
@@ -96,8 +101,7 @@ function parseArgs(args) {
96
101
  if (backend && backend !== "local" && backend !== "fortemi-core") {
97
102
  throw new Error("--backend must be local or fortemi-core");
98
103
  }
99
- const depth = (flagValue(args, "--depth", "--depth must be quick or thorough") ??
100
- "thorough");
104
+ const depth = (flagValue(args, "--depth", "--depth must be quick or thorough") ?? "thorough");
101
105
  if (depth !== "quick" && depth !== "thorough") {
102
106
  throw new Error("--depth must be quick or thorough");
103
107
  }
@@ -113,6 +117,7 @@ function parseArgs(args) {
113
117
  depth,
114
118
  maxSources,
115
119
  sourcesOnly: hasFlag(args, "--sources-only"),
120
+ includeDiagnostics: hasFlag(args, "--include-diagnostics"),
116
121
  json: hasFlag(args, "--json"),
117
122
  save: hasFlag(args, "--save"),
118
123
  };
@@ -140,18 +145,40 @@ function entryId(entry) {
140
145
  text.match(/\bPROF-[A-Z0-9-]+/i)?.[0]?.toUpperCase() ??
141
146
  path.basename(entry.path).replace(/\.[^.]+$/, ""));
142
147
  }
143
- function gradeFromText(text) {
144
- const normalized = text.toUpperCase();
145
- if (/\bVERY\s+LOW\b/.test(normalized))
148
+ function normalizedGrade(value) {
149
+ const normalized = value.trim().toUpperCase();
150
+ if (/^(VERY\s+LOW|D)$/.test(normalized))
146
151
  return "VERY LOW";
147
- if (/\bHIGH\b/.test(normalized))
152
+ if (/^(HIGH|A(?:-)?)$/.test(normalized))
148
153
  return "HIGH";
149
- if (/\bMODERATE\b/.test(normalized))
154
+ if (/^(MODERATE|B(?:-)?)$/.test(normalized))
150
155
  return "MODERATE";
151
- if (/\bLOW\b/.test(normalized))
156
+ if (/^(LOW|C(?:-)?)$/.test(normalized))
152
157
  return "LOW";
153
158
  return "UNKNOWN";
154
159
  }
160
+ /**
161
+ * Extract only an explicitly declared research grade. Generic severity words
162
+ * in scan reports must never become evidence-quality signals.
163
+ */
164
+ function gradeForEntry(entry, body) {
165
+ for (const tag of entry.tags) {
166
+ const tagged = tag.match(/^grade[-_: ](very[-_ ]low|high|moderate|low|[a-d](?:-)?)$/i);
167
+ if (tagged)
168
+ return normalizedGrade(tagged[1].replace(/[-_]/g, " "));
169
+ }
170
+ const text = [entry.summary, body].join("\n");
171
+ const declarations = [
172
+ /\bGRADE(?:\s+(?:quality|rating|level|assessment))?\s*[:=]\s*\*{0,2}\s*(VERY\s+LOW|HIGH|MODERATE|LOW|[A-D](?:-)?)\b/i,
173
+ /##\s+GRADE[^\n]*\n(?:[^\n]*\n){0,3}?[^\n]*?(?:overall|rating|level)?\s*[:=]\s*\*{0,2}\s*(VERY\s+LOW|HIGH|MODERATE|LOW|[A-D](?:-)?)\b/i,
174
+ ];
175
+ for (const declaration of declarations) {
176
+ const match = declaration.exec(text);
177
+ if (match)
178
+ return normalizedGrade(match[1]);
179
+ }
180
+ return "UNKNOWN";
181
+ }
155
182
  function relevance(score) {
156
183
  if (score >= 0.55)
157
184
  return "direct";
@@ -191,26 +218,58 @@ function isResearchEntry(entry) {
191
218
  entryPath.includes("/research/") ||
192
219
  entryPath.includes("/kb/"));
193
220
  }
221
+ /** Generated diagnostics are searchable only by explicit operator opt-in. */
222
+ function isDiagnosticEntry(entry) {
223
+ const type = entry.type.toLowerCase();
224
+ const entryPath = entry.path.toLowerCase().replaceAll("\\", "/");
225
+ const tags = entry.tags.map((tag) => tag.toLowerCase());
226
+ return (entryPath.includes("/.aiwg/research/quarantine/") ||
227
+ entryPath.includes("/research/quarantine/") ||
228
+ /(?:^|\/)no-ref-[^/]*artifact-scan\.md$/.test(entryPath) ||
229
+ /(?:llm|integrity|artifact)-scan\.md$/.test(entryPath) ||
230
+ /(?:integrity|artifact|llm)[-_ ]scan|quarantine|diagnostic/.test(type) ||
231
+ tags.some((tag) => /^(?:integrity|artifact|llm)[-_ ]scan$|^quarantine$|^diagnostic$/.test(tag)));
232
+ }
194
233
  function localEntries(cwd, graph) {
195
234
  const index = loadGraphIndexFile(cwd, "metadata.json", graph);
196
- return index ? Object.values(index.entries) : [];
235
+ return index ? Object.values(index.entries).map((entry) => ({ entry })) : [];
236
+ }
237
+ function cachedRecordBody(record) {
238
+ return [
239
+ record.search?.body,
240
+ record.text,
241
+ ...(record.chunks ?? []).map((chunk) => chunk.text),
242
+ ]
243
+ .filter((part) => Boolean(part))
244
+ .join("\n");
197
245
  }
198
246
  async function backendEntries(cwd, graph, backend) {
199
247
  if (backend === "fortemi-core") {
200
- const { loadFortemiCoreMetadataEntries } = await import("../artifacts/fortemi-core-query-adapter.js");
248
+ const { loadFortemiCoreExport, loadFortemiCoreMetadataEntries } = await import("../artifacts/fortemi-core-query-adapter.js");
201
249
  const loaded = loadFortemiCoreMetadataEntries(cwd, graph);
202
- return { entries: loaded.entries, hint: loaded.reason };
250
+ if (loaded.reason)
251
+ return { entries: [], hint: loaded.reason };
252
+ const exported = loadFortemiCoreExport(cwd, graph);
253
+ if (!exported.exported)
254
+ return { entries: [], hint: exported.reason };
255
+ const records = new Map(exported.exported.items.map((record) => [record.source.path, record]));
256
+ return {
257
+ entries: loaded.entries.map((entry) => {
258
+ const record = records.get(entry.path);
259
+ return {
260
+ entry,
261
+ cachedBody: record ? cachedRecordBody(record) : "",
262
+ };
263
+ }),
264
+ };
203
265
  }
204
266
  return { entries: localEntries(cwd, graph) };
205
267
  }
206
- function sourceForEntry(cwd, entry, question, depth) {
207
- const body = depth === "thorough" ? readEntryBody(cwd, entry.path) : "";
208
- const sourceText = [
209
- entry.title,
210
- entry.summary,
211
- entry.tags.join(" "),
212
- body,
213
- ].join("\n");
268
+ function sourceForEntry(cwd, candidate, question, depth) {
269
+ const { entry } = candidate;
270
+ const body = depth === "thorough"
271
+ ? (candidate.cachedBody ?? readEntryBody(cwd, entry.path))
272
+ : "";
214
273
  const score = scoreEntry(entry, question, body, depth);
215
274
  if (score <= 0)
216
275
  return null;
@@ -219,7 +278,7 @@ function sourceForEntry(cwd, entry, question, depth) {
219
278
  path: entry.path,
220
279
  title: entry.title,
221
280
  type: entry.type,
222
- grade: gradeFromText(sourceText),
281
+ grade: gradeForEntry(entry, body),
223
282
  relevance: relevance(score),
224
283
  score: Math.round(score * 1000) / 1000,
225
284
  summary: entry.summary,
@@ -236,13 +295,24 @@ export async function runResearchQuery(cwd, options) {
236
295
  throw new Error(loaded.hint);
237
296
  }
238
297
  const sources = loaded.entries
239
- .filter(isResearchEntry)
240
- .map((entry) => sourceForEntry(cwd, entry, options.question, depth))
298
+ .filter(({ entry }) => isResearchEntry(entry))
299
+ .filter(({ entry }) => options.includeDiagnostics || !isDiagnosticEntry(entry))
300
+ .map((candidate) => sourceForEntry(cwd, candidate, options.question, depth))
241
301
  .filter((source) => source !== null)
242
302
  .sort((left, right) => {
243
303
  const scoreCmp = right.score - left.score;
244
304
  if (scoreCmp !== 0)
245
305
  return scoreCmp;
306
+ const gradeOrder = [
307
+ "HIGH",
308
+ "MODERATE",
309
+ "LOW",
310
+ "VERY LOW",
311
+ "UNKNOWN",
312
+ ];
313
+ const gradeCmp = gradeOrder.indexOf(left.grade) - gradeOrder.indexOf(right.grade);
314
+ if (gradeCmp !== 0)
315
+ return gradeCmp;
246
316
  return left.path.localeCompare(right.path);
247
317
  })
248
318
  .slice(0, maxSources);
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Cross-orchestrator scheduling and admission for shared execution hosts.
3
+ *
4
+ * The store is the global serialization boundary. Multiple AIWG processes must
5
+ * use the same durable implementation; the in-memory store is for tests and
6
+ * single-process embedding only. Executor substrates report capacity and run
7
+ * admitted work, but do not own this policy.
8
+ *
9
+ * @implements #1566
10
+ */
11
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+ export class InMemoryAdmissionStore {
14
+ snapshot = { revision: 0, records: {} };
15
+ transact(mutate) {
16
+ const draft = structuredClone(this.snapshot);
17
+ const result = mutate(draft);
18
+ draft.revision += 1;
19
+ this.snapshot = draft;
20
+ return result;
21
+ }
22
+ read() {
23
+ return structuredClone(this.snapshot);
24
+ }
25
+ }
26
+ /** Durable cross-process store. The lock is deliberately non-blocking: a
27
+ * concurrent writer receives a conflict and retries through its control loop. */
28
+ export class FileAdmissionStore {
29
+ path;
30
+ constructor(path) {
31
+ this.path = path;
32
+ }
33
+ transact(mutate) {
34
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
35
+ const lockPath = `${this.path}.lock`;
36
+ let descriptor;
37
+ try {
38
+ descriptor = openSync(lockPath, 'wx', 0o600);
39
+ }
40
+ catch (error) {
41
+ if (error.code === 'EEXIST') {
42
+ throw new Error('shared-host admission store is busy; retry the request');
43
+ }
44
+ throw error;
45
+ }
46
+ try {
47
+ const snapshot = this.readUnsafe();
48
+ const result = mutate(snapshot);
49
+ snapshot.revision += 1;
50
+ const temporary = `${this.path}.${process.pid}.tmp`;
51
+ writeFileSync(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
52
+ renameSync(temporary, this.path);
53
+ return result;
54
+ }
55
+ finally {
56
+ closeSync(descriptor);
57
+ try {
58
+ unlinkSync(lockPath);
59
+ }
60
+ catch { /* best-effort lock cleanup */ }
61
+ }
62
+ }
63
+ read() {
64
+ return structuredClone(this.readUnsafe());
65
+ }
66
+ readUnsafe() {
67
+ if (!existsSync(this.path))
68
+ return { revision: 0, records: {} };
69
+ return JSON.parse(readFileSync(this.path, 'utf8'));
70
+ }
71
+ }
72
+ const terminalStates = new Set([
73
+ 'denied', 'cancelled', 'timed-out', 'preempted',
74
+ ]);
75
+ export class SharedHostScheduler {
76
+ store;
77
+ policy;
78
+ clock;
79
+ constructor(store, policy, clock = Date.now) {
80
+ this.store = store;
81
+ this.policy = policy;
82
+ this.clock = clock;
83
+ if (!Number.isInteger(policy.maxConcurrent) || policy.maxConcurrent < 1) {
84
+ throw new Error('maxConcurrent must be a positive integer');
85
+ }
86
+ if (policy.leaseTtlMs < 1 || policy.agingIntervalMs < 1) {
87
+ throw new Error('leaseTtlMs and agingIntervalMs must be positive');
88
+ }
89
+ }
90
+ submit(request) {
91
+ this.validateRequest(request);
92
+ return this.store.transact(snapshot => {
93
+ const existing = snapshot.records[request.requestId];
94
+ if (existing) {
95
+ if (!sameRequest(existing, request)) {
96
+ throw new Error(`request '${request.requestId}' conflicts with an existing admission`);
97
+ }
98
+ return existing;
99
+ }
100
+ snapshot.records[request.requestId] = {
101
+ ...structuredClone(request),
102
+ state: 'queued',
103
+ revision: 1,
104
+ reason: 'awaiting shared-host capacity',
105
+ };
106
+ this.reconcile(snapshot);
107
+ return structuredClone(snapshot.records[request.requestId]);
108
+ });
109
+ }
110
+ reconcileNow() {
111
+ return this.store.transact(snapshot => {
112
+ this.reconcile(snapshot);
113
+ return structuredClone(snapshot);
114
+ });
115
+ }
116
+ renew(requestId) {
117
+ return this.store.transact(snapshot => {
118
+ const record = this.required(snapshot, requestId);
119
+ if (record.state !== 'admitted')
120
+ throw new Error(`cannot renew ${record.state} admission`);
121
+ record.leaseExpiresAt = new Date(this.clock() + this.policy.leaseTtlMs).toISOString();
122
+ record.revision += 1;
123
+ record.reason = 'lease renewed';
124
+ return structuredClone(record);
125
+ });
126
+ }
127
+ release(requestId) {
128
+ return this.store.transact(snapshot => {
129
+ const record = this.required(snapshot, requestId);
130
+ delete snapshot.records[requestId];
131
+ if (record.state === 'admitted')
132
+ this.reconcile(snapshot);
133
+ return structuredClone(snapshot);
134
+ });
135
+ }
136
+ cancel(requestId) {
137
+ return this.store.transact(snapshot => {
138
+ const record = this.required(snapshot, requestId);
139
+ if (terminalStates.has(record.state))
140
+ return structuredClone(record);
141
+ record.state = 'cancelled';
142
+ record.reason = 'cancelled by orchestrator';
143
+ record.finishedAt = new Date(this.clock()).toISOString();
144
+ record.revision += 1;
145
+ this.reconcile(snapshot);
146
+ return structuredClone(record);
147
+ });
148
+ }
149
+ snapshot() {
150
+ return this.store.read();
151
+ }
152
+ reconcile(snapshot) {
153
+ const now = this.clock();
154
+ for (const record of Object.values(snapshot.records)) {
155
+ if (record.state === 'admitted' && Date.parse(record.leaseExpiresAt ?? '') <= now) {
156
+ record.state = 'timed-out';
157
+ record.reason = 'admission lease expired; capacity recovered';
158
+ record.finishedAt = new Date(now).toISOString();
159
+ record.revision += 1;
160
+ }
161
+ else if (record.state === 'queued' && Date.parse(record.submittedAt) + record.queueTimeoutMs <= now) {
162
+ record.state = 'timed-out';
163
+ record.reason = 'queue deadline elapsed';
164
+ record.finishedAt = new Date(now).toISOString();
165
+ record.revision += 1;
166
+ }
167
+ }
168
+ let queued = Object.values(snapshot.records)
169
+ .filter(record => record.state === 'queued')
170
+ .sort((a, b) => this.compare(a, b, now));
171
+ for (const candidate of queued) {
172
+ if (!this.hasCapacity(snapshot, candidate)) {
173
+ if (this.policy.allowPreemption)
174
+ this.tryPreempt(snapshot, candidate, now);
175
+ }
176
+ if (!this.hasCapacity(snapshot, candidate))
177
+ continue;
178
+ candidate.state = 'admitted';
179
+ candidate.reason = 'admitted by shared-host policy';
180
+ candidate.admittedAt = new Date(now).toISOString();
181
+ candidate.leaseExpiresAt = new Date(now + this.policy.leaseTtlMs).toISOString();
182
+ candidate.revision += 1;
183
+ }
184
+ queued = [];
185
+ }
186
+ tryPreempt(snapshot, candidate, now) {
187
+ const victims = Object.values(snapshot.records)
188
+ .filter(record => record.state === 'admitted' && record.preemptible === true)
189
+ .sort((a, b) => this.compare(b, a, now));
190
+ const victim = victims.find(record => {
191
+ if (this.effectivePriority(record, now) >= this.effectivePriority(candidate, now))
192
+ return false;
193
+ const priorState = record.state;
194
+ record.state = 'preempted';
195
+ const freesRequiredCapacity = this.hasCapacity(snapshot, candidate);
196
+ record.state = priorState;
197
+ return freesRequiredCapacity;
198
+ });
199
+ if (!victim)
200
+ return;
201
+ victim.state = 'preempted';
202
+ victim.reason = `preempted by higher-priority request '${candidate.requestId}'`;
203
+ victim.preemptedBy = candidate.requestId;
204
+ victim.finishedAt = new Date(now).toISOString();
205
+ victim.revision += 1;
206
+ }
207
+ hasCapacity(snapshot, candidate) {
208
+ const active = Object.values(snapshot.records).filter(record => record.state === 'admitted');
209
+ if (active.length >= this.policy.maxConcurrent)
210
+ return false;
211
+ if (!belowQuota(active, 'environment', candidate.environment, this.policy.environmentQuotas))
212
+ return false;
213
+ if (!belowQuota(active, 'provider', candidate.provider, this.policy.providerQuotas))
214
+ return false;
215
+ const runtimeQuotas = {
216
+ host: this.policy.defaultHostQuota ?? 1,
217
+ ...this.policy.runtimeQuotas,
218
+ };
219
+ return belowQuota(active, 'runtimeKind', candidate.runtimeKind, runtimeQuotas);
220
+ }
221
+ effectivePriority(record, now) {
222
+ const waited = Math.max(0, now - Date.parse(record.submittedAt));
223
+ return record.priority + Math.floor(waited / this.policy.agingIntervalMs);
224
+ }
225
+ compare(a, b, now) {
226
+ return this.effectivePriority(b, now) - this.effectivePriority(a, now)
227
+ || Date.parse(a.submittedAt) - Date.parse(b.submittedAt)
228
+ || a.requestId.localeCompare(b.requestId);
229
+ }
230
+ required(snapshot, requestId) {
231
+ const record = snapshot.records[requestId];
232
+ if (!record)
233
+ throw new Error(`unknown admission request '${requestId}'`);
234
+ return record;
235
+ }
236
+ validateRequest(request) {
237
+ if (!request.requestId || !request.orchestratorId || !request.environment || !request.provider) {
238
+ throw new Error('request identity, orchestrator, environment, and provider are required');
239
+ }
240
+ if (!Number.isFinite(request.priority))
241
+ throw new Error('priority must be finite');
242
+ if (!Number.isFinite(Date.parse(request.submittedAt)))
243
+ throw new Error('submittedAt must be a timestamp');
244
+ if (request.queueTimeoutMs < 1)
245
+ throw new Error('queueTimeoutMs must be positive');
246
+ }
247
+ }
248
+ function belowQuota(active, field, value, quotas) {
249
+ const quota = quotas?.[String(value)];
250
+ if (quota === undefined)
251
+ return true;
252
+ return active.filter(record => record[field] === value).length < quota;
253
+ }
254
+ function sameRequest(record, request) {
255
+ return record.orchestratorId === request.orchestratorId
256
+ && record.environment === request.environment
257
+ && record.provider === request.provider
258
+ && record.runtimeKind === request.runtimeKind;
259
+ }
260
+ //# sourceMappingURL=shared-host-scheduler.js.map
@@ -37,6 +37,58 @@
37
37
  * @issue #972
38
38
  */
39
39
  const DEFAULT_MCP_SERVER = 'fortemi';
40
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
41
+ export function resolveMcpRequestHeaders(server, environment = process.env) {
42
+ const headers = { ...(server.headers ?? {}) };
43
+ for (const [header, envName] of Object.entries(server.headerEnv ?? {})) {
44
+ if (!ENV_NAME.test(envName)) {
45
+ throw new Error(`storage(fortemi): invalid environment variable reference "${envName}"`);
46
+ }
47
+ const value = environment[envName];
48
+ if (!value) {
49
+ throw new Error(`storage(fortemi): required credential environment variable "${envName}" is not set`);
50
+ }
51
+ headers[header] = header.toLowerCase() === 'authorization' ? `Bearer ${value}` : value;
52
+ }
53
+ return headers;
54
+ }
55
+ export function validateRemoteMcpUrl(raw) {
56
+ let url;
57
+ try {
58
+ url = new URL(raw);
59
+ }
60
+ catch {
61
+ throw new Error(`storage(fortemi): invalid MCP server URL "${raw}"`);
62
+ }
63
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
64
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
65
+ throw new Error('storage(fortemi): remote MCP URLs must use HTTPS; HTTP is allowed only for loopback development');
66
+ }
67
+ return url;
68
+ }
69
+ export function unwrapMcpToolResult(result) {
70
+ if (!result || typeof result !== 'object')
71
+ return result;
72
+ const envelope = result;
73
+ if (envelope.isError) {
74
+ const detail = envelope.content
75
+ ?.filter((item) => item.type === 'text' && typeof item.text === 'string')
76
+ .map((item) => item.text)
77
+ .join('; ');
78
+ throw new Error(`storage(fortemi): MCP tool failed${detail ? `: ${detail}` : ''}`);
79
+ }
80
+ if (envelope.structuredContent !== undefined)
81
+ return envelope.structuredContent;
82
+ const text = envelope.content?.find((item) => item.type === 'text' && typeof item.text === 'string')?.text;
83
+ if (text === undefined)
84
+ return result;
85
+ try {
86
+ return JSON.parse(text);
87
+ }
88
+ catch {
89
+ return { content: text };
90
+ }
91
+ }
40
92
  export class FortemiAdapter {
41
93
  subsystem;
42
94
  mcpServer;
@@ -194,30 +246,60 @@ export class FortemiAdapter {
194
246
  * Implemented as a lazy import so tests that inject a stub never load
195
247
  * the SDK or touch the registry.
196
248
  */
197
- export const createDefaultMcpClient = async (serverName) => {
249
+ export const createDefaultMcpClient = async (serverName, registryOverride, environment = process.env) => {
198
250
  const { McpServerRegistry } = await import('../../mcp/registry.js');
199
- const registry = new McpServerRegistry();
251
+ const registry = registryOverride ?? new McpServerRegistry();
200
252
  const server = await registry.get(serverName);
201
253
  if (!server) {
202
254
  throw new Error(`storage(fortemi): MCP server "${serverName}" is not registered. ` +
203
255
  `Add it via "aiwg mcp add ${serverName} --command <cmd>" before using the fortemi backend.`);
204
256
  }
205
- if (server.type !== 'stdio') {
206
- throw new Error(`storage(fortemi): only stdio MCP servers are supported (got "${server.type}" for "${serverName}")`);
207
- }
208
- // Lazy import the SDK so tests that inject a stub don't pay the cost
257
+ // Lazy imports keep unit tests that inject a stub isolated from transports.
209
258
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
210
- const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
211
- const transport = new StdioClientTransport({
212
- command: server.command ?? '',
213
- args: server.args ?? [],
214
- env: server.env,
215
- });
259
+ let transport;
260
+ if (server.type === 'stdio') {
261
+ const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
262
+ transport = new StdioClientTransport({
263
+ command: server.command ?? '',
264
+ args: server.args ?? [],
265
+ env: server.env,
266
+ });
267
+ }
268
+ else {
269
+ if (!server.url) {
270
+ throw new Error(`storage(fortemi): MCP server "${serverName}" has no URL`);
271
+ }
272
+ const url = validateRemoteMcpUrl(server.url);
273
+ const headers = resolveMcpRequestHeaders(server, environment);
274
+ if (server.type === 'http') {
275
+ const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
276
+ transport = new StreamableHTTPClientTransport(url, {
277
+ requestInit: { headers },
278
+ });
279
+ }
280
+ else if (server.type === 'sse') {
281
+ const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
282
+ transport = new SSEClientTransport(url, {
283
+ requestInit: { headers },
284
+ eventSourceInit: {
285
+ fetch: async (input, init) => {
286
+ const merged = new Headers(init?.headers);
287
+ for (const [name, value] of Object.entries(headers))
288
+ merged.set(name, value);
289
+ return fetch(input, { ...init, headers: merged });
290
+ },
291
+ },
292
+ });
293
+ }
294
+ else {
295
+ throw new Error(`storage(fortemi): unsupported MCP transport "${String(server.type)}"`);
296
+ }
297
+ }
216
298
  const client = new Client({ name: 'aiwg-storage-fortemi-adapter', version: '1.0.0' }, { capabilities: {} });
217
299
  await client.connect(transport);
218
300
  return {
219
301
  async callTool(name, args) {
220
- return client.callTool({ name, arguments: args });
302
+ return unwrapMcpToolResult(await client.callTool({ name, arguments: args }));
221
303
  },
222
304
  async close() {
223
305
  await client.close();