@pencil-agent/nano-pencil 1.11.12 → 1.11.13

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.
@@ -0,0 +1,16 @@
1
+ import type { OAuthCredentials } from "@pencil-agent/ai";
2
+ export type FigmaOAuthCredentials = OAuthCredentials & {
3
+ clientId: string;
4
+ clientSecret?: string;
5
+ tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post" | "none";
6
+ scope?: string;
7
+ source?: string;
8
+ };
9
+ export type FigmaImportableSession = {
10
+ source: string;
11
+ expiresAt: number;
12
+ };
13
+ export declare function findImportableFigmaOAuthSession(): FigmaImportableSession | undefined;
14
+ export declare function refreshFigmaOAuthCredentials(credentials: FigmaOAuthCredentials): Promise<FigmaOAuthCredentials>;
15
+ export declare function registerFigmaMcpOAuthProvider(): void;
16
+ //# sourceMappingURL=figma-auth.d.ts.map
@@ -0,0 +1,503 @@
1
+ import { registerOAuthProvider } from "@pencil-agent/ai";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { exec } from "node:child_process";
7
+ const FIGMA_MCP_URL = "https://mcp.figma.com/mcp";
8
+ const FIGMA_RESOURCE_METADATA_URL = "https://mcp.figma.com/.well-known/oauth-protected-resource";
9
+ const FIGMA_AUTH_METADATA_URL = "https://api.figma.com/.well-known/oauth-authorization-server";
10
+ const FIGMA_AUTH_URL = "https://www.figma.com/oauth/mcp";
11
+ const FIGMA_TOKEN_URL = "https://api.figma.com/v1/oauth/token";
12
+ const FIGMA_REGISTER_URL = "https://api.figma.com/v1/oauth/mcp/register";
13
+ const FIGMA_SCOPE = "mcp:connect";
14
+ const FIGMA_CLIENT_NAME = "NanoPencil";
15
+ const FIGMA_CLIENT_URI = "https://github.com/pencil-agent/nano-pencil";
16
+ const FIGMA_CLIENT_METADATA_URL = "https://raw.githubusercontent.com/pencil-agent/nano-pencil/main/README.md";
17
+ const CLAUDE_CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
18
+ const CALLBACK_HOST = "127.0.0.1";
19
+ const CALLBACK_PATH = "/auth/callback";
20
+ const CALLBACK_PORT_CANDIDATES = [14565, 14566, 14567, 14568, 14569];
21
+ const SUCCESS_HTML = `<!doctype html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="utf-8" />
25
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
26
+ <title>Figma authentication successful</title>
27
+ </head>
28
+ <body>
29
+ <p>Figma authentication completed. Return to NanoPencil.</p>
30
+ </body>
31
+ </html>`;
32
+ const FAILURE_HTML = `<!doctype html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="utf-8" />
36
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
37
+ <title>Figma authentication failed</title>
38
+ </head>
39
+ <body>
40
+ <p>Figma authentication failed. You can close this window and retry from NanoPencil.</p>
41
+ </body>
42
+ </html>`;
43
+ function base64urlEncode(bytes) {
44
+ let binary = "";
45
+ for (const byte of bytes) {
46
+ binary += String.fromCharCode(byte);
47
+ }
48
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
49
+ }
50
+ async function generatePKCE() {
51
+ const verifierBytes = new Uint8Array(32);
52
+ crypto.getRandomValues(verifierBytes);
53
+ const verifier = base64urlEncode(verifierBytes);
54
+ const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
55
+ const challenge = base64urlEncode(new Uint8Array(hashBuffer));
56
+ return { verifier, challenge };
57
+ }
58
+ function openBrowser(url) {
59
+ const command = process.platform === "darwin"
60
+ ? `open "${url}"`
61
+ : process.platform === "win32"
62
+ ? `start "" "${url}"`
63
+ : `xdg-open "${url}"`;
64
+ exec(command, () => {
65
+ // Ignore launch failures. The TUI already shows the URL.
66
+ });
67
+ }
68
+ function createState() {
69
+ const bytes = new Uint8Array(16);
70
+ crypto.getRandomValues(bytes);
71
+ return Buffer.from(bytes).toString("hex");
72
+ }
73
+ function normalizeImportedCredentials(entry, source) {
74
+ if (!entry.accessToken || !entry.refreshToken || !entry.clientId) {
75
+ return undefined;
76
+ }
77
+ return {
78
+ access: entry.accessToken,
79
+ refresh: entry.refreshToken,
80
+ expires: typeof entry.expiresAt === "number" ? entry.expiresAt : 0,
81
+ clientId: entry.clientId,
82
+ clientSecret: entry.clientSecret,
83
+ tokenEndpointAuthMethod: entry.clientSecret ? "client_secret_basic" : "none",
84
+ scope: entry.stepUpScope || FIGMA_SCOPE,
85
+ source,
86
+ };
87
+ }
88
+ function loadClaudeFigmaCredentials() {
89
+ if (!existsSync(CLAUDE_CREDENTIALS_PATH)) {
90
+ return undefined;
91
+ }
92
+ try {
93
+ const parsed = JSON.parse(readFileSync(CLAUDE_CREDENTIALS_PATH, "utf-8"));
94
+ const entries = Object.values(parsed.mcpOAuth ?? {})
95
+ .filter((entry) => entry.serverUrl === FIGMA_MCP_URL)
96
+ .map((entry) => normalizeImportedCredentials(entry, "Claude Code"))
97
+ .filter((entry) => !!entry)
98
+ .sort((a, b) => b.expires - a.expires);
99
+ return entries[0];
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ export function findImportableFigmaOAuthSession() {
106
+ const imported = loadClaudeFigmaCredentials();
107
+ if (!imported) {
108
+ return undefined;
109
+ }
110
+ return {
111
+ source: imported.source || "Claude Code",
112
+ expiresAt: imported.expires,
113
+ };
114
+ }
115
+ function loadConfiguredClientInformation() {
116
+ const clientId = process.env.NANOPENCIL_FIGMA_CLIENT_ID?.trim();
117
+ if (!clientId) {
118
+ return undefined;
119
+ }
120
+ const clientSecret = process.env.NANOPENCIL_FIGMA_CLIENT_SECRET?.trim() || undefined;
121
+ const tokenEndpointAuthMethod = clientSecret ? "client_secret_basic" : "none";
122
+ return {
123
+ clientId,
124
+ clientSecret,
125
+ tokenEndpointAuthMethod,
126
+ source: "environment",
127
+ };
128
+ }
129
+ async function requestRefresh(credentials, authMethod) {
130
+ const headers = {
131
+ "Content-Type": "application/x-www-form-urlencoded",
132
+ Accept: "application/json",
133
+ };
134
+ const body = new URLSearchParams({
135
+ grant_type: "refresh_token",
136
+ refresh_token: credentials.refresh,
137
+ resource: FIGMA_MCP_URL,
138
+ });
139
+ if (authMethod === "client_secret_basic") {
140
+ if (!credentials.clientSecret) {
141
+ return undefined;
142
+ }
143
+ const basic = Buffer.from(`${credentials.clientId}:${credentials.clientSecret}`, "utf8").toString("base64");
144
+ headers.Authorization = `Basic ${basic}`;
145
+ }
146
+ else {
147
+ body.set("client_id", credentials.clientId);
148
+ if (authMethod === "client_secret_post" && credentials.clientSecret) {
149
+ body.set("client_secret", credentials.clientSecret);
150
+ }
151
+ }
152
+ const response = await fetch(FIGMA_TOKEN_URL, {
153
+ method: "POST",
154
+ headers,
155
+ body,
156
+ });
157
+ if (!response.ok) {
158
+ return undefined;
159
+ }
160
+ const json = (await response.json());
161
+ if (!json.access_token || typeof json.expires_in !== "number") {
162
+ return undefined;
163
+ }
164
+ return {
165
+ ...credentials,
166
+ access: json.access_token,
167
+ refresh: json.refresh_token || credentials.refresh,
168
+ expires: Date.now() + json.expires_in * 1000,
169
+ };
170
+ }
171
+ export async function refreshFigmaOAuthCredentials(credentials) {
172
+ const preferred = credentials.tokenEndpointAuthMethod ?? (credentials.clientSecret ? "client_secret_basic" : "none");
173
+ const attempts = preferred === "client_secret_basic"
174
+ ? ["client_secret_basic", "client_secret_post", "none"]
175
+ : preferred === "client_secret_post"
176
+ ? ["client_secret_post", "client_secret_basic", "none"]
177
+ : ["none", "client_secret_basic", "client_secret_post"];
178
+ for (const method of attempts) {
179
+ const refreshed = await requestRefresh(credentials, method);
180
+ if (refreshed) {
181
+ return {
182
+ ...refreshed,
183
+ tokenEndpointAuthMethod: method,
184
+ };
185
+ }
186
+ }
187
+ throw new Error("Failed to refresh Figma MCP OAuth credentials");
188
+ }
189
+ async function fetchAuthorizationServerMetadata() {
190
+ const response = await fetch(FIGMA_AUTH_METADATA_URL, {
191
+ headers: {
192
+ Accept: "application/json",
193
+ },
194
+ });
195
+ if (!response.ok) {
196
+ throw new Error(`Failed to fetch Figma authorization metadata (${response.status} ${response.statusText})`);
197
+ }
198
+ return (await response.json());
199
+ }
200
+ async function registerNanoPencilClient(redirectUri, metadata) {
201
+ if (!metadata.registration_endpoint) {
202
+ return undefined;
203
+ }
204
+ const response = await fetch(metadata.registration_endpoint, {
205
+ method: "POST",
206
+ headers: {
207
+ "Content-Type": "application/json",
208
+ Accept: "application/json",
209
+ },
210
+ body: JSON.stringify({
211
+ client_name: FIGMA_CLIENT_NAME,
212
+ redirect_uris: [redirectUri],
213
+ grant_types: ["authorization_code", "refresh_token"],
214
+ response_types: ["code"],
215
+ token_endpoint_auth_method: "client_secret_basic",
216
+ client_uri: FIGMA_CLIENT_URI,
217
+ client_metadata_url: FIGMA_CLIENT_METADATA_URL,
218
+ scope: FIGMA_SCOPE,
219
+ }),
220
+ });
221
+ if (!response.ok) {
222
+ return undefined;
223
+ }
224
+ const json = (await response.json());
225
+ if (!json.client_id) {
226
+ return undefined;
227
+ }
228
+ return {
229
+ clientId: json.client_id,
230
+ clientSecret: json.client_secret,
231
+ tokenEndpointAuthMethod: json.token_endpoint_auth_method || (json.client_secret ? "client_secret_basic" : "none"),
232
+ source: "dynamic-registration",
233
+ };
234
+ }
235
+ function parseAuthorizationInput(input) {
236
+ const value = input.trim();
237
+ if (!value)
238
+ return {};
239
+ try {
240
+ const url = new URL(value);
241
+ return {
242
+ code: url.searchParams.get("code") ?? undefined,
243
+ state: url.searchParams.get("state") ?? undefined,
244
+ };
245
+ }
246
+ catch {
247
+ // not a URL
248
+ }
249
+ if (value.includes("code=")) {
250
+ const params = new URLSearchParams(value);
251
+ return {
252
+ code: params.get("code") ?? undefined,
253
+ state: params.get("state") ?? undefined,
254
+ };
255
+ }
256
+ return { code: value };
257
+ }
258
+ async function startCallbackServer(signal) {
259
+ let lastResult = null;
260
+ let cancelled = false;
261
+ for (const port of CALLBACK_PORT_CANDIDATES) {
262
+ try {
263
+ const server = await new Promise((resolve, reject) => {
264
+ const created = createServer((req, res) => {
265
+ try {
266
+ const url = new URL(req.url || "", `http://${CALLBACK_HOST}:${port}`);
267
+ if (url.pathname !== CALLBACK_PATH) {
268
+ res.statusCode = 404;
269
+ res.end("Not found");
270
+ return;
271
+ }
272
+ const code = url.searchParams.get("code");
273
+ const state = url.searchParams.get("state");
274
+ const error = url.searchParams.get("error");
275
+ if (code && state) {
276
+ lastResult = { code, state };
277
+ res.statusCode = 200;
278
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
279
+ res.end(SUCCESS_HTML);
280
+ return;
281
+ }
282
+ res.statusCode = 400;
283
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
284
+ res.end(FAILURE_HTML + (error ? `\n<!-- ${error} -->` : ""));
285
+ }
286
+ catch {
287
+ res.statusCode = 500;
288
+ res.end("Internal error");
289
+ }
290
+ });
291
+ created.once("error", reject);
292
+ created.listen(port, CALLBACK_HOST, () => {
293
+ created.removeListener("error", reject);
294
+ resolve(created);
295
+ });
296
+ });
297
+ signal?.addEventListener("abort", () => {
298
+ cancelled = true;
299
+ server.close();
300
+ }, { once: true });
301
+ return {
302
+ server,
303
+ redirectUri: `http://${CALLBACK_HOST}:${port}${CALLBACK_PATH}`,
304
+ cancelWait: () => {
305
+ cancelled = true;
306
+ },
307
+ waitForCode: async () => {
308
+ const sleep = () => new Promise((resolve) => setTimeout(resolve, 100));
309
+ for (let i = 0; i < 1800; i += 1) {
310
+ if (lastResult)
311
+ return lastResult;
312
+ if (cancelled)
313
+ return null;
314
+ await sleep();
315
+ }
316
+ return null;
317
+ },
318
+ };
319
+ }
320
+ catch {
321
+ // Try the next port.
322
+ }
323
+ }
324
+ throw new Error("Failed to start a local OAuth callback server for Figma");
325
+ }
326
+ function buildAuthorizationUrl(client, redirectUri, state, challenge, scope, metadata) {
327
+ const url = new URL(metadata.authorization_endpoint || FIGMA_AUTH_URL);
328
+ url.searchParams.set("response_type", "code");
329
+ url.searchParams.set("client_id", client.clientId);
330
+ url.searchParams.set("redirect_uri", redirectUri);
331
+ url.searchParams.set("scope", scope);
332
+ url.searchParams.set("code_challenge", challenge);
333
+ url.searchParams.set("code_challenge_method", "S256");
334
+ url.searchParams.set("state", state);
335
+ url.searchParams.set("resource", FIGMA_MCP_URL);
336
+ return url.toString();
337
+ }
338
+ async function exchangeAuthorizationCode(code, verifier, redirectUri, client) {
339
+ const method = client.tokenEndpointAuthMethod ?? (client.clientSecret ? "client_secret_basic" : "none");
340
+ const headers = {
341
+ "Content-Type": "application/x-www-form-urlencoded",
342
+ Accept: "application/json",
343
+ };
344
+ const body = new URLSearchParams({
345
+ grant_type: "authorization_code",
346
+ code,
347
+ code_verifier: verifier,
348
+ redirect_uri: redirectUri,
349
+ resource: FIGMA_MCP_URL,
350
+ });
351
+ if (method === "client_secret_basic") {
352
+ if (!client.clientSecret) {
353
+ throw new Error("Figma OAuth client is missing client_secret for client_secret_basic");
354
+ }
355
+ headers.Authorization = `Basic ${Buffer.from(`${client.clientId}:${client.clientSecret}`, "utf8").toString("base64")}`;
356
+ }
357
+ else {
358
+ body.set("client_id", client.clientId);
359
+ if (method === "client_secret_post" && client.clientSecret) {
360
+ body.set("client_secret", client.clientSecret);
361
+ }
362
+ }
363
+ const response = await fetch(FIGMA_TOKEN_URL, {
364
+ method: "POST",
365
+ headers,
366
+ body,
367
+ });
368
+ if (!response.ok) {
369
+ const detail = await response.text().catch(() => "");
370
+ throw new Error(`Figma authorization code exchange failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
371
+ }
372
+ const json = (await response.json());
373
+ if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
374
+ throw new Error("Figma OAuth token response did not include the expected fields");
375
+ }
376
+ return {
377
+ access: json.access_token,
378
+ refresh: json.refresh_token,
379
+ expires: Date.now() + json.expires_in * 1000,
380
+ clientId: client.clientId,
381
+ clientSecret: client.clientSecret,
382
+ tokenEndpointAuthMethod: method,
383
+ scope: FIGMA_SCOPE,
384
+ source: client.source || "nanopencil",
385
+ };
386
+ }
387
+ async function loginWithStandaloneOAuth(callbacks) {
388
+ callbacks.onProgress?.("Preparing a standalone Figma OAuth flow...");
389
+ const metadata = await fetchAuthorizationServerMetadata();
390
+ const callback = await startCallbackServer(callbacks.signal);
391
+ try {
392
+ const configuredClient = loadConfiguredClientInformation();
393
+ const registeredClient = configuredClient || (await registerNanoPencilClient(callback.redirectUri, metadata));
394
+ if (!registeredClient) {
395
+ throw new Error("NanoPencil could not complete Figma dynamic client registration. Set NANOPENCIL_FIGMA_CLIENT_ID and NANOPENCIL_FIGMA_CLIENT_SECRET, or keep using the import fallback for now.");
396
+ }
397
+ const { verifier, challenge } = await generatePKCE();
398
+ const state = createState();
399
+ const scope = metadata.scopes_supported?.includes(FIGMA_SCOPE) ? FIGMA_SCOPE : FIGMA_SCOPE;
400
+ const authorizationUrl = buildAuthorizationUrl(registeredClient, callback.redirectUri, state, challenge, scope, metadata);
401
+ callbacks.onAuth({
402
+ url: authorizationUrl,
403
+ instructions: "A browser window should open. Approve Figma access to finish linking NanoPencil.",
404
+ });
405
+ openBrowser(authorizationUrl);
406
+ let code;
407
+ if (callbacks.onManualCodeInput) {
408
+ let manualInput;
409
+ let manualError;
410
+ const manualPromise = callbacks.onManualCodeInput()
411
+ .then((input) => {
412
+ manualInput = input;
413
+ callback.cancelWait();
414
+ })
415
+ .catch((error) => {
416
+ manualError = error instanceof Error ? error : new Error(String(error));
417
+ callback.cancelWait();
418
+ });
419
+ const result = await callback.waitForCode();
420
+ if (manualError) {
421
+ throw manualError;
422
+ }
423
+ if (result?.code) {
424
+ if (result.state !== state) {
425
+ throw new Error("Figma OAuth state mismatch");
426
+ }
427
+ code = result.code;
428
+ }
429
+ else if (manualInput) {
430
+ const parsed = parseAuthorizationInput(manualInput);
431
+ if (parsed.state && parsed.state !== state) {
432
+ throw new Error("Figma OAuth state mismatch");
433
+ }
434
+ code = parsed.code;
435
+ }
436
+ if (!code) {
437
+ await manualPromise;
438
+ if (manualError) {
439
+ throw manualError;
440
+ }
441
+ if (manualInput) {
442
+ const parsed = parseAuthorizationInput(manualInput);
443
+ if (parsed.state && parsed.state !== state) {
444
+ throw new Error("Figma OAuth state mismatch");
445
+ }
446
+ code = parsed.code;
447
+ }
448
+ }
449
+ }
450
+ else {
451
+ const result = await callback.waitForCode();
452
+ if (result?.state && result.state !== state) {
453
+ throw new Error("Figma OAuth state mismatch");
454
+ }
455
+ code = result?.code;
456
+ }
457
+ if (!code) {
458
+ throw new Error("Figma OAuth did not complete");
459
+ }
460
+ callbacks.onProgress?.("Finishing Figma authorization...");
461
+ return await exchangeAuthorizationCode(code, verifier, callback.redirectUri, registeredClient);
462
+ }
463
+ finally {
464
+ callback.server.close();
465
+ }
466
+ }
467
+ async function loginFigma(callbacks) {
468
+ try {
469
+ return await loginWithStandaloneOAuth(callbacks);
470
+ }
471
+ catch (standaloneError) {
472
+ callbacks.onProgress?.("Standalone Figma OAuth was unavailable. Checking for an importable official session...");
473
+ const imported = loadClaudeFigmaCredentials();
474
+ if (!imported) {
475
+ throw standaloneError;
476
+ }
477
+ callbacks.onProgress?.(`Refreshing the imported Figma session from ${imported.source}...`);
478
+ return await refreshFigmaOAuthCredentials(imported);
479
+ }
480
+ }
481
+ const figmaOAuthProvider = {
482
+ id: "figma",
483
+ name: "Figma MCP",
484
+ usesCallbackServer: true,
485
+ async login(callbacks) {
486
+ return await loginFigma(callbacks);
487
+ },
488
+ async refreshToken(credentials) {
489
+ return await refreshFigmaOAuthCredentials(credentials);
490
+ },
491
+ getApiKey(credentials) {
492
+ return String(credentials.access ?? "");
493
+ },
494
+ };
495
+ let registered = false;
496
+ export function registerFigmaMcpOAuthProvider() {
497
+ if (registered) {
498
+ return;
499
+ }
500
+ registerOAuthProvider(figmaOAuthProvider);
501
+ registered = true;
502
+ }
503
+ //# sourceMappingURL=figma-auth.js.map
@@ -10,13 +10,23 @@ export interface MCPServerConfig {
10
10
  /** Display name */
11
11
  name: string;
12
12
  /** Command to start the server (e.g., "npx", "uvx") */
13
- command: string;
13
+ command?: string;
14
14
  /** Arguments to pass to the command */
15
- args: string[];
15
+ args?: string[];
16
+ /** Streamable HTTP endpoint for remote/local HTTP MCP servers */
17
+ url?: string;
18
+ /** Additional headers for HTTP MCP servers */
19
+ headers?: Record<string, string>;
20
+ /** Credential provider id stored in auth.json for HTTP MCP servers */
21
+ authProvider?: string;
22
+ /** Header name to use when authProvider resolves a token */
23
+ authHeaderName?: string;
24
+ /** Header auth scheme. "bearer" prefixes the token, "raw" passes it as-is */
25
+ authScheme?: "bearer" | "raw";
16
26
  /** Environment variables to pass */
17
27
  env?: Record<string, string>;
18
- /** Transport type: "stdio" or "sse" */
19
- transport?: "stdio" | "sse";
28
+ /** Transport type: "stdio", "sse", or "http" */
29
+ transport?: "stdio" | "sse" | "http";
20
30
  /** Whether this server is enabled */
21
31
  enabled?: boolean;
22
32
  /** Tool call timeout in milliseconds (default: 20000) */
@@ -58,6 +68,8 @@ export declare class MCPClient {
58
68
  private servers;
59
69
  private serverRuntimes;
60
70
  private serverTools;
71
+ private httpSessions;
72
+ private authStorage;
61
73
  constructor();
62
74
  /**
63
75
  * Load MCP server configurations from config file
@@ -89,6 +101,11 @@ export declare class MCPClient {
89
101
  private writeFramedMessage;
90
102
  private sendNotification;
91
103
  private sendRequest;
104
+ private getHttpSession;
105
+ private buildHttpHeaders;
106
+ private ensureHttpInitialized;
107
+ private sendHttpRequest;
108
+ private parseEventStreamResponse;
92
109
  private initializeServer;
93
110
  private normalizeToolRecord;
94
111
  private loadToolsForServer;
@@ -112,6 +129,7 @@ export declare class MCPClient {
112
129
  * Call an MCP tool
113
130
  */
114
131
  callTool(toolName: string, args: Record<string, unknown>, timeoutMs?: number): Promise<MCPToolResult>;
132
+ private callHttpTool;
115
133
  /**
116
134
  * Call tool via stdio (JSON-RPC)
117
135
  */