@gtrabanco/pi-nan-provider 0.7.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/AGENTS.md +70 -23
- package/README.es.md +67 -37
- package/README.md +66 -40
- package/package.json +5 -5
- package/scripts/check-nan-mcp-server.ts +11 -3
- package/scripts/check-pi-sdk-versions.mjs +392 -0
- package/scripts/generate-models.ts +64 -12
- package/scripts/manual-overrides.ts +20 -8
- package/scripts/models.generated.ts +55 -52
- package/src/commands.ts +132 -52
- package/src/context-overflow-classifier.ts +1 -1
- package/src/cross-model-thinking-guard.ts +1 -1
- package/src/index.ts +81 -41
- package/src/mcp/api-key.ts +76 -0
- package/src/mcp/media-server.ts +56 -0
- package/src/provider-factory.ts +7 -2
- package/src/usage.ts +352 -175
- package/src/mcp/nan-media.ts +0 -236
- package/src/mcp/nan-search.ts +0 -241
- package/src/mcp/stdio-client.ts +0 -222
package/src/mcp/nan-media.ts
DELETED
|
@@ -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
|
-
}
|
package/src/mcp/nan-search.ts
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bridge to NaN's official remote MCP server over plain HTTP — no MCP client
|
|
3
|
-
* needed in pi (pi intentionally ships without MCP support; see docs/usage.md).
|
|
4
|
-
*
|
|
5
|
-
* Endpoint (NaN OpenAPI spec, tag "MCP"): `https://api.nan.builders/mcp`
|
|
6
|
-
* (host root, NOT under `/v1`). Transport is streamable HTTP and stateless;
|
|
7
|
-
* protocol is JSON-RPC 2.0 (`initialize`, `tools/list`, `tools/call`, `ping`).
|
|
8
|
-
* Auth is the same `sk-` key as the REST API (`Authorization: Bearer`), and
|
|
9
|
-
* MCP calls share the key's rate limit, daily quota, and concurrency.
|
|
10
|
-
*
|
|
11
|
-
* Today the server exposes `web_search` (same arguments as `POST /v1/search`);
|
|
12
|
-
* it is a growing registry, so this module keeps a generic `callNanMcpTool`
|
|
13
|
-
* that can invoke any current or future tool by name.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { Type, type Static } from "@earendil-works/pi-ai";
|
|
17
|
-
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { bridgeSource, resolveBridgeEnabled, type BridgeSource } from "./state.ts";
|
|
19
|
-
|
|
20
|
-
/** NaN's official remote MCP endpoint (host root, not /v1). */
|
|
21
|
-
export const NAN_MCP_URL = "https://api.nan.builders/mcp";
|
|
22
|
-
/** Web search can be slow; the endpoint shares the key's rate limits. */
|
|
23
|
-
export const NAN_MCP_TIMEOUT_MS = 30_000;
|
|
24
|
-
/** Env var that overrides the official web_search bridge: NAN_MCP_TOOLS=0|false|off disables it. */
|
|
25
|
-
export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
|
|
26
|
-
|
|
27
|
-
const NAN_PROVIDER_ID = "nan";
|
|
28
|
-
export const NAN_API_KEY_ENV = "NAN_API_KEY";
|
|
29
|
-
|
|
30
|
-
export interface NapiKeyContext {
|
|
31
|
-
/** pi's model registry (checks the stored credential and env vars). */
|
|
32
|
-
modelRegistry?: { getApiKeyForProvider(provider: string): Promise<string | undefined> };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Resolve the NaN API key for tool execution: pi's registry first (it covers
|
|
37
|
-
* the stored auth.json credential and configured env sources), then a direct
|
|
38
|
-
* env fallback so the tools also work on pi versions without the registry.
|
|
39
|
-
* Never logs or embeds the key beyond the Authorization header.
|
|
40
|
-
*/
|
|
41
|
-
export async function resolveNanApiKey(ctx: NapiKeyContext): Promise<string | undefined> {
|
|
42
|
-
try {
|
|
43
|
-
const stored = await ctx.modelRegistry?.getApiKeyForProvider(NAN_PROVIDER_ID);
|
|
44
|
-
if (stored) return stored;
|
|
45
|
-
} catch {
|
|
46
|
-
// Registry unavailable (older pi) or provider not registered — env fallback below.
|
|
47
|
-
}
|
|
48
|
-
return process.env[NAN_API_KEY_ENV] || undefined;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface NanMcpToolCall {
|
|
52
|
-
/** MCP tool name, e.g. "web_search". */
|
|
53
|
-
name: string;
|
|
54
|
-
/** Tool arguments object (JSON-serializable). */
|
|
55
|
-
arguments?: Record<string, unknown>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface NanMcpToolResult {
|
|
59
|
-
/** true when the call completed without a tool-level or transport error. */
|
|
60
|
-
ok: boolean;
|
|
61
|
-
/** Concatenated text content returned by the tool. */
|
|
62
|
-
text: string;
|
|
63
|
-
/** Error description when ok is false. */
|
|
64
|
-
error?: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface NanMcpCallOptions {
|
|
68
|
-
apiKey?: string;
|
|
69
|
-
timeoutMs?: number;
|
|
70
|
-
fetchImpl?: typeof fetch;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function renderMcpContent(content: unknown): string {
|
|
74
|
-
if (!Array.isArray(content)) return "";
|
|
75
|
-
const parts: string[] = [];
|
|
76
|
-
for (const item of content) {
|
|
77
|
-
if (!item || typeof item !== "object") continue;
|
|
78
|
-
const block = item as { type?: string; text?: string };
|
|
79
|
-
if (block.type === "text" && typeof block.text === "string") {
|
|
80
|
-
parts.push(block.text);
|
|
81
|
-
} else if (block.type === "image") {
|
|
82
|
-
parts.push("[image returned by tool; see the saved file path in the text above if present]");
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return parts.join("\n\n");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Invoke a tool on NaN's official MCP server: POST a JSON-RPC 2.0
|
|
90
|
-
* `tools/call` and flatten the response content. Transport failures,
|
|
91
|
-
* JSON-RPC errors, and tool errors all surface as `{ ok: false }` with a
|
|
92
|
-
* message — never as a thrown error, so tool results degrade gracefully.
|
|
93
|
-
*/
|
|
94
|
-
export async function callNanMcpTool(
|
|
95
|
-
name: string,
|
|
96
|
-
args: Record<string, unknown> | undefined,
|
|
97
|
-
options: NanMcpCallOptions = {},
|
|
98
|
-
): Promise<NanMcpToolResult> {
|
|
99
|
-
const { apiKey, timeoutMs = NAN_MCP_TIMEOUT_MS, fetchImpl = fetch } = options;
|
|
100
|
-
if (!apiKey) return { ok: false, text: "", error: `${NAN_API_KEY_ENV} is not set — set it or run /login nan.` };
|
|
101
|
-
|
|
102
|
-
const controller = new AbortController();
|
|
103
|
-
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
104
|
-
try {
|
|
105
|
-
const response = await fetchImpl(NAN_MCP_URL, {
|
|
106
|
-
method: "POST",
|
|
107
|
-
headers: {
|
|
108
|
-
Authorization: `Bearer ${apiKey}`,
|
|
109
|
-
"Content-Type": "application/json",
|
|
110
|
-
Accept: "application/json",
|
|
111
|
-
},
|
|
112
|
-
body: JSON.stringify({
|
|
113
|
-
jsonrpc: "2.0",
|
|
114
|
-
id: 1,
|
|
115
|
-
method: "tools/call",
|
|
116
|
-
params: { name, arguments: args ?? {} },
|
|
117
|
-
}),
|
|
118
|
-
signal: controller.signal,
|
|
119
|
-
});
|
|
120
|
-
if (!response.ok) {
|
|
121
|
-
return {
|
|
122
|
-
ok: false,
|
|
123
|
-
text: "",
|
|
124
|
-
error: `NaN MCP returned HTTP ${response.status}${response.status === 401 ? " — check your NaN API key" : ""}`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
const payload = (await response.json()) as {
|
|
128
|
-
result?: { content?: unknown; isError?: boolean };
|
|
129
|
-
error?: { code?: number; message?: string };
|
|
130
|
-
};
|
|
131
|
-
if (payload.error) {
|
|
132
|
-
return { ok: false, text: "", error: `NaN MCP error ${payload.error.code ?? ""}: ${payload.error.message ?? "unknown"}` };
|
|
133
|
-
}
|
|
134
|
-
const text = renderMcpContent(payload.result?.content);
|
|
135
|
-
if (payload.result?.isError) {
|
|
136
|
-
return { ok: false, text, error: text || "NaN MCP tool reported an error" };
|
|
137
|
-
}
|
|
138
|
-
return { ok: true, text: text || "(empty response)" };
|
|
139
|
-
} catch (error) {
|
|
140
|
-
const aborted = controller.signal.aborted;
|
|
141
|
-
return {
|
|
142
|
-
ok: false,
|
|
143
|
-
text: "",
|
|
144
|
-
error: aborted
|
|
145
|
-
? `NaN MCP call timed out after ${timeoutMs}ms`
|
|
146
|
-
: `NaN MCP call failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
147
|
-
};
|
|
148
|
-
} finally {
|
|
149
|
-
clearTimeout(timeoutId);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const webSearchParameters = Type.Object({
|
|
154
|
-
query: Type.String({ description: "The search query." }),
|
|
155
|
-
count: Type.Optional(
|
|
156
|
-
Type.Integer({
|
|
157
|
-
description: "Number of results to return, 1-20 (values outside the range are clamped). Default 5.",
|
|
158
|
-
minimum: 1,
|
|
159
|
-
maximum: 20,
|
|
160
|
-
}),
|
|
161
|
-
),
|
|
162
|
-
freshness: Type.Optional(
|
|
163
|
-
Type.String({
|
|
164
|
-
description:
|
|
165
|
-
"Recency filter: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or a 'YYYY-MM-DDtoYYYY-MM-DD' date range. Omit for no time filter.",
|
|
166
|
-
}),
|
|
167
|
-
),
|
|
168
|
-
fetch_content: Type.Optional(
|
|
169
|
-
Type.Boolean({
|
|
170
|
-
description:
|
|
171
|
-
"When true, also fetch and include the readable main text of the top results. Slower; defaults to snippets only.",
|
|
172
|
-
}),
|
|
173
|
-
),
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
export type NanWebSearchParams = Static<typeof webSearchParameters>;
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* pi tool bridging NaN's official MCP `web_search`. Registered by default
|
|
180
|
-
* when the runtime supports registerTool; disable with NAN_MCP_TOOLS=0.
|
|
181
|
-
*
|
|
182
|
-
* Failure convention per pi's AgentTool contract: execute() throws on
|
|
183
|
-
* failure instead of encoding errors in content — the runtime surfaces the
|
|
184
|
-
* error to the model so it can self-correct (e.g. ask the user for a key).
|
|
185
|
-
*/
|
|
186
|
-
export function createNanWebSearchTool(): ToolDefinition<typeof webSearchParameters> {
|
|
187
|
-
return {
|
|
188
|
-
name: "nan_web_search",
|
|
189
|
-
label: "NaN Web Search",
|
|
190
|
-
description:
|
|
191
|
-
"Web search via NaN's remote MCP server (api.nan.builders/mcp). Returns ranked web results with snippets; requires NAN_API_KEY. Same rate limit and quota as the REST /v1/search endpoint.",
|
|
192
|
-
promptSnippet: "nan_web_search(query, count?, freshness?, fetch_content?): web search via the NaN MCP server",
|
|
193
|
-
parameters: webSearchParameters,
|
|
194
|
-
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
195
|
-
const apiKey = await resolveNanApiKey(contextHasKeySource(ctx));
|
|
196
|
-
if (!apiKey) {
|
|
197
|
-
throw new Error(
|
|
198
|
-
`${NAN_API_KEY_ENV} is not set. Export it (export ${NAN_API_KEY_ENV}="sk-...") or run /login nan.`,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
const result = await callNanMcpTool(
|
|
202
|
-
"web_search",
|
|
203
|
-
params as Record<string, unknown>,
|
|
204
|
-
{ apiKey, timeoutMs: NAN_MCP_TIMEOUT_MS },
|
|
205
|
-
);
|
|
206
|
-
if (!result.ok) throw new Error(result.error ?? "NaN MCP call failed");
|
|
207
|
-
return { content: [{ type: "text", text: result.text }], details: undefined } as const;
|
|
208
|
-
},
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Whether MCP tool registration is disabled via env (NAN_MCP_TOOLS=0|false|off). */
|
|
213
|
-
export function mcpToolsDisabled(): boolean {
|
|
214
|
-
const value = process.env[NAN_MCP_TOOLS_ENV]?.trim().toLowerCase();
|
|
215
|
-
return value === "0" || value === "false" || value === "off";
|
|
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
|
-
|
|
238
|
-
/** Extract an ExtensionContext-shaped key source for tests without full pi. */
|
|
239
|
-
export function contextHasKeySource(ctx: ExtensionContext): NapiKeyContext {
|
|
240
|
-
return ctx as unknown as NapiKeyContext;
|
|
241
|
-
}
|
package/src/mcp/stdio-client.ts
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal MCP (Model Context Protocol) stdio client — just enough protocol to
|
|
3
|
-
* call one tool per spawned process, so bridging stdio MCP servers is lazy by
|
|
4
|
-
* construction: nothing starts, connects, or costs anything until a tool is
|
|
5
|
-
* actually invoked.
|
|
6
|
-
*
|
|
7
|
-
* Protocol: JSON-RPC 2.0 over the child's stdin/stdout, one message per line
|
|
8
|
-
* (MCP stdio transport). Handshake: `initialize` request → `initialized`
|
|
9
|
-
* notification → request. The child process is terminated after the call, so
|
|
10
|
-
* each invocation pays a fresh spawn (~npx overhead on first use, cached
|
|
11
|
-
* afterwards) in exchange for zero idle cost and no daemon management.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
15
|
-
|
|
16
|
-
/** Oldest stable protocol version; accepted by every MCP SDK server. */
|
|
17
|
-
const PROTOCOL_VERSION = "2024-11-05";
|
|
18
|
-
const CLIENT_INFO = { name: "pi-nan-provider", version: "0.2.0" };
|
|
19
|
-
|
|
20
|
-
export interface StdioMcpCallOptions {
|
|
21
|
-
/** Command to spawn, e.g. ["npx", "-y", "nan-mcp-server@1.1.2"]. */
|
|
22
|
-
command: readonly string[];
|
|
23
|
-
/** Extra environment for the child (merged over process.env). */
|
|
24
|
-
env?: Record<string, string | undefined>;
|
|
25
|
-
/** Kill the child and fail the call after this long. */
|
|
26
|
-
timeoutMs?: number;
|
|
27
|
-
/** Cooperative cancellation from the host tool call. */
|
|
28
|
-
signal?: AbortSignal;
|
|
29
|
-
/** Working directory for the child. */
|
|
30
|
-
cwd?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface StdioMcpToolResult {
|
|
34
|
-
/** true when the tool call completed without a tool-level or transport error. */
|
|
35
|
-
ok: boolean;
|
|
36
|
-
/** Concatenated text content returned by the tool. */
|
|
37
|
-
text: string;
|
|
38
|
-
/** Error description when ok is false. */
|
|
39
|
-
error?: string;
|
|
40
|
-
/** Server stderr tail, attached to failures for diagnosis. */
|
|
41
|
-
stderrTail?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface JsonRpcMessage {
|
|
45
|
-
jsonrpc?: string;
|
|
46
|
-
id?: number | string | null;
|
|
47
|
-
method?: string;
|
|
48
|
-
params?: unknown;
|
|
49
|
-
result?: {
|
|
50
|
-
content?: Array<{ type?: string; text?: string }>;
|
|
51
|
-
isError?: boolean;
|
|
52
|
-
tools?: unknown[];
|
|
53
|
-
};
|
|
54
|
-
error?: { code?: number; message?: string };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function renderContent(result: JsonRpcMessage["result"]): string {
|
|
58
|
-
const parts: string[] = [];
|
|
59
|
-
for (const item of result?.content ?? []) {
|
|
60
|
-
if (item?.type === "text" && typeof item.text === "string") {
|
|
61
|
-
parts.push(item.text);
|
|
62
|
-
} else if (item?.type === "image") {
|
|
63
|
-
parts.push("[image returned by tool]");
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return parts.join("\n\n");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Spawn the MCP server, run the handshake, call one tool, and terminate the
|
|
71
|
-
* process. Never throws — every failure mode resolves to
|
|
72
|
-
* `{ ok: false, error }` so callers can decide how to surface it.
|
|
73
|
-
*/
|
|
74
|
-
export async function callStdioMcpTool(
|
|
75
|
-
toolName: string,
|
|
76
|
-
toolArguments: Record<string, unknown> | undefined,
|
|
77
|
-
options: StdioMcpCallOptions,
|
|
78
|
-
): Promise<StdioMcpToolResult> {
|
|
79
|
-
const { command, env, timeoutMs = 120_000, signal, cwd } = options;
|
|
80
|
-
|
|
81
|
-
if (command.length === 0) {
|
|
82
|
-
return { ok: false, text: "", error: "No MCP server command configured" };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return new Promise<StdioMcpToolResult>((resolve) => {
|
|
86
|
-
let child: ReturnType<typeof spawn>;
|
|
87
|
-
try {
|
|
88
|
-
child = spawn(command[0]!, command.slice(1), {
|
|
89
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
90
|
-
env: { ...process.env, ...env },
|
|
91
|
-
...(cwd ? { cwd } : {}),
|
|
92
|
-
});
|
|
93
|
-
} catch (error) {
|
|
94
|
-
resolve({
|
|
95
|
-
ok: false,
|
|
96
|
-
text: "",
|
|
97
|
-
error: `Failed to start MCP server: ${error instanceof Error ? error.message : String(error)}`,
|
|
98
|
-
});
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
let settled = false;
|
|
103
|
-
let stdoutBuffer = "";
|
|
104
|
-
let stderr = "";
|
|
105
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
-
let lastResponse: JsonRpcMessage | undefined;
|
|
107
|
-
|
|
108
|
-
const cleanup = () => {
|
|
109
|
-
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
110
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
111
|
-
try {
|
|
112
|
-
child.stdin?.end();
|
|
113
|
-
} catch {
|
|
114
|
-
// stdin already closed
|
|
115
|
-
}
|
|
116
|
-
if (!child.killed) {
|
|
117
|
-
child.kill("SIGTERM");
|
|
118
|
-
}
|
|
119
|
-
// Escalate if the server ignores SIGTERM.
|
|
120
|
-
const escalate = setTimeout(() => child.kill("SIGKILL"), 1_000);
|
|
121
|
-
child.once("exit", () => clearTimeout(escalate));
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const finish = (result: StdioMcpToolResult) => {
|
|
125
|
-
if (settled) return;
|
|
126
|
-
settled = true;
|
|
127
|
-
cleanup();
|
|
128
|
-
resolve(result);
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const onAbort = () => finish({ ok: false, text: "", error: "MCP tool call aborted" });
|
|
132
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
133
|
-
|
|
134
|
-
const fail = (error: string): void => {
|
|
135
|
-
finish({
|
|
136
|
-
ok: false,
|
|
137
|
-
text: "",
|
|
138
|
-
error,
|
|
139
|
-
...(stderr ? { stderrTail: stderr.split("\n").slice(-5).join("\n").slice(-500) } : {}),
|
|
140
|
-
});
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
timeoutId = setTimeout(() => {
|
|
144
|
-
fail(`MCP server did not answer within ${timeoutMs}ms`);
|
|
145
|
-
}, timeoutMs);
|
|
146
|
-
|
|
147
|
-
const send = (message: object) => {
|
|
148
|
-
child.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
child.stdout?.setEncoding("utf8");
|
|
152
|
-
child.stdout?.on("data", (chunk: string) => {
|
|
153
|
-
stdoutBuffer += chunk;
|
|
154
|
-
let newlineIndex = stdoutBuffer.indexOf("\n");
|
|
155
|
-
while (newlineIndex !== -1) {
|
|
156
|
-
const line = stdoutBuffer.slice(0, newlineIndex).trim();
|
|
157
|
-
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
158
|
-
if (line.length > 0) {
|
|
159
|
-
try {
|
|
160
|
-
handleLine(JSON.parse(line) as JsonRpcMessage);
|
|
161
|
-
} catch {
|
|
162
|
-
// Non-JSON stdout line — tolerate server banners/logs.
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
newlineIndex = stdoutBuffer.indexOf("\n");
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
child.stderr?.setEncoding("utf8");
|
|
170
|
-
child.stderr?.on("data", (chunk: string) => {
|
|
171
|
-
stderr += chunk;
|
|
172
|
-
if (stderr.length > 8_000) stderr = stderr.slice(-4_000);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
child.once("error", (error) => {
|
|
176
|
-
fail(`MCP server process error: ${error instanceof Error ? error.message : String(error)}`);
|
|
177
|
-
});
|
|
178
|
-
child.once("exit", (code, exitSignal) => {
|
|
179
|
-
if (!settled) fail(`MCP server exited early (code=${code ?? "null"}, signal=${exitSignal ?? "null"})`);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const handleLine = (message: JsonRpcMessage) => {
|
|
183
|
-
if (message.id === 1) {
|
|
184
|
-
// initialize response → complete handshake, then call the tool.
|
|
185
|
-
if (message.error) {
|
|
186
|
-
fail(`MCP handshake failed: ${message.error.message ?? "unknown error"}`);
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
190
|
-
send({
|
|
191
|
-
jsonrpc: "2.0",
|
|
192
|
-
id: 2,
|
|
193
|
-
method: "tools/call",
|
|
194
|
-
params: { name: toolName, arguments: toolArguments ?? {} },
|
|
195
|
-
});
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
if (message.id === 2) {
|
|
199
|
-
lastResponse = message;
|
|
200
|
-
if (message.error) {
|
|
201
|
-
fail(`MCP tool error ${message.error.code ?? ""}: ${message.error.message ?? "unknown"}`);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
const text = renderContent(message.result);
|
|
205
|
-
if (message.result?.isError) {
|
|
206
|
-
finish({ ok: false, text, error: text || "MCP tool reported an error" });
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
finish({ ok: true, text: text || "(empty response)" });
|
|
210
|
-
}
|
|
211
|
-
// Other lines (notifications, keep-alives) are ignored.
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
// Handshake start.
|
|
215
|
-
send({
|
|
216
|
-
jsonrpc: "2.0",
|
|
217
|
-
id: 1,
|
|
218
|
-
method: "initialize",
|
|
219
|
-
params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO },
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
}
|