@gtrabanco/pi-nan-provider 0.10.3 → 1.0.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.
@@ -23,10 +23,12 @@
23
23
  * back to the generated catalog so startup is never blocked.
24
24
  */
25
25
 
26
- import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
27
- import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
26
+ import type { ImageModel, Model } from "@earendil-works/pi-ai";
27
+ import type { ThinkingLevelMap, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
28
28
  import { GENERATED_CATALOG_META, NAN_GENERATED_MODELS } from "../scripts/models.generated.ts";
29
29
 
30
+ export { NAN_GENERATED_MODELS };
31
+
30
32
  export type { Model };
31
33
 
32
34
  /**
@@ -341,3 +343,146 @@ export async function fetchNanCompatibleModels(
341
343
  export function generatedCatalogMeta() {
342
344
  return GENERATED_CATALOG_META;
343
345
  }
346
+
347
+ // ── Stale catalog overlay (issue #19) ───────────────────────────────────────
348
+
349
+ /**
350
+ * Build a lookup map from model id to generated catalog entry. Used by
351
+ * `overlayGeneratedCapabilities` so the overlay is a single Map lookup.
352
+ */
353
+ export function buildGeneratedCatalogMap(
354
+ generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
355
+ ): Map<string, GeneratedModelEntry> {
356
+ return new Map(generated.map((entry) => [entry.id, entry]));
357
+ }
358
+
359
+ /**
360
+ * Pre-built lookup map for generated chat-catalog entries.
361
+ * Built once at module evaluation time so every read is a true O(1) Map.get.
362
+ * (The doc on `overlayGeneratedCapabilities` previously claimed O(1)
363
+ * `buildGeneratedCatalogMap` was called on every call — this fixes that.)
364
+ */
365
+ const _generatedCatalogMap: Map<string, GeneratedModelEntry> = buildGeneratedCatalogMap();
366
+
367
+ /**
368
+ * Overlay generated-catalog capability data onto a stored/restored model when
369
+ * the model id exists in the generated catalog (and the model type matches).
370
+ *
371
+ * Returns the generated catalog entry converted to a pi-ai Model via `toModel`
372
+ * when there is an exact id match with the same api (openai-completions).
373
+ * Image models, classifier models, or ids not in the catalog pass through
374
+ * unchanged — the persisted store decides which ids exist, but only the
375
+ * generated catalog decides capability data (issue #19).
376
+ *
377
+ * When `prebuilt` is provided it must be a Map<string, GeneratedModelEntry>
378
+ * keyed by model id for chat entries only. This parameter exists so the
379
+ * provider wrapper can pass a prebuilt map instead of rebuilding it every
380
+ * call (module-level `_generatedCatalogMap` is used when omitted).
381
+ *
382
+ * This function is pure, stateless, and O(1) — a single Map.get lookup.
383
+ * It is called at read time from the provider wrapper so every consumer
384
+ * (pi, the model registry, downstream code) sees the latest capabilities.
385
+ */
386
+ export function overlayGeneratedCapabilities(
387
+ model: Model<string> | ImageModel<string>,
388
+ generatedOrPrebuilt?: readonly GeneratedModelEntry[] | Map<string, GeneratedModelEntry>,
389
+ ): Model<string> | ImageModel<string> {
390
+ if (!("reasoning" in model && "contextWindow" in model)) {
391
+ // ImageModel and ClassifierModel do not have reasoning/contextWindow.
392
+ return model;
393
+ }
394
+ // Resolve the lookup map: prebuilt map > array > module-level singleton.
395
+ const byId: Map<string, GeneratedModelEntry> =
396
+ generatedOrPrebuilt instanceof Map
397
+ ? generatedOrPrebuilt
398
+ : Array.isArray(generatedOrPrebuilt)
399
+ ? buildGeneratedCatalogMap(generatedOrPrebuilt)
400
+ : _generatedCatalogMap;
401
+ const entry = byId.get(model.id);
402
+ // Only match when the generated catalog has this id AND the api is the
403
+ // chat api (openai-completions). Image models use a different api and
404
+ // a separate static list — they are not in this catalog.
405
+ if (entry && model.api === NAN_COMPAT_API) {
406
+ return toModel(entry, { providerId: model.provider, baseUrl: model.baseUrl });
407
+ }
408
+ return model;
409
+ }
410
+
411
+ // ── Combined overlay for chat + image models (GAP 1: getAllModels wrapping) ─
412
+
413
+ /**
414
+ * Internal representation of an overlay entry: either a generated chat entry
415
+ * (GeneratedModelEntry → toModel result) or a baseline image model
416
+ * (ImageModel). Keys are model ids that are unique across chat/image
417
+ * namespaces.
418
+ */
419
+ export interface OverlayEntry {
420
+ /** For chat models: the pi-ai Model produced by toModel(entry, source). */
421
+ chat?: Model<string>;
422
+ /** For image models: the ImageModel from baselineImageModels(source). */
423
+ image?: ImageModel<string>;
424
+ }
425
+
426
+ /**
427
+ * Build a combined overlay map keyed by model id. The overlay contains:
428
+ * - For every generated chat model id: the toModel() result (chat entry).
429
+ * - For every baseline image model: the ImageModel itself (image entry).
430
+ *
431
+ * This map is built once at factory time (in withStaleCatalogOverlay) and
432
+ * used by both `getModels` and `getAllModels` wrappers so every read path
433
+ * receives generated-catalog capability data regardless of whether the
434
+ * model is chat or image (issue #19, GAP 1).
435
+ */
436
+ export function buildOverlayMap(
437
+ source: CatalogSource,
438
+ generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
439
+ imageModels?: ImageModel<string>[],
440
+ ): Map<string, OverlayEntry> {
441
+ const overlay = new Map<string, OverlayEntry>();
442
+ // Chat models from the generated catalog.
443
+ for (const entry of generated) {
444
+ overlay.set(entry.id, { chat: toModel(entry, source) });
445
+ }
446
+ // Image models from the baseline (these live under a different api id
447
+ // so they cannot coexist as chat entries — ids are unique across types).
448
+ if (imageModels) {
449
+ for (const img of imageModels) {
450
+ overlay.set(img.id, { image: img });
451
+ }
452
+ }
453
+ return overlay;
454
+ }
455
+
456
+ /**
457
+ * Internal model-like shape used by applyOverlay: every model type has id, api,
458
+ * and (for images) a `type` discriminator.
459
+ */
460
+ interface _ModelLike {
461
+ id: string;
462
+ api: string;
463
+ type?: string;
464
+ }
465
+
466
+ /**
467
+ * Apply the combined overlay (chat + image) to a single model. Looks up the
468
+ * id in the provided map and returns the overlaid entry when present.
469
+ * Image models (type === "image") receive their ImageModel overlay; chat
470
+ * models receive their generated-catalog entry via `overlayGeneratedCapabilities`.
471
+ * ClassifierModel and other model types pass through unchanged.
472
+ */
473
+ export function applyOverlay<T extends _ModelLike>(
474
+ model: T,
475
+ overlay: Map<string, OverlayEntry>,
476
+ ): T {
477
+ const entry = overlay.get(model.id);
478
+ if (!entry) return model;
479
+ // Image models: return the baseline ImageModel overlay.
480
+ if ((model as _ModelLike).type === "image") {
481
+ return (entry.image ?? model) as T;
482
+ }
483
+ // Chat models: use the standard overlay function (still O(1)).
484
+ if (entry.chat && model.api === NAN_COMPAT_API) {
485
+ return (entry.chat as unknown as T) ?? model;
486
+ }
487
+ return model;
488
+ }
package/src/images.ts ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * NaN image generation API — custom API id, model definitions, and a
3
+ * ProviderImages implementation talking to the OpenAI-compatible image
4
+ * endpoints at api.nan.builders.
5
+ *
6
+ * NaN exposes two OpenAI-compatible image endpoints:
7
+ *
8
+ * POST /v1/images/generations — text-to-image (JSON body)
9
+ * POST /v1/images/edits — image-to-image (multipart/form-data)
10
+ *
11
+ * Both require the inference-tier membership (403 otherwise) and share a
12
+ * 20 req/min / 100 req/month quota separate from the chat token budget
13
+ * (https://nan.builders/docs/models and https://nan.builders/openapi.json,
14
+ * checked 2026-10-01).
15
+ *
16
+ * We register a custom API id "nan-images" so pi's model registry can route
17
+ * generateImages calls through our implementation (createNanImagesApi), and
18
+ * register the resulting ProviderImages via createProvider({ images: ... }).
19
+ *
20
+ * Style note (per repo conventions): only type-only imports from the bare
21
+ * root "@earendil-works/pi-ai". No subpath imports — pi's extension loader
22
+ * aliases subpaths and the whole extension fails to load. No imageErrorResult
23
+ * import either: pi-ai exposes it only under the forbidden
24
+ * "@earendil-works/pi-ai/utils/..." subpath, so error results are built here.
25
+ */
26
+
27
+ import type {
28
+ AssistantImages,
29
+ ImageModel,
30
+ ProviderImages,
31
+ } from "@earendil-works/pi-ai";
32
+
33
+ // ── Custom API id ──────────────────────────────────────────────────────────
34
+
35
+ /** Custom image-api id for NaN image generation. */
36
+ export const NAN_IMAGE_API = "nan-images";
37
+
38
+ // ── Model definitions ─────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Static image-model definition carrying input/output modalities and
42
+ * provenance notes. Kept local (not in the generated chat catalog) because
43
+ * these are image endpoints, not chat models: NON_CHAT_MODEL_IDS still keeps
44
+ * them out of the chat catalog, and this list is the image-side counterpart.
45
+ */
46
+ export interface NanImageModelDefinition {
47
+ /** Stable model id, e.g. "flux-2-klein". */
48
+ id: string;
49
+ /** Human-readable model name, e.g. "FLUX 2 Klein". */
50
+ name: string;
51
+ /** Input modalities the model accepts. */
52
+ input: ("text" | "image")[];
53
+ /** Output modalities the model produces. */
54
+ output: ("text" | "image")[];
55
+ /** Provenance notes citing sources for every capability claim. */
56
+ notes: string[];
57
+ }
58
+
59
+ /**
60
+ * All NaN image models known to this provider. Two entries, ordered:
61
+ * flux-2-klein (text+image input), qwen-image-2.1 (text-only).
62
+ */
63
+ export const NAN_IMAGE_MODELS: readonly NanImageModelDefinition[] = [
64
+ {
65
+ id: "flux-2-klein",
66
+ name: "FLUX 2 Klein",
67
+ input: ["text", "image"],
68
+ output: ["image"],
69
+ notes: [
70
+ "Image generation endpoint (text-to-image and image-to-image). " +
71
+ "Documented in https://nan.builders/docs/models and " +
72
+ "https://nan.builders/openapi.json (checked 2026-10-01): POST " +
73
+ "/v1/images/generations and POST /v1/images/edits (1-4 reference " +
74
+ "images; mask unsupported). Requires inference-tier membership " +
75
+ "(403 otherwise); image endpoints are rate-limited separately " +
76
+ "from chat (20 req/min, 100 req/month shared with qwen-image-2.1) " +
77
+ "and do not consume the chat token budget. models.dev provider " +
78
+ "nan does not list flux-2-klein (checked 2026-10-01), so this is " +
79
+ "a manual entry; per-token cost is 0 because NaN bills image " +
80
+ "generation by request quota on the same membership model as chat.",
81
+ ],
82
+ },
83
+ {
84
+ id: "qwen-image-2.1",
85
+ name: "Qwen-Image-2.1",
86
+ input: ["text"],
87
+ output: ["image"],
88
+ notes: [
89
+ "Image generation endpoint (text-to-image only). Documented in " +
90
+ "https://nan.builders/docs/models and " +
91
+ "https://nan.builders/openapi.json (checked 2026-10-01): POST " +
92
+ "/v1/images/generations. models.dev provider nan lists it " +
93
+ "(checked 2026-10-01) with cost 0/0 and limit.output 0 (it " +
94
+ "returns image bytes, not tokens). Same inference-tier " +
95
+ "requirement and 100 req/month quota shared with " +
96
+ "flux-2-klein; image-to-image (/images/edits) is not documented " +
97
+ "for this model.",
98
+ ],
99
+ },
100
+ ] as const;
101
+
102
+ // ── Baseline models ────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Structural source shape (same as fetch-models.ts CatalogSource) declared
106
+ * locally so this module has zero circular dependency on fetch-models.
107
+ */
108
+ interface ImageCatalogSource {
109
+ /** Provider id as registered in pi, e.g. "nan". */
110
+ providerId: string;
111
+ /** OpenAI-compatible base URL including version path. */
112
+ baseUrl: string;
113
+ }
114
+
115
+ /**
116
+ * Map every entry in NAN_IMAGE_MODELS to a pi-ai ImageModel for our custom
117
+ * API id. Per-token cost is 0: NaN bills image generation by request quota
118
+ * on the membership model, the same convention the generated chat catalog
119
+ * uses for NaN's quota-based pricing.
120
+ */
121
+ export function baselineImageModels(
122
+ source: ImageCatalogSource,
123
+ ): ImageModel<typeof NAN_IMAGE_API>[] {
124
+ return NAN_IMAGE_MODELS.map(
125
+ (model): ImageModel<typeof NAN_IMAGE_API> => ({
126
+ id: model.id,
127
+ name: model.name,
128
+ type: "image",
129
+ api: NAN_IMAGE_API,
130
+ provider: source.providerId,
131
+ baseUrl: source.baseUrl,
132
+ input: [...model.input],
133
+ output: [...model.output],
134
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
135
+ }),
136
+ );
137
+ }
138
+
139
+ // ── API implementation ─────────────────────────────────────────────────────
140
+
141
+ /** Injectable options for createNanImagesApi (fetchImpl is for tests). */
142
+ export interface CreateNanImagesApiOptions {
143
+ /** Injectable fetch for tests; defaults to globalThis.fetch. */
144
+ fetchImpl?: typeof fetch;
145
+ /** Default per-request timeout in ms when the caller does not supply one. */
146
+ timeoutMs?: number;
147
+ }
148
+
149
+ /**
150
+ * Build a ProviderImages implementation for NaN's OpenAI-compatible image
151
+ * endpoints (POST /v1/images/generations and POST /v1/images/edits).
152
+ *
153
+ * We always request b64_json (not "url") so the returned images carry their
154
+ * base64 data directly — no separate download step is needed. NaN returns
155
+ * raw base64 with no MIME type, so the output type is sniffed from the
156
+ * decoded magic bytes.
157
+ *
158
+ * Contract: never throws. Auth failures, invalid input, transport errors and
159
+ * non-OK responses all surface as an AssistantImages with stopReason
160
+ * "error" (or "aborted" when the caller's signal aborted).
161
+ */
162
+ export function createNanImagesApi(
163
+ options?: CreateNanImagesApiOptions,
164
+ ): ProviderImages {
165
+ const fetchImpl = options?.fetchImpl ?? globalThis.fetch;
166
+
167
+ return {
168
+ async generateImages(model, context, requestOptions) {
169
+ const base: AssistantImages = {
170
+ api: model.api,
171
+ provider: model.provider,
172
+ model: model.id,
173
+ output: [],
174
+ stopReason: "stop",
175
+ timestamp: Date.now(),
176
+ };
177
+
178
+ try {
179
+ // ── Auth ────────────────────────────────────────────────
180
+ const apiKey = requestOptions?.apiKey;
181
+ if (!apiKey) {
182
+ throw new Error("No API key for provider: " + model.provider);
183
+ }
184
+
185
+ // ── Input extraction ────────────────────────────────────
186
+ const text = context.input
187
+ .filter((block) => block.type === "text")
188
+ .map((block) => block.text)
189
+ .join("\n")
190
+ .trim();
191
+ const referenceImages = context.input.filter(
192
+ (block) => block.type === "image",
193
+ );
194
+
195
+ if (text.length === 0 && referenceImages.length === 0) {
196
+ throw new Error(
197
+ "Image generation requires a text prompt or a reference image",
198
+ );
199
+ }
200
+
201
+ // Reference images need a model that accepts image input
202
+ // (flux-2-klein); qwen-image-2.1 is text-to-image only.
203
+ if (
204
+ referenceImages.length > 0 &&
205
+ !model.input.includes("image")
206
+ ) {
207
+ throw new Error(
208
+ "Model " + model.id + " does not accept image input",
209
+ );
210
+ }
211
+
212
+ // ── Endpoint selection ──────────────────────────────────
213
+ const endpoint =
214
+ referenceImages.length > 0
215
+ ? "/images/edits"
216
+ : "/images/generations";
217
+ const url = model.baseUrl.replace(/\/+$/, "") + endpoint;
218
+
219
+ // ── Headers ─────────────────────────────────────────────
220
+ // Merge model headers with caller headers, dropping null
221
+ // values (ProviderHeaders uses null to suppress a default).
222
+ // Bearer auth is applied last so caller values cannot
223
+ // override the provider credential.
224
+ const headers: Record<string, string> = {};
225
+ for (const [key, value] of Object.entries({
226
+ ...model.headers,
227
+ ...requestOptions?.headers,
228
+ })) {
229
+ if (typeof value === "string") headers[key] = value;
230
+ }
231
+ headers.Authorization = "Bearer " + apiKey;
232
+
233
+ // ── Optional metadata passthrough ───────────────────────
234
+ // n, size, seed and guidance are NaN extensions accepted on
235
+ // both endpoints; pass them through only with the right type.
236
+ const metadata = (requestOptions?.metadata ?? {}) as Record<
237
+ string,
238
+ unknown
239
+ >;
240
+ const optionalFields: Record<string, string | number> = {};
241
+ if (typeof metadata.n === "number") optionalFields.n = metadata.n;
242
+ if (typeof metadata.size === "string") optionalFields.size = metadata.size;
243
+ if (typeof metadata.seed === "number") optionalFields.seed = metadata.seed;
244
+ if (typeof metadata.guidance === "number") {
245
+ optionalFields.guidance = metadata.guidance;
246
+ }
247
+
248
+ // ── Request body ────────────────────────────────────────
249
+ let body: FormData | string;
250
+ if (referenceImages.length > 0) {
251
+ // Image-to-image: multipart/form-data. Content-Type is
252
+ // deliberately NOT set — fetch adds the boundary.
253
+ const form = new FormData();
254
+ form.set("model", model.id);
255
+ form.set("prompt", text);
256
+ form.set("response_format", "b64_json");
257
+ for (const [key, value] of Object.entries(optionalFields)) {
258
+ form.set(key, String(value));
259
+ }
260
+ for (const image of referenceImages) {
261
+ // Decode base64 with atob (no Buffer: the extension
262
+ // must not assume a Node-only global).
263
+ const bytes = Uint8Array.from(atob(image.data), (char) =>
264
+ char.charCodeAt(0),
265
+ );
266
+ form.append(
267
+ "image[]",
268
+ new Blob([bytes], {
269
+ type: image.mimeType || "image/png",
270
+ }),
271
+ "reference.png",
272
+ );
273
+ }
274
+ body = form;
275
+ } else {
276
+ headers["Content-Type"] = "application/json";
277
+ // fetch does not serialize plain objects: build the JSON text here.
278
+ body = JSON.stringify({
279
+ model: model.id,
280
+ prompt: text,
281
+ response_format: "b64_json",
282
+ ...optionalFields,
283
+ });
284
+ }
285
+
286
+ // ── Signal (timeout + caller abort) ─────────────────────
287
+ const timeoutMs = requestOptions?.timeoutMs ?? options?.timeoutMs;
288
+ let signal: AbortSignal | undefined;
289
+ if (timeoutMs) {
290
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
291
+ signal = requestOptions?.signal
292
+ ? AbortSignal.any([timeoutSignal, requestOptions.signal])
293
+ : timeoutSignal;
294
+ } else if (requestOptions?.signal) {
295
+ signal = requestOptions.signal;
296
+ }
297
+
298
+ // ── HTTP call ───────────────────────────────────────────
299
+ const response = await fetchImpl(url, {
300
+ method: "POST",
301
+ headers,
302
+ body: body as RequestInit["body"],
303
+ ...(signal ? { signal } : {}),
304
+ });
305
+
306
+ if (!response.ok) {
307
+ // Best-effort error body: NaN usually returns
308
+ // {"error":{"message":"..."}} (403 for a missing
309
+ // inference tier, 429 for the quota).
310
+ let bodyText = "";
311
+ try {
312
+ bodyText = await response.text();
313
+ } catch {
314
+ // Ignore read errors — best effort only.
315
+ }
316
+ throw new Error(
317
+ "NaN image request failed: HTTP " +
318
+ response.status +
319
+ (bodyText ? " " + bodyText : ""),
320
+ );
321
+ }
322
+
323
+ // ── Parse response ──────────────────────────────────────
324
+ const payload = (await response.json()) as {
325
+ data?: Array<{ b64_json?: string }>;
326
+ };
327
+ for (const item of payload.data ?? []) {
328
+ if (
329
+ typeof item.b64_json === "string" &&
330
+ item.b64_json.length > 0
331
+ ) {
332
+ base.output.push({
333
+ type: "image",
334
+ data: item.b64_json,
335
+ mimeType: sniffImageMimeType(item.b64_json),
336
+ });
337
+ }
338
+ }
339
+ if (base.output.length === 0) {
340
+ throw new Error("NaN image response contained no image data");
341
+ }
342
+
343
+ return base;
344
+ } catch (error) {
345
+ base.stopReason =
346
+ requestOptions?.signal?.aborted === true ? "aborted" : "error";
347
+ base.errorMessage =
348
+ error instanceof Error ? error.message : String(error);
349
+ return base;
350
+ }
351
+ },
352
+ };
353
+ }
354
+
355
+ // ── MIME-type sniffing ─────────────────────────────────────────────────────
356
+
357
+ /**
358
+ * Sniff the MIME type from a base64-encoded image string by inspecting the
359
+ * leading magic bytes.
360
+ *
361
+ * PNG: 89 50 4E 47 → "image/png"
362
+ * JPEG: FF D8 FF → "image/jpeg"
363
+ * WebP: RIFF....WEBP → "image/webp"
364
+ *
365
+ * NaN returns raw base64 without a MIME type, so we infer it from the
366
+ * signature instead of guessing. Falls back to "image/png" and never throws.
367
+ */
368
+ function sniffImageMimeType(base64: string): string {
369
+ try {
370
+ // Only the first 16 bytes are needed to identify PNG/JPEG/WebP.
371
+ const decoded = atob(base64.slice(0, 16));
372
+ const bytes = new Uint8Array(decoded.length);
373
+ for (let i = 0; i < decoded.length; i++) {
374
+ bytes[i] = decoded.charCodeAt(i);
375
+ }
376
+
377
+ if (
378
+ bytes[0] === 0x89 &&
379
+ bytes[1] === 0x50 &&
380
+ bytes[2] === 0x4e &&
381
+ bytes[3] === 0x47
382
+ ) {
383
+ return "image/png";
384
+ }
385
+ if (
386
+ bytes[0] === 0xff &&
387
+ bytes[1] === 0xd8 &&
388
+ bytes[2] === 0xff
389
+ ) {
390
+ return "image/jpeg";
391
+ }
392
+ if (
393
+ bytes[0] === 0x52 &&
394
+ bytes[1] === 0x49 &&
395
+ bytes[2] === 0x46 &&
396
+ bytes[3] === 0x46 &&
397
+ bytes[8] === 0x57 &&
398
+ bytes[9] === 0x45 &&
399
+ bytes[10] === 0x42 &&
400
+ bytes[11] === 0x50
401
+ ) {
402
+ return "image/webp";
403
+ }
404
+ return "image/png";
405
+ } catch {
406
+ return "image/png";
407
+ }
408
+ }
package/src/index.ts CHANGED
@@ -17,11 +17,12 @@
17
17
  *
18
18
  * 2. MCP servers via pi's native registerMcpServer (pi >=0.99):
19
19
  * - `nan-search` — the official NaN remote MCP server
20
- * (https://api.nan.builders/mcp), exposing `mcp__nan-search__web_search`.
20
+ * (https://api.nan.builders/mcp), exposing `mcp__nan_search__web_search`
21
+ * (pi >=0.99.2; 0.99.0-0.99.1 kept the hyphen: `mcp__nan-search__web_search`).
21
22
  * Default: enabled. NAN_MCP_TOOLS=0 to disable.
22
23
  * - `nan-media` — the community stdio media server
23
24
  * (flux-2-klein / kokoro / whisper), exposing
24
- * `mcp__nan-media__generate_image` etc. Default: enabled,
25
+ * `mcp__nan_media__generate_image` etc. Default: enabled,
25
26
  * NAN_MEDIA_MCP=0 to disable.
26
27
  *
27
28
  * Both are session-scoped (visible in /mcp with source "extension").
@@ -109,6 +110,7 @@ async function registerWebSearchMcpServer(pi: ExtensionAPI, apiKey: string | und
109
110
  url: "https://api.nan.builders/mcp",
110
111
  headers: { Authorization: `Bearer ${apiKey}` },
111
112
  exposure: "direct",
113
+ description: "Web search through NaN's gateway (official NaN MCP server)",
112
114
  });
113
115
  return true;
114
116
  }
@@ -130,6 +132,7 @@ function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): b
130
132
  args: mediaArgs.slice(1),
131
133
  env: { [NAN_API_KEY_ENV]: apiKey },
132
134
  exposure: "direct",
135
+ description: "NaN media tools: image generation/editing, text-to-speech and speech-to-text via the community nan-mcp-server",
133
136
  timeout: mediaMcpTimeoutSec(),
134
137
  });
135
138
  return true;