@gtrabanco/pi-nan-provider 0.2.1 → 0.4.1

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/README.es.md CHANGED
@@ -59,19 +59,29 @@ Consigue una clave en la [plataforma NaN](https://cloud.nan.builders/r/7GK06FX8)
59
59
 
60
60
  pi no incluye cliente MCP a propósito ("It intentionally does not include built-in MCP" — `docs/usage.md` de pi). Este paquete conecta servidores MCP dentro de pi como herramientas nativas personalizadas, de modo que el LLM las llama como cualquier herramienta integrada.
61
61
 
62
- ### 1. Servidor MCP oficial de NaN (activo por defecto)
62
+ Ambos puentes están **activados y perezosos por defecto**, y se configuran con el comando `/nan-mcp` que trae este paquete (pi no tiene comando `/mcp` propio — no tiene cliente MCP en absoluto — así que el comando se llama `/nan-mcp`):
63
+
64
+ | Comando | Efecto |
65
+ |---|---|
66
+ | `/nan-mcp status` | Estado de ambos puentes y de dónde sale cada interruptor (env / persistido / por defecto) |
67
+ | `/nan-mcp enable [target]` | Activa un puente — o ambos si no das target — y lo persiste en `<agentDir>/nan-provider.json` (p. ej. `~/.pi/agent/nan-provider.json`); las herramientas se registran al instante en la sesión actual |
68
+ | `/nan-mcp disable [target]` | Desactiva persistentemente; pi no tiene `unregisterTool`, así que las herramientas ya registradas siguen hasta reiniciar; las sesiones futuras no las registran |
69
+
70
+ Targets: `web-search` (puente oficial; alias `search`) y `nan-mcp-server` (puente de media de la comunidad; alias `media`). Ejemplo: `/nan-mcp enable nan-mcp-server`. Las variables de entorno explícitas tienen prioridad sobre los toggles persistidos (ver tabla inferior).
71
+
72
+ ### 1. Servidor MCP oficial de NaN (por defecto: activado, perezoso)
63
73
 
64
74
  El servidor MCP remoto oficial de NaN ([`https://api.nan.builders/mcp`](https://nan.builders/docs/api), JSON-RPC 2.0 sobre HTTP, misma clave `sk-`, mismo límite de tasa/cuota/concurrencia que la API REST) se conecta como:
65
75
 
66
- - **`nan_web_search(query, count?, freshness?, fetch_content?)`** — búsqueda web vía NaN. Registrado por defecto siempre que pi soporte `registerTool`; desactívalo con `NAN_MCP_TOOLS=0`.
76
+ - **`nan_web_search(query, count?, freshness?, fetch_content?)`** — búsqueda web vía NaN. La llamada HTTP solo ocurre cuando se invoca la herramienta.
67
77
 
68
78
  El servidor es un registro en crecimiento (descubrible con `tools/list`); este paquete conecta por ahora la herramienta documentada `web_search` y mantiene un helper genérico `callNanMcpTool()` para herramientas futuras.
69
79
 
70
- ### 2. Servidor MCP de media de la comunidad (opcional, perezoso)
80
+ ### 2. Servidor MCP de media de la comunidad (por defecto: activado, perezoso)
71
81
 
72
82
  [`nan-mcp-server`](https://github.com/luciferfran/nan-mcp-server) es un servidor MCP stdio que expone las herramientas de media de NaN: generación/edición de imágenes (flux-2-klein), TTS (kokoro) y STT (whisper). Como pi no tiene cliente MCP, este paquete lo conecta como herramientas de pi mediante un cliente MCP stdio mínimo:
73
83
 
74
- - **Desactivado por defecto** — actívalo con `NAN_MEDIA_MCP=1`.
84
+ - **Activado por defecto**, conmutado persistentemente con `/nan-mcp enable|disable nan-mcp-server` (o `media`), o por sesión con `NAN_MEDIA_MCP` (cualquier valor explícito — p. ej. `NAN_MEDIA_MCP=0` — tiene prioridad sobre el toggle persistido).
75
85
  - **Perezoso (lazy)**: el proceso del servidor MCP se lanza *por cada llamada* y se termina justo después. No arranca ni conecta nada a menos que se invoque realmente generación de audio/imagen/transcripción.
76
86
  - **Configuración**: `NAN_API_KEY` se reenvía automáticamente (la misma clave del proveedor); los ficheros generados van a `~/nan-mcp-output/` (por defecto del servidor, configurable con `NAN_OUTPUT_DIR`).
77
87
 
@@ -87,11 +97,11 @@ Variables de entorno:
87
97
 
88
98
  | Variable | Por defecto | Significado |
89
99
  |---|---|---|
90
- | `NAN_MEDIA_MCP` | off | `1`/`true`/`on` activa las herramientas de media |
100
+ | `NAN_MEDIA_MCP` | — | Override por sesión del puente de media: cualquier valor explícito (incl. `0`) gana al toggle persistido de `/nan-mcp`; sin definir → persistido/por defecto |
91
101
  | `NAN_MEDIA_MCP_VERSION` | `1.0.7` | Versión del servidor fijada para `npx -y nan-mcp-server@<v>` (recomendación de supply-chain del propio proyecto) |
92
102
  | `NAN_MEDIA_MCP_COMMAND` | — | Comando personalizado completo, p. ej. `bunx nan-mcp-server@1.0.7` |
93
103
  | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Timeout por llamada; el proceso se mata al expirar |
94
- | `NAN_MCP_TOOLS` | — | `0`/`false`/`off` desactiva el puente `nan_web_search` oficial |
104
+ | `NAN_MCP_TOOLS` | — | Override por sesión del puente oficial: `0`/`false`/`off` desactiva `nan_web_search`; sin definir → persistido/por defecto |
95
105
 
96
106
  ## Modelos
97
107
 
package/README.md CHANGED
@@ -59,19 +59,29 @@ Get a key from the [NaN platform](https://cloud.nan.builders/r/7GK06FX8) (user s
59
59
 
60
60
  pi intentionally ships without an MCP client ("It intentionally does not include built-in MCP" — pi's `docs/usage.md`). This package bridges MCP servers into pi as native custom tools, so the LLM calls them like any built-in tool.
61
61
 
62
- ### 1. Official NaN MCP server (on by default)
62
+ Both bridges are **enabled and lazy by default** and are configured with the `/nan-mcp` slash command this package ships (pi has no `/mcp` command of its own — it has no MCP client at all — so the command is namespaced `/nan-mcp`):
63
+
64
+ | Command | Effect |
65
+ |---|---|
66
+ | `/nan-mcp status` | State of both bridges and where each toggle comes from (env / persisted / default) |
67
+ | `/nan-mcp enable [target]` | Enable a bridge — or both when no target is given — and persist it in `<agentDir>/nan-provider.json` (e.g. `~/.pi/agent/nan-provider.json`); tools register immediately for the current session |
68
+ | `/nan-mcp disable [target]` | Disable persistently; pi has no `unregisterTool`, so already-registered tools remain until restart, future sessions skip them |
69
+
70
+ Targets: `web-search` (official bridge) and `nan-mcp-server` (community media bridge; alias `media`). Example: `/nan-mcp enable nan-mcp-server`. Explicit env vars override the persisted toggles for the session (see the table below).
71
+
72
+ ### 1. Official NaN MCP server (default: enabled, lazy)
63
73
 
64
74
  NaN's official remote MCP server ([`https://api.nan.builders/mcp`](https://nan.builders/docs/api), JSON-RPC 2.0 over HTTP, same `sk-` key, same rate limit/quota/concurrency as the REST API) is bridged as:
65
75
 
66
- - **`nan_web_search(query, count?, freshness?, fetch_content?)`** — web search through NaN. Registered by default whenever pi supports `registerTool`; disable with `NAN_MCP_TOOLS=0`.
76
+ - **`nan_web_search(query, count?, freshness?, fetch_content?)`** — web search through NaN. The HTTP call happens only when the tool is invoked.
67
77
 
68
78
  The server is a growing registry (discover with `tools/list`); this package currently bridges the documented `web_search` tool and keeps a generic `callNanMcpTool()` helper for future tools.
69
79
 
70
- ### 2. Community media MCP server (opt-in, lazy)
80
+ ### 2. Community media MCP server (default: enabled, lazy)
71
81
 
72
82
  [`nan-mcp-server`](https://github.com/luciferfran/nan-mcp-server) is a stdio MCP server exposing NaN's media tools: image generation/editing (flux-2-klein), TTS (kokoro), and STT (whisper). Because pi has no MCP client, this package bridges it as pi tools via a minimal built-in MCP stdio client:
73
83
 
74
- - **Off by default** — enable with `NAN_MEDIA_MCP=1`.
84
+ - **Enabled by default**, toggled persistently with `/nan-mcp enable|disable nan-mcp-server` (or `media`), or per-session with `NAN_MEDIA_MCP` (any explicit value — e.g. `NAN_MEDIA_MCP=0` — overrides the persisted toggle).
75
85
  - **Lazy**: the MCP server process is spawned *per tool call* and terminated immediately after. Nothing starts, connects, or costs anything unless audio/image/transcription is actually invoked.
76
86
  - **Config**: `NAN_API_KEY` is forwarded automatically (same key as the provider); generated files land in `~/nan-mcp-output/` (the server's default, override with `NAN_OUTPUT_DIR`).
77
87
 
@@ -87,11 +97,11 @@ Environment variables:
87
97
 
88
98
  | Variable | Default | Meaning |
89
99
  |---|---|---|
90
- | `NAN_MEDIA_MCP` | off | `1`/`true`/`on` enables the media tools |
100
+ | `NAN_MEDIA_MCP` | — | Per-session override for the media bridge: any explicit value (incl. `0`) beats the `/nan-mcp` persisted toggle; unset → persisted/default |
91
101
  | `NAN_MEDIA_MCP_VERSION` | `1.0.7` | Pinned server version for `npx -y nan-mcp-server@<v>` (upstream's own supply-chain recommendation) |
92
102
  | `NAN_MEDIA_MCP_COMMAND` | — | Full custom command, e.g. `bunx nan-mcp-server@1.0.7` |
93
103
  | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Per-call timeout; the process is killed after it |
94
- | `NAN_MCP_TOOLS` | — | `0`/`false`/`off` disables the official `nan_web_search` bridge |
104
+ | `NAN_MCP_TOOLS` | — | Per-session override for the official bridge: `0`/`false`/`off` disables `nan_web_search`; unset → persisted/default |
95
105
 
96
106
  ## Models
97
107
 
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-nan-provider",
3
- "version": "0.2.1",
3
+ "version": "0.4.1",
4
4
  "description": "NaN Builders (api.nan.builders) model provider for pi - OpenAI-compatible registration with a models.dev-generated fallback, tier-aware live catalog, and MCP bridges (official web search + optional community media server)",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",
8
8
  "pi-extension",
9
- "pi-coding-agent",
9
+ "provider",
10
10
  "ai",
11
11
  "ai-provider",
12
+ "llm",
12
13
  "nan",
13
14
  "nan-builders",
15
+ "api-nan-builders",
14
16
  "openai-compatible",
15
- "litellm"
17
+ "ai-model"
16
18
  ],
17
19
  "type": "module",
18
20
  "main": "./src/index.ts",
@@ -55,5 +57,13 @@
55
57
  "bugs": {
56
58
  "url": "https://github.com/gtrabanco/pi-nan-provider/issues"
57
59
  },
58
- "homepage": "https://github.com/gtrabanco/pi-nan-provider#readme"
60
+ "homepage": "https://github.com/gtrabanco/pi-nan-provider#readme",
61
+ "dependencies": {
62
+ "@gtrabanco/pi-agentic-workflow": "^0.4.0",
63
+ "@tintinweb/pi-subagents": "^0.19.0",
64
+ "pi-effort": "^0.0.8",
65
+ "pi-mcp-adapter": "^2.32.1",
66
+ "version": "^0.1.2",
67
+ "view": "^1.1.1"
68
+ }
59
69
  }
@@ -1,7 +1,7 @@
1
1
  // This file is auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually — run `bun run generate-models` to update.
3
3
  //
4
- // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-04T18:57:45.925Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-04T20:25:06.364Z
5
5
  // Provenance: every contextWindow/maxTokens/input/cost value traces to
6
6
  // models.dev or to the per-entry notes below. Nothing is invented; entries
7
7
  // models.dev documents incompletely are omitted and flagged instead.
@@ -430,7 +430,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
430
430
  export const GENERATED_CATALOG_META = {
431
431
  source: "https://models.dev/api.json",
432
432
  modelsDevProvider: "nan",
433
- fetchedAt: "2026-09-04T18:57:45.925Z",
433
+ fetchedAt: "2026-09-04T20:25:06.364Z",
434
434
  modelCount: 7,
435
435
  models: ["deepseek-v4-flash","gemma4","glm5.2","glm5.3-flash","mimo-v2.5","qwen3.6","qwen3.8-flash"],
436
436
  notes: [
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `/nan-mcp` — slash command configuring BOTH MCP bridges this package
3
+ * registers as pi tools:
4
+ *
5
+ * - `web-search` — the official NaN remote MCP server (api.nan.builders/mcp),
6
+ * exposing `nan_web_search`. Default: enabled.
7
+ * - `nan-mcp-server` — the community stdio media server (image generation /
8
+ * editing, TTS, STT). Default: enabled, lazy (spawned per tool call).
9
+ *
10
+ * pi has no MCP client and therefore no `/mcp` command of its own, so this
11
+ * command owns the toggles:
12
+ *
13
+ * - `/nan-mcp` or `/nan-mcp status` — state of both bridges and their source.
14
+ * - `/nan-mcp enable [target]` — enable a bridge (or both, when no target is
15
+ * given) and persist it in `<agentDir>/nan-provider.json`; enabled bridges
16
+ * register their tools immediately for the current session.
17
+ * - `/nan-mcp disable [target]` — disable persistently. pi has no
18
+ * unregisterTool, so already-registered tools remain until the next
19
+ * session/restart; the persisted toggle governs future sessions.
20
+ *
21
+ * Targets accept aliases: `web-search` (`search`, `web_search`, `official`,
22
+ * `nan-web-search`) and `nan-mcp-server` (`media`, `media-mcp`, `nan-media`).
23
+ * Explicit env vars (NAN_MCP_TOOLS, NAN_MEDIA_MCP) override the persisted
24
+ * toggles for the session.
25
+ */
26
+
27
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
28
+ import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource } from "./mcp/nan-media.ts";
29
+ import { webSearchBridgeEnabled, webSearchBridgeSource } from "./mcp/nan-search.ts";
30
+ import { NAN_STATE_FILE, readBridgeState, writeBridgeState, type BridgeKey } from "./mcp/state.ts";
31
+
32
+ export interface NanMcpCommandOptions {
33
+ /** Register web-search bridge tools now; must skip already-registered tools. */
34
+ registerWebSearchTools: () => void;
35
+ /** Register media bridge tools now; must skip already-registered tools. */
36
+ registerMediaTools: () => void;
37
+ }
38
+
39
+ const USAGE =
40
+ "Usage: /nan-mcp [status] · /nan-mcp enable [web-search|nan-mcp-server] · /nan-mcp disable [web-search|nan-mcp-server]";
41
+
42
+ /** Canonical bridge targets with aliases (e.g. `/mcp enable nan-mcp-server` muscle memory). */
43
+ const TARGETS: Record<string, BridgeKey> = {
44
+ "web-search": "webSearch",
45
+ search: "webSearch",
46
+ web_search: "webSearch",
47
+ official: "webSearch",
48
+ "nan-web-search": "webSearch",
49
+ "nan-mcp-server": "mediaMcp",
50
+ media: "mediaMcp",
51
+ "media-mcp": "mediaMcp",
52
+ "nan-media": "mediaMcp",
53
+ };
54
+
55
+ function targetName(bridge: BridgeKey): string {
56
+ return bridge === "webSearch" ? "web-search" : "nan-mcp-server";
57
+ }
58
+
59
+ function envOverrideLabel(bridge: BridgeKey): string {
60
+ return bridge === "webSearch"
61
+ ? `env NAN_MCP_TOOLS=${process.env.NAN_MCP_TOOLS ?? ""} (overrides persisted)`
62
+ : `env NAN_MEDIA_MCP=${process.env.NAN_MEDIA_MCP ?? ""} (overrides persisted)`;
63
+ }
64
+
65
+ function sourceLabel(bridge: BridgeKey, source: "env" | "persisted" | "default"): string {
66
+ if (source === "env") return envOverrideLabel(bridge);
67
+ if (source === "persisted") {
68
+ return `persisted in <agentDir>/${NAN_STATE_FILE} (${targetName(bridge)}: ${readBridgeState(bridge)})`;
69
+ }
70
+ return "default (both bridges are enabled and lazy by default)";
71
+ }
72
+
73
+ function statusMessage(): string {
74
+ return [
75
+ `web-search bridge (official NaN MCP → nan_web_search): ${webSearchBridgeEnabled() ? "enabled" : "disabled"} — ${sourceLabel("webSearch", webSearchBridgeSource())}.`,
76
+ `nan-mcp-server bridge (community media MCP → nan_generate_image/nan_edit_image/nan_text_to_speech/nan_list_voices/nan_speech_to_text): ${mediaMcpEnabled() ? "enabled" : "disabled"} — ${sourceLabel("mediaMcp", mediaMcpSource())}.`,
77
+ `Media spawn command: ${mediaMcpCommand().join(" ")} (per tool call, terminated after).`,
78
+ "Configure with /nan-mcp enable|disable [web-search|nan-mcp-server]; no target = both.",
79
+ ].join("\n");
80
+ }
81
+
82
+ /** Parse an optional target argument; no target = both bridges. Returns undefined target name on unknown. */
83
+ function parseTarget(token: string | undefined): { bridges: BridgeKey[]; target?: string } | undefined {
84
+ if (token === undefined) return { bridges: ["webSearch", "mediaMcp"] };
85
+ const key = TARGETS[token.toLowerCase()];
86
+ if (!key) return undefined;
87
+ return { bridges: [key], target: token.toLowerCase() };
88
+ }
89
+
90
+ function describeBridges(bridges: BridgeKey[]): string {
91
+ const names = bridges.map((bridge) => targetName(bridge));
92
+ return names.length === 2 ? "both bridges" : names.join(" and ");
93
+ }
94
+
95
+ export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOptions): void {
96
+ pi.registerCommand("nan-mcp", {
97
+ description:
98
+ "NaN MCP configuration: enable/disable/status for both bridges (official web-search + community nan-mcp-server)",
99
+ getArgumentCompletions: (argumentPrefix: string) => {
100
+ const prefix = argumentPrefix.trim().toLowerCase();
101
+ const items = [
102
+ { value: "enable", label: "enable", description: "Enable a bridge (or both) and persist it" },
103
+ { value: "disable", label: "disable", description: "Disable a bridge (or both) persistently" },
104
+ { value: "status", label: "status", description: "Show current bridge status" },
105
+ { value: "enable web-search", label: "enable web-search", description: "Enable the official NaN web-search bridge" },
106
+ { value: "enable nan-mcp-server", label: "enable nan-mcp-server", description: "Enable the community media bridge" },
107
+ ];
108
+ const filtered = items.filter((item) => item.value.startsWith(prefix));
109
+ return filtered.length > 0 ? filtered : null;
110
+ },
111
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
112
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
113
+ const [rawSubcommand = "status", rawTarget, ...rest] = tokens;
114
+ const subcommand = rawSubcommand.toLowerCase();
115
+
116
+ if (subcommand === "status") {
117
+ ctx.ui.notify(statusMessage(), "info");
118
+ return;
119
+ }
120
+
121
+ if (subcommand === "enable" || subcommand === "disable") {
122
+ // Trailing tokens beyond the target (e.g. duplicate target names) are ignored.
123
+ const parsed = parseTarget(rawTarget);
124
+ if (parsed === undefined || rest.length > 0) {
125
+ ctx.ui.notify(
126
+ parsed === undefined
127
+ ? `Unknown target "${[rawTarget, ...rest].filter(Boolean).join(" ")}". Targets: web-search, nan-mcp-server (or omit for both).`
128
+ : USAGE,
129
+ "warning",
130
+ );
131
+ return;
132
+ }
133
+ const { bridges, target } = parsed;
134
+ const enabled = subcommand === "enable";
135
+
136
+ for (const bridge of bridges) {
137
+ writeBridgeState(bridge, enabled);
138
+ }
139
+ if (enabled) {
140
+ if (bridges.includes("webSearch")) options.registerWebSearchTools();
141
+ if (bridges.includes("mediaMcp")) options.registerMediaTools();
142
+ }
143
+
144
+ const where = target ? `for "${target}"` : "for both bridges";
145
+ const persistence = `persisted in <agentDir>/${NAN_STATE_FILE}`;
146
+ if (enabled) {
147
+ ctx.ui.notify(
148
+ `Enabled ${describeBridges(bridges)} ${where}, ${persistence}. Tools are available now; both bridges are lazy (web-search calls the endpoint per request, nan-mcp-server spawns per call), so nothing runs until invoked.` +
149
+ " An explicit NAN_MCP_TOOLS / NAN_MEDIA_MCP env var would override this toggle.",
150
+ "info",
151
+ );
152
+ } else {
153
+ ctx.ui.notify(
154
+ `Disabled ${describeBridges(bridges)} ${where}, ${persistence}. pi has no unregisterTool, so tools already registered in this session remain until restart (or /reload); future sessions will not register them.` +
155
+ " An explicit NAN_MCP_TOOLS / NAN_MEDIA_MCP env var overrides this toggle.",
156
+ "warning",
157
+ );
158
+ }
159
+ return;
160
+ }
161
+
162
+ ctx.ui.notify(USAGE, "warning");
163
+ },
164
+ });
165
+ }
package/src/index.ts CHANGED
@@ -21,8 +21,9 @@
21
21
  */
22
22
 
23
23
  import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
24
+ import { registerNanMcpCommand } from "./commands.ts";
24
25
  import { baselineModels } from "./fetch-models.ts";
25
- import { createNanWebSearchTool, mcpToolsDisabled, NAN_API_KEY_ENV } from "./mcp/nan-search.ts";
26
+ import { createNanWebSearchTool, webSearchBridgeEnabled, NAN_API_KEY_ENV } from "./mcp/nan-search.ts";
26
27
  import { createNanMediaTools, mediaMcpEnabled } from "./mcp/nan-media.ts";
27
28
  import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
28
29
  import { PROVIDERS } from "./providers.ts";
@@ -65,19 +66,35 @@ function registerProviderCompat(pi: ExtensionAPI, config: OpenAICompatibleProvid
65
66
  }
66
67
 
67
68
  /**
68
- * Register MCP-bridged tools when the runtime supports them. Old pi versions
69
- * without registerTool simply skip this block — provider registration is
70
- * unaffected.
69
+ * Register MCP-bridged tools when the runtime supports them. Both bridges
70
+ * are enabled by default and lazy (nothing runs until a tool is invoked);
71
+ * `/nan-mcp` (and the NAN_MCP_TOOLS / NAN_MEDIA_MCP env vars) toggle them.
72
+ * Old pi versions without registerTool/registerCommand skip gracefully.
73
+ *
74
+ * Registration is tracked in `registeredToolNames` so `/nan-mcp enable` can
75
+ * add tools mid-session without double-registering.
71
76
  */
72
77
  function registerMcpToolsCompat(pi: ExtensionAPI): void {
73
78
  if (typeof pi.registerTool !== "function") return;
74
- if (!mcpToolsDisabled()) {
75
- pi.registerTool(createNanWebSearchTool());
76
- }
77
- if (mediaMcpEnabled()) {
79
+ const registeredToolNames = new Set<string>();
80
+ const registerSearchTool = () => {
81
+ const search = createNanWebSearchTool();
82
+ if (registeredToolNames.has(search.name)) return;
83
+ registeredToolNames.add(search.name);
84
+ pi.registerTool(search);
85
+ };
86
+ const registerMediaTools = () => {
78
87
  for (const tool of createNanMediaTools()) {
88
+ if (registeredToolNames.has(tool.name)) continue;
89
+ registeredToolNames.add(tool.name);
79
90
  pi.registerTool(tool);
80
91
  }
92
+ };
93
+
94
+ if (webSearchBridgeEnabled()) registerSearchTool();
95
+ if (mediaMcpEnabled()) registerMediaTools();
96
+ if (typeof pi.registerCommand === "function") {
97
+ registerNanMcpCommand(pi, { registerWebSearchTools: registerSearchTool, registerMediaTools });
81
98
  }
82
99
  }
83
100
 
@@ -20,6 +20,7 @@
20
20
  import { Type, type TSchema } from "@earendil-works/pi-ai";
21
21
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
22
22
  import { callStdioMcpTool } from "./stdio-client.ts";
23
+ import { bridgeSource, resolveBridgeEnabled, type BridgeSource } from "./state.ts";
23
24
  import { NAN_API_KEY_ENV, resolveNanApiKey, type NapiKeyContext } from "./nan-search.ts";
24
25
 
25
26
  export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
@@ -38,15 +39,34 @@ export const NAN_MEDIA_TOOLS = [
38
39
  "nan_speech_to_text",
39
40
  ] as const;
40
41
 
41
- /** Truthy env parse: 1/true/on (case-insensitive). */
42
- function envEnabled(name: string): boolean {
43
- const value = process.env[name]?.trim().toLowerCase();
44
- return value === "1" || value === "true" || value === "on";
42
+ /** Truthy env parse for the media bridge env var: 1/true/on (case-insensitive). */
43
+ function envTruthy(): boolean {
44
+ return envValueIsTruthy(process.env[NAN_MEDIA_MCP_ENV]);
45
45
  }
46
46
 
47
- /** Whether the optional nan-mcp-server bridge is enabled (NAN_MEDIA_MCP=1). */
47
+ /** Whether the env var is explicitly set (any value) — it overrides the persisted toggle. */
48
+ function envExplicit(): boolean {
49
+ const value = process.env[NAN_MEDIA_MCP_ENV];
50
+ return value !== undefined && value.trim() !== "";
51
+ }
52
+
53
+ function envValueIsTruthy(value: string | undefined): boolean {
54
+ return value?.trim().toLowerCase() === "1" || value?.trim().toLowerCase() === "true" || value?.trim().toLowerCase() === "on";
55
+ }
56
+
57
+ /**
58
+ * Effective enablement of the community media MCP bridge: an explicit
59
+ * `NAN_MEDIA_MCP` env var (any value, e.g. `0` to force one session off)
60
+ * wins; then the toggle persisted by `/nan-mcp`; default: **enabled** (both
61
+ * bridges are on and lazy by default — the server spawns per tool call).
62
+ */
48
63
  export function mediaMcpEnabled(): boolean {
49
- return envEnabled(NAN_MEDIA_MCP_ENV);
64
+ return resolveBridgeEnabled("mediaMcp", envExplicit(), envTruthy(), true);
65
+ }
66
+
67
+ /** Source of the effective media enablement (env / persisted / default). */
68
+ export function mediaMcpSource(): BridgeSource {
69
+ return bridgeSource("mediaMcp", envExplicit());
50
70
  }
51
71
 
52
72
  /** Default spawn: `npx -y nan-mcp-server@<pinned version>` (npx caches after first use). */
@@ -15,12 +15,13 @@
15
15
 
16
16
  import { Type, type Static } from "@earendil-works/pi-ai";
17
17
  import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
18
+ import { bridgeSource, resolveBridgeEnabled, type BridgeSource } from "./state.ts";
18
19
 
19
20
  /** NaN's official remote MCP endpoint (host root, not /v1). */
20
21
  export const NAN_MCP_URL = "https://api.nan.builders/mcp";
21
22
  /** Web search can be slow; the endpoint shares the key's rate limits. */
22
23
  export const NAN_MCP_TIMEOUT_MS = 30_000;
23
- /** Env var that disables MCP tool registration: NAN_MCP_TOOLS=0|false|off. */
24
+ /** Env var that overrides the official web_search bridge: NAN_MCP_TOOLS=0|false|off disables it. */
24
25
  export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
25
26
 
26
27
  const NAN_PROVIDER_ID = "nan";
@@ -214,6 +215,26 @@ export function mcpToolsDisabled(): boolean {
214
215
  return value === "0" || value === "false" || value === "off";
215
216
  }
216
217
 
218
+ /** Whether NAN_MCP_TOOLS is explicitly set (any value) — it overrides the persisted toggle. */
219
+ export function mcpToolsEnvExplicit(): boolean {
220
+ const value = process.env[NAN_MCP_TOOLS_ENV];
221
+ return value !== undefined && value.trim() !== "";
222
+ }
223
+
224
+ /**
225
+ * Effective enablement of the official web_search bridge: explicit
226
+ * NAN_MCP_TOOLS env var wins (any value, e.g. 0 to force one session off);
227
+ * then the toggle persisted by /nan-mcp; default: enabled.
228
+ */
229
+ export function webSearchBridgeEnabled(): boolean {
230
+ return resolveBridgeEnabled("webSearch", mcpToolsEnvExplicit(), !mcpToolsDisabled(), true);
231
+ }
232
+
233
+ /** Source of the effective web_search enablement (env / persisted / default). */
234
+ export function webSearchBridgeSource(): BridgeSource {
235
+ return bridgeSource("webSearch", mcpToolsEnvExplicit());
236
+ }
237
+
217
238
  /** Extract an ExtensionContext-shaped key source for tests without full pi. */
218
239
  export function contextHasKeySource(ctx: ExtensionContext): NapiKeyContext {
219
240
  return ctx as unknown as NapiKeyContext;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Persisted toggles for the MCP bridges this package registers, stored in
3
+ * pi's agent dir (e.g. ~/.pi/agent/nan-provider.json) so `/nan-mcp` changes
4
+ * survive across sessions.
5
+ *
6
+ * Resolution order for each bridge:
7
+ * 1. Explicit bridge env var (any value, including 0 — one-session override).
8
+ * 2. Persisted toggle written by /nan-mcp.
9
+ * 3. Default: enabled (both bridges are on and lazy by default).
10
+ */
11
+
12
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
+
16
+ /** Persisted-toggle state file inside pi's agent dir. */
17
+ export const NAN_STATE_FILE = "nan-provider.json";
18
+
19
+ export interface NanProviderState {
20
+ /** Persisted enablement of the official web_search MCP bridge. */
21
+ webSearch?: boolean;
22
+ /** Persisted enablement of the community nan-mcp-server media bridge. */
23
+ mediaMcp?: boolean;
24
+ }
25
+
26
+ export type BridgeKey = keyof NanProviderState;
27
+
28
+ export type BridgeSource = "env" | "persisted" | "default";
29
+
30
+ function stateFilePath(): string {
31
+ return join(getAgentDir(), NAN_STATE_FILE);
32
+ }
33
+
34
+ /**
35
+ * Read the persisted state. Never throws: a missing or corrupted file yields
36
+ * an empty state (defaults apply).
37
+ */
38
+ export function readState(): NanProviderState {
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(stateFilePath(), "utf8")) as NanProviderState;
41
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /** Persist a patch over the current state. Creates the agent dir if needed. */
48
+ export function writeState(patch: Partial<NanProviderState>): void {
49
+ const path = stateFilePath();
50
+ mkdirSync(join(path, ".."), { recursive: true });
51
+ writeFileSync(path, `${JSON.stringify({ ...readState(), ...patch }, null, "\t")}\n`);
52
+ }
53
+
54
+ /** Persisted value for one bridge; `undefined` = not persisted (default applies). */
55
+ export function readBridgeState(bridge: BridgeKey): boolean | undefined {
56
+ const value = readState()[bridge];
57
+ return typeof value === "boolean" ? value : undefined;
58
+ }
59
+
60
+ export function writeBridgeState(bridge: BridgeKey, enabled: boolean): void {
61
+ writeState({ [bridge]: enabled });
62
+ }
63
+
64
+ /** Where the effective value of a bridge comes from (for /nan-mcp status). */
65
+ export function bridgeSource(bridge: BridgeKey, envExplicit: boolean): BridgeSource {
66
+ if (envExplicit) return "env";
67
+ return readBridgeState(bridge) !== undefined ? "persisted" : "default";
68
+ }
69
+
70
+ /** Effective value: explicit env → persisted → default. */
71
+ export function resolveBridgeEnabled(bridge: BridgeKey, envExplicit: boolean, envTruthy: boolean, defaultValue: boolean): boolean {
72
+ if (envExplicit) return envTruthy;
73
+ return readBridgeState(bridge) ?? defaultValue;
74
+ }