@gtrabanco/pi-nan-provider 0.8.0 → 0.10.2

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.
@@ -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
- }
@@ -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
- }