@gtrabanco/pi-nan-provider 0.8.0 → 0.10.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.
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @gtrabanco/pi-nan-provider — NaN Builders provider + MCP bridges for pi.
2
+ * @gtrabanco/pi-nan-provider — NaN Builders provider + native MCP servers for pi.
3
3
  *
4
4
  * What this extension registers:
5
5
  *
@@ -15,14 +15,17 @@
15
15
  * 0.84 alike), and the openai-completions streaming implementation is
16
16
  * resolved dynamically — see provider-factory.ts for why.
17
17
  *
18
- * 2. MCP tools over pi's registerTool (pi intentionally has no MCP client):
19
- * - `nan_web_search` via NaN's official remote MCP server
20
- * (https://api.nan.builders/mcp) — on by default, NAN_MCP_TOOLS=0 to
21
- * disable.
22
- * - Media tools bridging the optional community `nan-mcp-server`
23
- * (stdio, spawned per call) — on by default, NAN_MEDIA_MCP=0 to
24
- * disable. The server spawns per tool call, so nothing runs unless
25
- * audio/image/transcription is actually invoked.
18
+ * 2. MCP servers via pi's native registerMcpServer (pi >=0.99):
19
+ * - `nan-search` — the official NaN remote MCP server
20
+ * (https://api.nan.builders/mcp), exposing `mcp__nan-search__web_search`.
21
+ * Default: enabled. NAN_MCP_TOOLS=0 to disable.
22
+ * - `nan-media` — the community stdio media server
23
+ * (flux-2-klein / kokoro / whisper), exposing
24
+ * `mcp__nan-media__generate_image` etc. Default: enabled,
25
+ * NAN_MEDIA_MCP=0 to disable.
26
+ *
27
+ * Both are session-scoped (visible in /mcp with source "extension").
28
+ * Exposure: `direct` — tools are declared to the model like built-ins.
26
29
  */
27
30
 
28
31
  import type { ContextEvent, ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
@@ -34,10 +37,10 @@ import {
34
37
  stripCrossModelThinking,
35
38
  } from "./cross-model-thinking-guard.ts";
36
39
  import { baselineModels } from "./fetch-models.ts";
37
- import { createNanWebSearchTool, webSearchBridgeEnabled, NAN_API_KEY_ENV } from "./mcp/nan-search.ts";
38
- import { createNanMediaTools, mediaMcpEnabled } from "./mcp/nan-media.ts";
39
40
  import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
40
41
  import { PROVIDERS } from "./providers.ts";
42
+ import { NAN_API_KEY_ENV, mcpToolsDisabled, mcpToolsEnvExplicit, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, NAN_MCP_TOOLS_ENV, webSearchBridgeEnabled } from "./mcp/api-key.ts";
43
+ import { mediaMcpCommand, mediaMcpEnabled, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
41
44
 
42
45
  /**
43
46
  * Register a provider on any pi version: the native full-Provider overload
@@ -80,45 +83,78 @@ async function registerProviderCompat(
80
83
  pi.registerProvider(config.id, legacy);
81
84
  }
82
85
 
86
+ // ── Native MCP registration ──────────────────────────────────────────────
87
+
83
88
  /**
84
- * Register MCP-bridged tools when the runtime supports them. Both bridges
85
- * are enabled by default and lazy (nothing runs until a tool is invoked);
86
- * `/nan-mcp` (and the NAN_MCP_TOOLS / NAN_MEDIA_MCP env vars) toggle them.
87
- * Old pi versions without registerTool/registerCommand skip gracefully.
88
- *
89
- * Registration is tracked in `registeredToolNames` so `/nan-mcp enable` can
90
- * add tools mid-session without double-registering.
89
+ * Guard message shown when pi lacks native MCP support (pi <0.99 despite peers).
90
+ * Matches the loud-skip style of existing guards (pi-ai, registerTool).
91
91
  */
92
- function registerMcpToolsCompat(pi: ExtensionAPI): void {
93
- if (typeof pi.registerTool !== "function") return;
94
- const registeredToolNames = new Set<string>();
95
- const registerSearchTool = () => {
96
- const search = createNanWebSearchTool();
97
- if (registeredToolNames.has(search.name)) return;
98
- registeredToolNames.add(search.name);
99
- pi.registerTool(search);
100
- };
101
- const registerMediaTools = () => {
102
- for (const tool of createNanMediaTools()) {
103
- if (registeredToolNames.has(tool.name)) continue;
104
- registeredToolNames.add(tool.name);
105
- pi.registerTool(tool);
106
- }
107
- };
92
+ const NO_NATIVE_MCP_GUARD_MSG =
93
+ "pi-nan-provider 0.10+ requires pi >= 0.99 for MCP tools (native MCP); upgrade pi or stay on package 0.9.x";
108
94
 
109
- if (webSearchBridgeEnabled()) registerSearchTool();
110
- if (mediaMcpEnabled()) registerMediaTools();
111
- if (typeof pi.registerCommand === "function") {
112
- registerNanMcpCommand(pi, { registerWebSearchTools: registerSearchTool, registerMediaTools });
113
- registerNanUsageCommand(pi);
95
+ /** Register the official NaN web-search MCP server (session-scoped). */
96
+ async function registerWebSearchMcpServer(pi: ExtensionAPI): Promise<boolean> {
97
+ if (!webSearchBridgeEnabled()) return false;
98
+ const apiKey = process.env[NAN_API_KEY_ENV];
99
+ if (!apiKey) {
100
+ return false;
114
101
  }
102
+ pi.registerMcpServer("nan-search", {
103
+ type: "http",
104
+ url: "https://api.nan.builders/mcp",
105
+ headers: { Authorization: `Bearer ${apiKey}` },
106
+ exposure: "direct",
107
+ });
108
+ return true;
109
+ }
110
+
111
+ /** Register the community media stdio MCP server (session-scoped). */
112
+ function registerMediaMcpServer(pi: ExtensionAPI): boolean {
113
+ if (!mediaMcpEnabled()) return false;
114
+ const mediaArgs = mediaMcpCommand();
115
+ pi.registerMcpServer("nan-media", {
116
+ type: "stdio",
117
+ command: mediaArgs[0]!,
118
+ args: mediaArgs.slice(1),
119
+ env: { [NAN_API_KEY_ENV]: process.env[NAN_API_KEY_ENV] ?? "" },
120
+ exposure: "direct",
121
+ timeout: mediaMcpTimeoutSec(),
122
+ });
123
+ return true;
115
124
  }
116
125
 
126
+ /**
127
+ * Register MCP servers via pi's native registerMcpServer.
128
+ *
129
+ * Session-scoped: servers appear in /mcp with source "extension", are
130
+ * visible to the model (exposure: "direct"), and user mcp.json entries
131
+ * with the same name take precedence.
132
+ *
133
+ * On pi <0.99 (registerMcpServer absent) this does NOT bridge — it
134
+ * notifies once and skips (same loud-skip style as existing guards).
135
+ * Hard cutover: users on older pi stay on package 0.9.x.
136
+ */
137
+ async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
138
+ // Guard: pi >= 0.99 required for native MCP.
139
+ if (typeof pi.registerMcpServer !== "function") {
140
+ console.warn(NO_NATIVE_MCP_GUARD_MSG);
141
+ return;
142
+ }
143
+
144
+
145
+ const didWebSearch = await registerWebSearchMcpServer(pi);
146
+ registerMediaMcpServer(pi);
147
+
148
+
149
+ }
150
+
151
+ // ── Extension entrypoint ─────────────────────────────────────────────────
152
+
117
153
  /**
118
154
  * Drop the reasoning pi-ai replays across a model switch.
119
155
  *
120
156
  * pi-ai's `transformMessages` downgrades a previous model's `thinking` blocks
121
- * to plain text with no size bound (still true on 0.85.1 / main), and nothing
157
+ * to plain text with no size bound (verified on 0.87.1), and nothing
122
158
  * bounds the sum across messages — measured at 30–60% of the whole context on
123
159
  * real sessions. Switching from a 1M-context model to a 262K one (`qwen3.6`)
124
160
  * then overflows the window, and NaN's gateway answers a generic
@@ -146,7 +182,11 @@ export default async function nanProviderExtension(pi: ExtensionAPI): Promise<vo
146
182
  for (const config of PROVIDERS) {
147
183
  await registerProviderCompat(pi, config);
148
184
  }
149
- registerMcpToolsCompat(pi);
185
+ await registerMcpServersNative(pi);
186
+ if (typeof pi.registerCommand === "function") {
187
+ registerNanMcpCommand(pi);
188
+ registerNanUsageCommand(pi);
189
+ }
150
190
  }
151
191
 
152
192
  /** Exposed for tests: the env var this package uses for every NaN surface. */
@@ -0,0 +1,76 @@
1
+ import { bridgeSource, resolveBridgeEnabled } from "./state.ts";
2
+
3
+ /**
4
+ * NaN API key resolution — shared across every surface (web search, media, usage).
5
+ *
6
+ * Strategy: pi's stored credential (modelRegistry) wins → env var fallback.
7
+ * Never logs or embeds the key beyond the Authorization header.
8
+ */
9
+
10
+ /** Env var that overrides the official web_search bridge. */
11
+ export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
12
+ /** Env var that overrides the media bridge. */
13
+ export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
14
+ /** The NaN API key env var used everywhere. */
15
+ export const NAN_API_KEY_ENV = "NAN_API_KEY";
16
+ /** NaN provider id used for stored-credential lookup. */
17
+ const NAN_PROVIDER_ID = "nan";
18
+
19
+ /**
20
+ * Resolve the NaN API key: pi's registry first (covers stored auth.json),
21
+ * then env fallback. `registryKey` is the result of an attempted registry
22
+ * lookup (already caught and converted to string | undefined by the caller).
23
+ */
24
+ export function resolveNanApiKey(registryKey: string | undefined): string | undefined {
25
+ if (registryKey) return registryKey;
26
+ return process.env[NAN_API_KEY_ENV] || undefined;
27
+ }
28
+
29
+ /**
30
+ * Attempt to resolve the API key through pi's model registry.
31
+ * Catches any error and returns undefined so the caller can fall back to env.
32
+ */
33
+ export async function tryResolveNanApiKeyViaRegistry(
34
+ modelRegistry: { getApiKeyForProvider(provider: string): Promise<string | undefined> } | undefined,
35
+ ): Promise<string | undefined> {
36
+ try {
37
+ return await modelRegistry?.getApiKeyForProvider(NAN_PROVIDER_ID);
38
+ } catch {
39
+ // Registry unavailable — caller falls back to env.
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ /** Whether MCP tool registration is disabled via env (NAN_MCP_TOOLS=0|false|off). */
45
+ export function mcpToolsDisabled(): boolean {
46
+ const value = process.env[NAN_MCP_TOOLS_ENV]?.trim().toLowerCase();
47
+ return value === "0" || value === "false" || value === "off";
48
+ }
49
+
50
+ /** Whether NAN_MCP_TOOLS is explicitly set (any value). */
51
+ export function mcpToolsEnvExplicit(): boolean {
52
+ const value = process.env[NAN_MCP_TOOLS_ENV];
53
+ return value !== undefined && value.trim() !== "";
54
+ }
55
+
56
+ /** Whether NAN_MEDIA_MCP is explicitly set (any value). */
57
+ export function mediaMcpEnvExplicit(): boolean {
58
+ const value = process.env[NAN_MEDIA_MCP_ENV];
59
+ return value !== undefined && value.trim() !== "";
60
+ }
61
+
62
+ /** Whether NAN_MEDIA_MCP is explicitly set and truthy. */
63
+ export function mediaMcpEnvTruthy(): boolean {
64
+ const value = process.env[NAN_MEDIA_MCP_ENV]?.trim().toLowerCase();
65
+ return value === "1" || value === "true" || value === "on";
66
+ }
67
+
68
+ /** Source of the effective web-search enablement (env / persisted / default). */
69
+ export function webSearchBridgeSource(): import("./state.ts").BridgeSource {
70
+ return bridgeSource("webSearch", mcpToolsEnvExplicit());
71
+ }
72
+
73
+ /** Whether the web-search bridge is enabled by gate (env → persisted → default). */
74
+ export function webSearchBridgeEnabled(): boolean {
75
+ return resolveBridgeEnabled("webSearch", mcpToolsEnvExplicit(), !mcpToolsDisabled(), true);
76
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Media server construction helpers — version pin, command, timeout.
3
+ * Also exports bridge gate resolution (mediaMcpEnabled / mediaMcpSource)
4
+ * to keep the native MCP registration in index.ts minimal.
5
+ */
6
+ import { bridgeSource, resolveBridgeEnabled, type BridgeSource } from "./state.ts";
7
+
8
+ /**
9
+ * Default spawn: `npx -y nan-mcp-server@<pinned version>` (npx caches after first use).
10
+ */
11
+ export function mediaMcpCommand(version = process.env[NAN_MEDIA_MCP_VERSION_ENV] || DEFAULT_NAN_MEDIA_MCP_VERSION): string[] {
12
+ const custom = process.env[NAN_MEDIA_MCP_COMMAND_ENV]?.trim();
13
+ if (custom) return custom.split(/\s+/);
14
+ return ["npx", "-y", `nan-mcp-server@${version}`];
15
+ }
16
+
17
+ /** Per-request timeout in milliseconds. Native MCP uses seconds. */
18
+ export function mediaMcpTimeoutMs(): number {
19
+ const parsed = Number.parseInt(process.env[NAN_MEDIA_MCP_TIMEOUT_ENV] ?? "", 10);
20
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MEDIA_MCP_TIMEOUT_MS;
21
+ }
22
+
23
+ /** Per-request timeout in **seconds** for the native MCP config. */
24
+ export function mediaMcpTimeoutSec(): number {
25
+ return Math.ceil(mediaMcpTimeoutMs() / 1000);
26
+ }
27
+
28
+ export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
29
+
30
+ export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.1.2";
31
+ export const DEFAULT_MEDIA_MCP_TIMEOUT_MS = 120_000;
32
+ export const NAN_MEDIA_MCP_VERSION_ENV = "NAN_MEDIA_MCP_VERSION";
33
+ export const NAN_MEDIA_MCP_COMMAND_ENV = "NAN_MEDIA_MCP_COMMAND";
34
+ export const NAN_MEDIA_MCP_TIMEOUT_ENV = "NAN_MEDIA_MCP_TIMEOUT_MS";
35
+
36
+ /** Whether NAN_MEDIA_MCP is explicitly set (any value). */
37
+ export function mediaMcpEnvExplicit(): boolean {
38
+ const value = process.env[NAN_MEDIA_MCP_ENV];
39
+ return value !== undefined && value.trim() !== "";
40
+ }
41
+
42
+ /** Whether NAN_MEDIA_MCP is explicitly set and truthy. */
43
+ export function mediaMcpEnvTruthy(): boolean {
44
+ const value = process.env[NAN_MEDIA_MCP_ENV]?.trim().toLowerCase();
45
+ return value === "1" || value === "true" || value === "on";
46
+ }
47
+
48
+ /** Whether the media bridge is enabled by gate (env → persisted → default). */
49
+ export function mediaMcpEnabled(): boolean {
50
+ return resolveBridgeEnabled("mediaMcp", mediaMcpEnvExplicit(), mediaMcpEnvTruthy(), true);
51
+ }
52
+
53
+ /** Source of the effective media enablement. */
54
+ export function mediaMcpSource(): BridgeSource {
55
+ return bridgeSource("mediaMcp", mediaMcpEnvExplicit());
56
+ }
@@ -14,8 +14,8 @@
14
14
  * statically import ONLY the bare `@earendil-works/pi-ai` root. pi's
15
15
  * extension loader maps that specifier to the compat entrypoint on every
16
16
  * supported runtime (bundled CLI, Node-mode aliases, compiled-binary
17
- * virtualModules; pi 0.83 and 0.84 alike), and compat re-exports every lazy
18
- * API factory. Subpath specifiers (`@earendil-works/pi-ai/api/...`) get the
17
+ * virtualModules; pi 0.83 through 0.87.1 alike), and compat re-exports every
18
+ * lazy API factory. Subpath specifiers (`@earendil-works/pi-ai/api/...`) get the
19
19
  * alias applied as a prefix and resolve to `<compat.js>/api/...`, which does
20
20
  * not exist — the extension then fails to load entirely.
21
21
  *
@@ -79,6 +79,11 @@ export interface NanCompatibleProviderOptions {
79
79
  * Any caller-supplied `onPayload` (e.g. pi's own debug/passthrough hook) is
80
80
  * preserved and chained AFTER sanitization, so the final payload is always
81
81
  * schema-valid.
82
+ *
83
+ * Note (pi-ai ≥ 0.86): this wrapper forwards the `TranscriptContext` parameter
84
+ * unchanged and only rewrites `options.onPayload` — the `TranscriptContext`
85
+ * migration (branded `TranscriptContext = { messages: Message[] }` replacing
86
+ * `Context`) does not affect this function's behavior.
82
87
  */
83
88
  function isObject(value: unknown): value is Record<string, unknown> {
84
89
  return typeof value === "object" && value !== null;
package/src/usage.ts CHANGED
@@ -13,8 +13,11 @@
13
13
  * (NaN docs, checked 2026-09-27).
14
14
  *
15
15
  * The endpoint reports consumption, never caps: quotas below come from
16
- * https://nan.builders/docs/models (checked 2026-09-21) and are merged with
17
- * the returned per-model totals. `/usage` returns daily rows (paginated) plus
16
+ * https://nan.builders/docs/models (checked 2026-09-29) and are merged with
17
+ * the returned per-model totals. mimo-v2.5 has no row: NaN removed it from
18
+ * the docs (checked 2026-09-29), so a cap for it would have no source —
19
+ * historical consumption still appears under the undocumented-models section.
20
+ * `/usage` returns daily rows (paginated) plus
18
21
  * `totals` covering the whole requested window — max 90 inclusive days, wider
19
22
  * windows are rejected with 400 — so the command reads `totals.by_model` and
20
23
  * asks for a single-row page. Rate limit: 30 requests/min, separate from the
@@ -22,7 +25,7 @@
22
25
  */
23
26
 
24
27
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
25
- import { NAN_API_KEY_ENV, resolveNanApiKey } from "./mcp/nan-search.ts";
28
+ import { NAN_API_KEY_ENV, resolveNanApiKey, tryResolveNanApiKeyViaRegistry } from "./mcp/api-key.ts";
26
29
 
27
30
  // ── Known quota limits per model (from NaN docs) ──────────────────────────
28
31
 
@@ -43,7 +46,6 @@ export interface ModelQuota {
43
46
 
44
47
  export const MODEL_QUOTAS: readonly ModelQuota[] = [
45
48
  { model: "deepseek-v4-flash", label: "DeepSeek V4 Flash", monthlyCap: 3_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
46
- { model: "mimo-v2.5", label: "MiMo V2.5", monthlyCap: 1_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
47
49
  { model: "mimo-v2.6-flash", label: "MiMo V2.6 Flash", monthlyCap: 1_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
48
50
  { model: "qwen3.6", label: "Qwen 3.6", monthlyCap: 0, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
49
51
  { model: "gemma4", label: "Gemma 4", monthlyCap: 0, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
@@ -429,7 +431,10 @@ export interface NanUsageCommandOptions {
429
431
  export function registerNanUsageCommand(pi: ExtensionAPI, options: NanUsageCommandOptions = {}): void {
430
432
  if (typeof pi.registerCommand !== "function") return;
431
433
 
432
- const resolveApiKey = options.resolveApiKey ?? ((ctx: ExtensionCommandContext) => resolveNanApiKey(ctx));
434
+ const resolveApiKey = options.resolveApiKey ?? (async (ctx: ExtensionCommandContext): Promise<string | undefined> => {
435
+ const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
436
+ return resolveNanApiKey(registryKey);
437
+ });
433
438
 
434
439
  pi.registerCommand("nan-usage", {
435
440
  description: "Show NaN token usage vs monthly caps, window/all-time totals, and time until billing reset",
@@ -1,236 +0,0 @@
1
- /**
2
- * Optional bridge to the community stdio MCP server
3
- * `nan-mcp-server` (https://github.com/luciferfran/nan-mcp-server), which
4
- * exposes NaN's media tools: image generation/editing (flux-2-klein), TTS
5
- * (kokoro), STT (whisper), plus voice listing.
6
- *
7
- * Both MCP bridges are enabled by default and fully lazy:
8
- * - ON by default. NAN_MEDIA_MCP=0 (or false/off) disables it for one
9
- * session; `/nan-mcp disable nan-mcp-server` persists the toggle.
10
- * - The server process is spawned per tool call and terminated right after —
11
- * zero startup cost, nothing runs unless audio/image/transcription is
12
- * actually invoked.
13
- * - NAN_API_KEY is forwarded to the child; NAN_OUTPUT_DIR and the server's
14
- * other env vars inherit from your environment (generated files land in
15
- * ~/nan-mcp-output/ by default).
16
- * - Version pinning follows the upstream server's own supply-chain guidance:
17
- * NAN_MEDIA_MCP_VERSION (default "1.1.2"), or pass a custom command with
18
- * NAN_MEDIA_MCP_COMMAND (space-separated, e.g. "bunx nan-mcp-server@1.1.2").
19
- */
20
-
21
- import { Type, type TSchema } from "@earendil-works/pi-ai";
22
- import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
23
- import { callStdioMcpTool } from "./stdio-client.ts";
24
- import { bridgeSource, resolveBridgeEnabled, type BridgeSource } from "./state.ts";
25
- import { NAN_API_KEY_ENV, resolveNanApiKey, type NapiKeyContext } from "./nan-search.ts";
26
-
27
- export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
28
- export const NAN_MEDIA_MCP_VERSION_ENV = "NAN_MEDIA_MCP_VERSION";
29
- export const NAN_MEDIA_MCP_COMMAND_ENV = "NAN_MEDIA_MCP_COMMAND";
30
- export const NAN_MEDIA_MCP_TIMEOUT_ENV = "NAN_MEDIA_MCP_TIMEOUT_MS";
31
- export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.1.2";
32
-
33
- /**
34
- * The MCP tool names on the stdio nan-mcp-server that this package bridges as
35
- * pi tools. Kept as the single source of truth for the check script
36
- * (scripts/check-nan-mcp-server.ts) and to type each spec's `mcpTool` so the
37
- * bridge cannot drift from the set it claims to expose.
38
- */
39
- export const NAN_MEDIA_MCP_SERVER_TOOLS = [
40
- "generate_image",
41
- "edit_image",
42
- "text_to_speech",
43
- "list_voices",
44
- "speech_to_text",
45
- ] as const;
46
- export const DEFAULT_MEDIA_MCP_TIMEOUT_MS = 120_000;
47
-
48
- /** Media tools bridged from the stdio MCP server (audio/image/transcription scope). */
49
- export const NAN_MEDIA_TOOLS = [
50
- "nan_generate_image",
51
- "nan_edit_image",
52
- "nan_text_to_speech",
53
- "nan_list_voices",
54
- "nan_speech_to_text",
55
- ] as const;
56
-
57
- /** Truthy env parse for the media bridge env var: 1/true/on (case-insensitive). */
58
- function envTruthy(): boolean {
59
- return envValueIsTruthy(process.env[NAN_MEDIA_MCP_ENV]);
60
- }
61
-
62
- /** Whether the env var is explicitly set (any value) — it overrides the persisted toggle. */
63
- function envExplicit(): boolean {
64
- const value = process.env[NAN_MEDIA_MCP_ENV];
65
- return value !== undefined && value.trim() !== "";
66
- }
67
-
68
- function envValueIsTruthy(value: string | undefined): boolean {
69
- return value?.trim().toLowerCase() === "1" || value?.trim().toLowerCase() === "true" || value?.trim().toLowerCase() === "on";
70
- }
71
-
72
- /**
73
- * Effective enablement of the community media MCP bridge: an explicit
74
- * `NAN_MEDIA_MCP` env var (any value, e.g. `0` to force one session off)
75
- * wins; then the toggle persisted by `/nan-mcp`; default: **enabled** (both
76
- * bridges are on and lazy by default — the server spawns per tool call).
77
- */
78
- export function mediaMcpEnabled(): boolean {
79
- return resolveBridgeEnabled("mediaMcp", envExplicit(), envTruthy(), true);
80
- }
81
-
82
- /** Source of the effective media enablement (env / persisted / default). */
83
- export function mediaMcpSource(): BridgeSource {
84
- return bridgeSource("mediaMcp", envExplicit());
85
- }
86
-
87
- /** Default spawn: `npx -y nan-mcp-server@<pinned version>` (npx caches after first use). */
88
- export function mediaMcpCommand(version = process.env[NAN_MEDIA_MCP_VERSION_ENV] || DEFAULT_NAN_MEDIA_MCP_VERSION): string[] {
89
- const custom = process.env[NAN_MEDIA_MCP_COMMAND_ENV]?.trim();
90
- if (custom) return custom.split(/\s+/);
91
- return ["npx", "-y", `nan-mcp-server@${version}`];
92
- }
93
-
94
- function mediaMcpTimeoutMs(): number {
95
- const parsed = Number.parseInt(process.env[NAN_MEDIA_MCP_TIMEOUT_ENV] ?? "", 10);
96
- return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MEDIA_MCP_TIMEOUT_MS;
97
- }
98
-
99
- interface MediaToolSpec<TParams extends TSchema = TSchema> {
100
- name: (typeof NAN_MEDIA_TOOLS)[number];
101
- mcpTool: (typeof NAN_MEDIA_MCP_SERVER_TOOLS)[number];
102
- label: string;
103
- description: string;
104
- promptSnippet: string;
105
- parameters: TParams;
106
- }
107
-
108
- const sizeProperty = () =>
109
- Type.Optional(
110
- Type.String({
111
- description: 'Image size "WxH" divisible by 16, e.g. 1024x1024, 1536x1024, 1024x1536. Default 1024x1024',
112
- }),
113
- );
114
-
115
- function defineMediaTool<TParams extends TSchema>(spec: MediaToolSpec<TParams>): ToolDefinition<TParams> {
116
- return {
117
- name: spec.name,
118
- label: spec.label,
119
- description: spec.description,
120
- promptSnippet: spec.promptSnippet,
121
- parameters: spec.parameters,
122
- execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
123
- const apiKey = await resolveNanApiKey(ctx as unknown as NapiKeyContext);
124
- if (!apiKey) {
125
- throw new Error(`${NAN_API_KEY_ENV} is not set. Export it or run /login nan to use NaN media tools.`);
126
- }
127
- const result = await callStdioMcpTool(spec.mcpTool, params as Record<string, unknown>, {
128
- command: mediaMcpCommand(),
129
- env: { [NAN_API_KEY_ENV]: apiKey },
130
- timeoutMs: mediaMcpTimeoutMs(),
131
- ...(signal ? { signal } : {}),
132
- });
133
- if (!result.ok) {
134
- throw new Error(
135
- `${result.error ?? "nan-mcp-server call failed"}${result.stderrTail ? ` (server stderr: ${result.stderrTail})` : ""}`,
136
- );
137
- }
138
- return { content: [{ type: "text", text: result.text }], details: undefined } as const;
139
- },
140
- };
141
- }
142
-
143
- /**
144
- * Build the media tool set. Registered only when NAN_MEDIA_MCP=1 and the
145
- * runtime supports registerTool; execution spawns the MCP server per call.
146
- * Schemas mirror nan-mcp-server's zod input schemas (v1.1.2).
147
- */
148
- export function createNanMediaTools(): ToolDefinition[] {
149
- return [
150
- defineMediaTool({
151
- name: "nan_generate_image",
152
- mcpTool: "generate_image",
153
- label: "NaN Generate Image",
154
- description:
155
- "Generate an image with flux-2-klein (NaN API). Returns the saved image file path (under ~/nan-mcp-output/ by default) and its URL.",
156
- promptSnippet: "nan_generate_image(prompt, size?, n?, seed?, guidance?, outputName?): generate an image via NaN (flux-2-klein)",
157
- parameters: Type.Object({
158
- prompt: Type.String({ description: "Textual description of the image to generate" }),
159
- size: sizeProperty(),
160
- n: Type.Optional(Type.Integer({ description: "Number of images to generate (1-4). Default 1", minimum: 1, maximum: 4 })),
161
- seed: Type.Optional(Type.Number({ description: "Base seed for reproducibility" })),
162
- guidance: Type.Optional(Type.Number({ description: "FLUX guidance scale" })),
163
- outputName: Type.Optional(Type.String({ description: "Optional base name for the output file(s)" })),
164
- }),
165
- }),
166
- defineMediaTool({
167
- name: "nan_edit_image",
168
- mcpTool: "edit_image",
169
- label: "NaN Edit Image",
170
- description:
171
- "Edit an image with flux-2-klein image-to-image (NaN API). Takes reference image files and applies a transformation. Returns the saved output image path.",
172
- promptSnippet: "nan_edit_image(prompt, images, size?, n?, seed?, guidance?, outputName?): edit images via NaN (flux-2-klein)",
173
- parameters: Type.Object({
174
- prompt: Type.String({ description: "Description of the edit or transformation to apply" }),
175
- images: Type.Array(Type.String(), {
176
- description: "Absolute paths to reference image files (PNG, JPEG, WebP; up to 4, each < 25MB)",
177
- minItems: 1,
178
- maxItems: 4,
179
- }),
180
- size: sizeProperty(),
181
- n: Type.Optional(Type.Integer({ description: "Number of images to generate (1-4). Default 1", minimum: 1, maximum: 4 })),
182
- seed: Type.Optional(Type.Number({ description: "Base seed for reproducibility" })),
183
- guidance: Type.Optional(Type.Number({ description: "FLUX guidance scale" })),
184
- outputName: Type.Optional(Type.String({ description: "Optional base name for the output file(s)" })),
185
- }),
186
- }),
187
- defineMediaTool({
188
- name: "nan_text_to_speech",
189
- mcpTool: "text_to_speech",
190
- label: "NaN Text To Speech",
191
- description:
192
- "Synthesize audio from text with kokoro (NaN API TTS). Returns the saved audio file path. Use nan_list_voices to see all available voices per language.",
193
- promptSnippet: "nan_text_to_speech(text, voice?, format?, speed?, outputName?): synthesize speech via NaN (kokoro)",
194
- parameters: Type.Object({
195
- text: Type.String({ description: "Text to synthesize" }),
196
- voice: Type.Optional(
197
- Type.String({
198
- description:
199
- 'Voice to use, e.g. "af_heart" (American English female), "ef_dora" (Spanish female), "em_alex" (Spanish male). Use nan_list_voices for the full catalog',
200
- }),
201
- ),
202
- format: Type.Optional(
203
- Type.Unsafe<"mp3" | "wav" | "flac" | "aac" | "pcm" | "opus">({
204
- type: "string",
205
- enum: ["mp3", "wav", "flac", "aac", "pcm", "opus"],
206
- description: "Audio format. Default mp3",
207
- }),
208
- ),
209
- speed: Type.Optional(Type.Number({ description: "Speech speed. Default 1.0" })),
210
- outputName: Type.Optional(Type.String({ description: "Optional base name for the output file" })),
211
- }),
212
- }),
213
- defineMediaTool({
214
- name: "nan_list_voices",
215
- mcpTool: "list_voices",
216
- label: "NaN List Voices",
217
- description: "List all available kokoro TTS voices grouped by language.",
218
- promptSnippet: "nan_list_voices(): list available kokoro TTS voices",
219
- parameters: Type.Object({}),
220
- }),
221
- defineMediaTool({
222
- name: "nan_speech_to_text",
223
- mcpTool: "speech_to_text",
224
- label: "NaN Speech To Text",
225
- description: "Transcribe an audio file with whisper (NaN API STT). Returns the transcript.",
226
- promptSnippet: "nan_speech_to_text(file, language?, verbose?): transcribe an audio file via NaN (whisper)",
227
- parameters: Type.Object({
228
- file: Type.String({ description: "Absolute path to the audio file to transcribe" }),
229
- language: Type.Optional(
230
- Type.String({ description: 'ISO-639-1 language code, e.g. "es", "en". Auto-detected if omitted' }),
231
- ),
232
- verbose: Type.Optional(Type.Boolean({ description: "Return verbose JSON with segments instead of plain text" })),
233
- }),
234
- }),
235
- ];
236
- }