pi-figma-remote-auth 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DianP
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # pi-figma-remote-auth
2
+
3
+ A Pi extension that handles **OAuth authentication and MCP config** for Figma's hosted Remote MCP server (`https://mcp.figma.com/mcp`), so that an existing [`pi-mcp-adapter`](https://github.com/nicobailon/pi-mcp-adapter) install can expose Figma tools inside Pi.
4
+
5
+ ## What this does
6
+
7
+ In short, it does two things:
8
+
9
+ 1. **Writes the MCP server config** (`mcpServers.figma`) into Pi's MCP config file, pointing at Figma's remote endpoint with `auth: "oauth"`.
10
+ 2. **Runs the OAuth flow** against Figma and saves the resulting tokens in `pi-mcp-adapter`'s auth store, so the adapter can connect without re-prompting.
11
+
12
+ The OAuth step uses a workaround for Figma's MCP client allowlist: the dynamic client registration registers as `client_name: "Codex"` (which Figma allows), then the credentials are stored in the format `pi-mcp-adapter` expects. Same idea as [`mcp-auth-helper`](https://github.com/sdaoudi/mcp-auth-helper), but writing into the Pi adapter's storage instead of OpenCode's.
13
+
14
+ This is **not** the local Figma desktop MCP bridge — it only configures the remote endpoint.
15
+
16
+ ## Prerequisite
17
+
18
+ Install `pi-mcp-adapter` first:
19
+
20
+ ```sh
21
+ pi install npm:pi-mcp-adapter
22
+ ```
23
+
24
+ Restart Pi.
25
+
26
+ ## Install this package
27
+
28
+ From npm:
29
+
30
+ ```sh
31
+ pi install npm:pi-figma-remote-auth
32
+ ```
33
+
34
+ Or from a local checkout:
35
+
36
+ ```sh
37
+ pi install /absolute/path/to/pi-figma-remote-auth
38
+ ```
39
+
40
+ Or for one run, without installing:
41
+
42
+ ```sh
43
+ pi -e npm:pi-figma-remote-auth
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ Inside Pi:
49
+
50
+ ```text
51
+ /figma-remote-auth setup
52
+ /figma-remote-auth login
53
+ ```
54
+
55
+ During `login`, Pi prints the Figma authorization URL. Copy it from the notification and open it in your browser — the extension does **not** auto-open and Pi's TUI may not render the URL as clickable. After authorizing, restart Pi (or `/mcp reconnect figma`) so `pi-mcp-adapter` reloads MCP config.
56
+
57
+ Use Figma tools through the adapter proxy:
58
+
59
+ ```ts
60
+ mcp({ search: "figma" });
61
+ mcp({ connect: "figma" });
62
+ mcp({ tool: "figma_TOOL_NAME", args: "{}" });
63
+ ```
64
+
65
+ ## Commands
66
+
67
+ ```text
68
+ /figma-remote-auth help
69
+ /figma-remote-auth status [--server figma]
70
+ /figma-remote-auth setup [--global|--project|--shared] [--server figma] [--url https://mcp.figma.com/mcp] [--direct-tools] [--keep-alive]
71
+ /figma-remote-auth login [--server figma] [--url https://mcp.figma.com/mcp] [--client-name Codex] [--port <port>]
72
+ /figma-remote-auth logout [--server figma]
73
+ ```
74
+
75
+ `setup` and `login` first check that `pi-mcp-adapter` exists. If missing, install it before continuing.
76
+
77
+ By default, `login` uses a random free localhost callback port so it does not collide with `pi-mcp-adapter`'s own OAuth callback server. Use `--port` only when you need a fixed callback URL.
78
+
79
+ Config targets:
80
+
81
+ - `--global` (default): writes `~/.pi/agent/mcp.json` or `$PI_CODING_AGENT_DIR/mcp.json`
82
+ - `--project`: writes `.pi/mcp.json`
83
+ - `--shared`: writes `.mcp.json`
84
+
85
+ Token store (written by `login`, removed by `logout`):
86
+
87
+ - `$MCP_OAUTH_DIR/figma/tokens.json` when `$MCP_OAUTH_DIR` is set
88
+ - otherwise `~/.pi/agent/mcp-oauth/figma/tokens.json` or `$PI_CODING_AGENT_DIR/mcp-oauth/figma/tokens.json`
89
+
90
+ ## Security
91
+
92
+ Tokens are written with file mode `0600`. Pi packages execute code with local system access; review before installing.
package/index.ts ADDED
@@ -0,0 +1,197 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ assertMcpAdapterExists,
5
+ hasMcpAdapter,
6
+ hasMcpAdapterExecutable,
7
+ } from "./src/adapter.js";
8
+ import {
9
+ type Command,
10
+ DEFAULT_FIGMA_MCP_URL,
11
+ completionsFor,
12
+ parseArgs,
13
+ } from "./src/args.js";
14
+ import {
15
+ configContainsServer,
16
+ getKnownConfigPaths,
17
+ writeMcpConfig,
18
+ } from "./src/config.js";
19
+ import {
20
+ getAuthFilePath,
21
+ readAuthEntry,
22
+ removeAuthFile,
23
+ } from "./src/auth-store.js";
24
+ import { runOAuthFlow } from "./src/oauth.js";
25
+ import { formatUserError } from "./src/util.js";
26
+
27
+ const COMMAND_NAME = "figma-remote-auth";
28
+
29
+ export default function figmaRemoteAuthExtension(pi: ExtensionAPI) {
30
+ pi.registerCommand(COMMAND_NAME, {
31
+ description: "Setup/auth Figma Remote MCP for pi-mcp-adapter",
32
+ getArgumentCompletions: (prefix) => completionsFor(prefix),
33
+ handler: async (args, ctx) => {
34
+ let command: Command;
35
+ try {
36
+ command = parseArgs(args);
37
+ } catch (error) {
38
+ ctx.ui.notify(formatUserError(error), "error");
39
+ return;
40
+ }
41
+
42
+ try {
43
+ await dispatch(pi, ctx, command);
44
+ } catch (error) {
45
+ ctx.ui.notify(formatUserError(error), "error");
46
+ }
47
+ },
48
+ });
49
+ }
50
+
51
+ type CommandContext = Parameters<
52
+ NonNullable<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>
53
+ >[1];
54
+
55
+ async function dispatch(
56
+ pi: ExtensionAPI,
57
+ ctx: CommandContext,
58
+ command: Command,
59
+ ): Promise<void> {
60
+ switch (command.kind) {
61
+ case "help":
62
+ ctx.ui.notify(helpText(), "info");
63
+ return;
64
+
65
+ case "status":
66
+ ctx.ui.notify(buildStatus(pi, command.serverName, ctx.cwd), "info");
67
+ return;
68
+
69
+ case "setup": {
70
+ assertMcpAdapterExists(pi);
71
+ const path = writeMcpConfig(command, ctx.cwd);
72
+ ctx.ui.notify(
73
+ [
74
+ `Figma Remote MCP config written: ${path}`,
75
+ `Restart Pi, then use: mcp({ connect: "${command.serverName}" })`,
76
+ ].join("\n"),
77
+ "info",
78
+ );
79
+ return;
80
+ }
81
+
82
+ case "login": {
83
+ assertMcpAdapterExists(pi);
84
+ const confirmed = await ctx.ui.confirm(
85
+ "Figma Remote MCP OAuth",
86
+ [
87
+ `Start OAuth for "${command.serverName}" at ${command.url}?`,
88
+ "",
89
+ "Pi will show a copyable/clickable authorization URL.",
90
+ "Browser will not auto-open.",
91
+ "",
92
+ `Client name: ${command.clientName}`,
93
+ `Token store: ${getAuthFilePath(command.serverName)}`,
94
+ ].join("\n"),
95
+ );
96
+ if (!confirmed) return;
97
+
98
+ ctx.ui.notify("Starting Figma OAuth flow…", "info");
99
+ ctx.ui.setStatus(COMMAND_NAME, "Waiting for Figma OAuth callback…");
100
+ try {
101
+ const authPath = await runOAuthFlow({
102
+ serverName: command.serverName,
103
+ url: command.url,
104
+ clientName: command.clientName,
105
+ callbackPort: command.callbackPort,
106
+ signal: ctx.signal,
107
+ onAuthorizationUrl: (authorizationUrl, callbackUrl) => {
108
+ ctx.ui.notify(
109
+ [
110
+ "Open this URL in your browser to authorize Figma:",
111
+ authorizationUrl,
112
+ "",
113
+ `Waiting for callback on ${callbackUrl}`,
114
+ ].join("\n"),
115
+ "info",
116
+ );
117
+ console.log(
118
+ `\nFigma MCP authorization URL:\n${authorizationUrl}\n\nCallback URL: ${callbackUrl}\n`,
119
+ );
120
+ },
121
+ });
122
+ ctx.ui.notify(
123
+ [
124
+ `Figma OAuth credentials saved: ${authPath}`,
125
+ `Restart Pi or run /mcp reconnect ${command.serverName}.`,
126
+ ].join("\n"),
127
+ "info",
128
+ );
129
+ } finally {
130
+ ctx.ui.setStatus(COMMAND_NAME, undefined);
131
+ }
132
+ return;
133
+ }
134
+
135
+ case "logout": {
136
+ const removed = removeAuthFile(command.serverName);
137
+ ctx.ui.notify(
138
+ removed
139
+ ? `Cleared Figma OAuth credentials for "${command.serverName}".`
140
+ : `No Figma OAuth credentials found for "${command.serverName}".`,
141
+ "info",
142
+ );
143
+ return;
144
+ }
145
+ }
146
+ }
147
+
148
+ function helpText(): string {
149
+ return [
150
+ "Figma Remote MCP for pi-mcp-adapter",
151
+ "",
152
+ `/${COMMAND_NAME} status [--server <name>]`,
153
+ `/${COMMAND_NAME} setup [--global|--project|--shared] [--direct-tools] [--keep-alive]`,
154
+ `/${COMMAND_NAME} login [--client-name Codex] [--port <port>]`,
155
+ `/${COMMAND_NAME} logout [--server <name>]`,
156
+ "",
157
+ `Default endpoint: ${DEFAULT_FIGMA_MCP_URL}`,
158
+ "Default config target: --global (<Pi agent dir>/mcp.json)",
159
+ ].join("\n");
160
+ }
161
+
162
+ function buildStatus(
163
+ pi: ExtensionAPI,
164
+ serverName: string,
165
+ cwd: string,
166
+ ): string {
167
+ const configHits = getKnownConfigPaths(cwd).filter((path) =>
168
+ configContainsServer(path, serverName),
169
+ );
170
+ const authPath = getAuthFilePath(serverName);
171
+ const auth = readAuthEntry(authPath);
172
+ const adapterPresent = hasMcpAdapter(pi);
173
+ const adapterInstalled = adapterPresent || hasMcpAdapterExecutable();
174
+
175
+ const lines = [
176
+ `Figma Remote MCP status (server: "${serverName}")`,
177
+ `pi-mcp-adapter installed: ${adapterInstalled ? "yes" : "no"}`,
178
+ `pi-mcp-adapter loaded now: ${adapterPresent ? "yes" : "no"}`,
179
+ `config entries: ${configHits.length ? configHits.join(", ") : "none"}`,
180
+ `auth file: ${existsSync(authPath) ? authPath : "missing"}`,
181
+ ];
182
+
183
+ if (auth?.tokens?.expiresAt) {
184
+ const expires = new Date(auth.tokens.expiresAt * 1000).toISOString();
185
+ const valid = auth.tokens.expiresAt > Math.floor(Date.now() / 1000) + 60;
186
+ lines.push(
187
+ `token expires: ${expires} (${valid ? "valid" : "expired/near expiry"})`,
188
+ );
189
+ } else if (auth?.tokens?.accessToken) {
190
+ lines.push("token expires: unknown (no expires_in)");
191
+ }
192
+ if (auth?.serverUrl) {
193
+ lines.push(`auth server URL: ${auth.serverUrl}`);
194
+ }
195
+
196
+ return lines.join("\n");
197
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "pi-figma-remote-auth",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that authenticates and configures Figma Remote MCP for pi-mcp-adapter.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "DianP",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "figma",
12
+ "mcp",
13
+ "remote-mcp",
14
+ "pi-mcp-adapter",
15
+ "oauth"
16
+ ],
17
+ "homepage": "https://github.com/dianp/pi-figma-remote-auth#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/dianp/pi-figma-remote-auth/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/dianp/pi-figma-remote-auth.git"
24
+ },
25
+ "engines": {
26
+ "node": ">=22.6.0"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "scripts": {
34
+ "pretest": "node --experimental-strip-types tests/ensure-symlinks.ts",
35
+ "test": "tsc --noEmit",
36
+ "prepublishOnly": "npm test"
37
+ },
38
+ "files": [
39
+ "index.ts",
40
+ "src",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "pi-mcp-adapter": ">=2.5.4"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "pi-mcp-adapter": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "devDependencies": {
54
+ "@earendil-works/pi-coding-agent": "latest",
55
+ "@types/node": "^20.0.0",
56
+ "typescript": "^5.0.0"
57
+ }
58
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+
5
+ export function hasMcpAdapter(pi: ExtensionAPI): boolean {
6
+ try {
7
+ if (pi.getAllTools().some((tool) => tool.name === "mcp")) return true;
8
+ return pi
9
+ .getCommands()
10
+ .some((command) => command.name === "mcp" || command.name === "mcp-auth");
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ export function hasMcpAdapterExecutable(): boolean {
17
+ const pathValue = process.env.PATH ?? "";
18
+ const executableNames =
19
+ process.platform === "win32"
20
+ ? ["pi-mcp-adapter.cmd", "pi-mcp-adapter.exe", "pi-mcp-adapter"]
21
+ : ["pi-mcp-adapter"];
22
+
23
+ for (const dir of pathValue.split(delimiter)) {
24
+ if (!dir) continue;
25
+ for (const name of executableNames) {
26
+ if (existsSync(join(dir, name))) return true;
27
+ }
28
+ }
29
+
30
+ return false;
31
+ }
32
+
33
+ export function assertMcpAdapterExists(pi: ExtensionAPI): void {
34
+ if (hasMcpAdapter(pi) || hasMcpAdapterExecutable()) return;
35
+ throw new Error(
36
+ "pi-mcp-adapter not found. Install it first with `pi install npm:pi-mcp-adapter`, restart Pi, then retry.",
37
+ );
38
+ }
package/src/args.ts ADDED
@@ -0,0 +1,272 @@
1
+ export type ConfigTarget = "global" | "project" | "shared";
2
+
3
+ export type Command =
4
+ | { kind: "help" }
5
+ | { kind: "status"; serverName: string }
6
+ | {
7
+ kind: "setup";
8
+ serverName: string;
9
+ url: string;
10
+ target: ConfigTarget;
11
+ directTools: boolean;
12
+ keepAlive: boolean;
13
+ }
14
+ | {
15
+ kind: "login";
16
+ serverName: string;
17
+ url: string;
18
+ clientName: string;
19
+ callbackPort: number;
20
+ }
21
+ | { kind: "logout"; serverName: string };
22
+
23
+ export type CommandKind = Command["kind"];
24
+
25
+ const SUBCOMMANDS = ["help", "status", "setup", "login", "logout"] as const;
26
+ type SubcommandLiteral = (typeof SUBCOMMANDS)[number];
27
+
28
+ export const DEFAULT_SERVER_NAME = "figma";
29
+ export const DEFAULT_FIGMA_MCP_URL = "https://mcp.figma.com/mcp";
30
+ export const DEFAULT_CLIENT_NAME = "Codex";
31
+ export const DEFAULT_CALLBACK_PORT = 0;
32
+
33
+ const FLAG_DESCRIPTIONS: Record<string, string> = {
34
+ "--global": "Write config to <Pi agent dir>/mcp.json (default)",
35
+ "--project": "Write config to .pi/mcp.json (project-local)",
36
+ "--shared": "Write config to .mcp.json (shared/checked-in)",
37
+ "--server": "Server name in mcpServers (default: figma)",
38
+ "--url": "Remote MCP endpoint (default: https://mcp.figma.com/mcp)",
39
+ "--client-name": "OAuth client_name to register (default: Codex)",
40
+ "--port": "Fixed callback port (default: random free)",
41
+ "--direct-tools": "Expose tools directly instead of via the proxy",
42
+ "--keep-alive": "Keep the MCP connection open instead of lazy",
43
+ };
44
+
45
+ const SUBCOMMAND_DESCRIPTIONS: Record<SubcommandLiteral, string> = {
46
+ help: "Show help",
47
+ status: "Show current install/config/auth status",
48
+ setup: "Write Figma Remote MCP entry into Pi's mcp.json",
49
+ login: "Run OAuth and save tokens for pi-mcp-adapter",
50
+ logout: "Remove the saved OAuth tokens",
51
+ };
52
+
53
+ const FLAGS_BY_SUBCOMMAND: Record<SubcommandLiteral, readonly string[]> = {
54
+ help: [],
55
+ status: ["--server"],
56
+ setup: [
57
+ "--global",
58
+ "--project",
59
+ "--shared",
60
+ "--server",
61
+ "--url",
62
+ "--direct-tools",
63
+ "--keep-alive",
64
+ ],
65
+ login: ["--server", "--url", "--client-name", "--port"],
66
+ logout: ["--server"],
67
+ };
68
+
69
+ export interface CompletionItem {
70
+ value: string;
71
+ label: string;
72
+ description?: string;
73
+ }
74
+
75
+ export function parseArgs(input: string): Command {
76
+ const tokens = tokenize(input.trim());
77
+ const subcommandToken = (tokens.shift() ?? "help") as string;
78
+
79
+ if (!isSubcommand(subcommandToken)) {
80
+ throw new Error(
81
+ `Unknown subcommand: "${subcommandToken}". Try: ${SUBCOMMANDS.join(", ")}`,
82
+ );
83
+ }
84
+
85
+ const flags = collectFlags(tokens, subcommandToken);
86
+
87
+ switch (subcommandToken) {
88
+ case "help":
89
+ return { kind: "help" };
90
+ case "status":
91
+ return {
92
+ kind: "status",
93
+ serverName: flags.serverName ?? DEFAULT_SERVER_NAME,
94
+ };
95
+ case "setup":
96
+ return {
97
+ kind: "setup",
98
+ serverName: flags.serverName ?? DEFAULT_SERVER_NAME,
99
+ url: validateUrl(flags.url ?? DEFAULT_FIGMA_MCP_URL),
100
+ target: flags.target ?? "global",
101
+ directTools: flags.directTools ?? false,
102
+ keepAlive: flags.keepAlive ?? false,
103
+ };
104
+ case "login":
105
+ return {
106
+ kind: "login",
107
+ serverName: flags.serverName ?? DEFAULT_SERVER_NAME,
108
+ url: validateUrl(flags.url ?? DEFAULT_FIGMA_MCP_URL),
109
+ clientName: flags.clientName ?? DEFAULT_CLIENT_NAME,
110
+ callbackPort: flags.callbackPort ?? DEFAULT_CALLBACK_PORT,
111
+ };
112
+ case "logout":
113
+ return {
114
+ kind: "logout",
115
+ serverName: flags.serverName ?? DEFAULT_SERVER_NAME,
116
+ };
117
+ }
118
+ }
119
+
120
+ export function completionsFor(prefix: string): CompletionItem[] | null {
121
+ const tokens = tokenize(prefix.replace(/\s+$/, ""));
122
+ const trailingSpace = /\s$/.test(prefix);
123
+ const subcommandToken = tokens[0];
124
+ const subcommand = isSubcommand(subcommandToken) ? subcommandToken : null;
125
+ const last = trailingSpace ? "" : (tokens.at(-1) ?? "");
126
+
127
+ const completingSubcommand = tokens.length <= 1 && !trailingSpace;
128
+ const candidates = completingSubcommand
129
+ ? [...SUBCOMMANDS]
130
+ : subcommand
131
+ ? [...FLAGS_BY_SUBCOMMAND[subcommand]]
132
+ : [];
133
+
134
+ const items: CompletionItem[] = candidates
135
+ .filter((value) => value.startsWith(last))
136
+ .map((value) => ({
137
+ value,
138
+ label: value,
139
+ description: completingSubcommand
140
+ ? SUBCOMMAND_DESCRIPTIONS[value as SubcommandLiteral]
141
+ : FLAG_DESCRIPTIONS[value],
142
+ }));
143
+
144
+ return items.length > 0 ? items : null;
145
+ }
146
+
147
+ interface CollectedFlags {
148
+ serverName?: string;
149
+ url?: string;
150
+ clientName?: string;
151
+ callbackPort?: number;
152
+ target?: ConfigTarget;
153
+ directTools?: boolean;
154
+ keepAlive?: boolean;
155
+ }
156
+
157
+ function collectFlags(
158
+ tokens: string[],
159
+ subcommand: SubcommandLiteral,
160
+ ): CollectedFlags {
161
+ const allowed = new Set(FLAGS_BY_SUBCOMMAND[subcommand]);
162
+ const flags: CollectedFlags = {};
163
+
164
+ for (let i = 0; i < tokens.length; i++) {
165
+ const token = tokens[i];
166
+ if (!token.startsWith("--")) {
167
+ throw new Error(`Unexpected argument: ${token}`);
168
+ }
169
+ if (!allowed.has(token)) {
170
+ throw new Error(rejectFlagMessage(token, subcommand));
171
+ }
172
+
173
+ switch (token) {
174
+ case "--global":
175
+ flags.target = "global";
176
+ break;
177
+ case "--project":
178
+ flags.target = "project";
179
+ break;
180
+ case "--shared":
181
+ flags.target = "shared";
182
+ break;
183
+ case "--direct-tools":
184
+ flags.directTools = true;
185
+ break;
186
+ case "--keep-alive":
187
+ flags.keepAlive = true;
188
+ break;
189
+ case "--server":
190
+ flags.serverName = validateServerName(
191
+ requireValue(tokens, ++i, token),
192
+ );
193
+ break;
194
+ case "--url":
195
+ flags.url = requireValue(tokens, ++i, token);
196
+ break;
197
+ case "--client-name":
198
+ flags.clientName = requireValue(tokens, ++i, token);
199
+ break;
200
+ case "--port":
201
+ flags.callbackPort = parsePort(requireValue(tokens, ++i, token));
202
+ break;
203
+ default:
204
+ throw new Error(`Unknown option: ${token}`);
205
+ }
206
+ }
207
+
208
+ return flags;
209
+ }
210
+
211
+ function rejectFlagMessage(
212
+ flag: string,
213
+ subcommand: SubcommandLiteral,
214
+ ): string {
215
+ const allowed = FLAGS_BY_SUBCOMMAND[subcommand];
216
+ if (!allowed.length)
217
+ return `${flag} is not valid for "${subcommand}" (no options accepted)`;
218
+ return `${flag} is not valid for "${subcommand}". Allowed: ${allowed.join(", ")}`;
219
+ }
220
+
221
+ function isSubcommand(value: string | undefined): value is SubcommandLiteral {
222
+ return !!value && (SUBCOMMANDS as readonly string[]).includes(value);
223
+ }
224
+
225
+ function tokenize(input: string): string[] {
226
+ const result: string[] = [];
227
+ const pattern =
228
+ /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
229
+ let match: RegExpExecArray | null;
230
+ while ((match = pattern.exec(input)) !== null) {
231
+ result.push(
232
+ (match[1] ?? match[2] ?? match[3]).replace(/\\(["'\\])/g, "$1"),
233
+ );
234
+ }
235
+ return result;
236
+ }
237
+
238
+ function requireValue(tokens: string[], index: number, option: string): string {
239
+ const value = tokens[index];
240
+ if (!value || value.startsWith("--"))
241
+ throw new Error(`${option} requires a value`);
242
+ return value;
243
+ }
244
+
245
+ function parsePort(value: string): number {
246
+ const port = Number.parseInt(value, 10);
247
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
248
+ throw new Error(`Invalid port: ${value}`);
249
+ return port;
250
+ }
251
+
252
+ function validateServerName(serverName: string): string {
253
+ if (!/^[A-Za-z0-9_.-]+$/.test(serverName)) {
254
+ throw new Error(
255
+ "Server name may contain only letters, numbers, dot, underscore, and dash",
256
+ );
257
+ }
258
+ return serverName;
259
+ }
260
+
261
+ function validateUrl(value: string): string {
262
+ let parsed: URL;
263
+ try {
264
+ parsed = new URL(value);
265
+ } catch {
266
+ throw new Error(`Invalid --url: ${value}`);
267
+ }
268
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
269
+ throw new Error(`--url must be http(s): ${value}`);
270
+ }
271
+ return parsed.href;
272
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { expandHome, getAgentPath } from "./paths.js";
11
+
12
+ export interface RegisteredClient {
13
+ clientId: string;
14
+ clientSecret?: string;
15
+ clientIdIssuedAt?: number;
16
+ clientSecretExpiresAt?: number;
17
+ }
18
+
19
+ export interface TokenResponse {
20
+ access_token: string;
21
+ refresh_token?: string;
22
+ expires_in?: number;
23
+ scope?: string;
24
+ token_type?: string;
25
+ }
26
+
27
+ export interface AdapterAuthEntry {
28
+ tokens?: {
29
+ accessToken: string;
30
+ refreshToken?: string;
31
+ expiresAt?: number;
32
+ scope?: string;
33
+ };
34
+ clientInfo?: RegisteredClient;
35
+ codeVerifier?: string;
36
+ oauthState?: string;
37
+ serverUrl?: string;
38
+ }
39
+
40
+ export function getAuthFilePath(serverName: string): string {
41
+ const base = process.env.MCP_OAUTH_DIR?.trim() || getAgentPath("mcp-oauth");
42
+ return join(expandHome(base), serverName, "tokens.json");
43
+ }
44
+
45
+ export function saveAdapterAuth(
46
+ serverName: string,
47
+ serverUrl: string,
48
+ client: RegisteredClient,
49
+ tokens: TokenResponse,
50
+ ): string {
51
+ const now = Math.floor(Date.now() / 1000);
52
+ const entry: AdapterAuthEntry = {
53
+ tokens: {
54
+ accessToken: tokens.access_token,
55
+ refreshToken: tokens.refresh_token,
56
+ expiresAt: tokens.expires_in ? now + tokens.expires_in : undefined,
57
+ scope: tokens.scope,
58
+ },
59
+ clientInfo: client,
60
+ serverUrl,
61
+ };
62
+
63
+ const authPath = getAuthFilePath(serverName);
64
+ mkdirSync(dirname(authPath), { recursive: true, mode: 0o700 });
65
+ writeFileSync(authPath, `${JSON.stringify(entry, null, 2)}\n`, {
66
+ encoding: "utf8",
67
+ mode: 0o600,
68
+ });
69
+ try {
70
+ chmodSync(authPath, 0o600);
71
+ } catch {
72
+ // Best effort on platforms without POSIX chmod.
73
+ }
74
+ return authPath;
75
+ }
76
+
77
+ export function readAuthEntry(path: string): AdapterAuthEntry | undefined {
78
+ if (!existsSync(path)) return undefined;
79
+ try {
80
+ return JSON.parse(readFileSync(path, "utf8")) as AdapterAuthEntry;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ export function removeAuthFile(serverName: string): boolean {
87
+ const authPath = getAuthFilePath(serverName);
88
+ if (!existsSync(authPath)) return false;
89
+ rmSync(authPath, { force: true });
90
+ return true;
91
+ }
package/src/config.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { ConfigTarget } from "./args.js";
4
+ import { getAgentPath } from "./paths.js";
5
+ import { asRecord, readJsonObject } from "./util.js";
6
+
7
+ export interface WriteMcpConfigOptions {
8
+ serverName: string;
9
+ url: string;
10
+ target: ConfigTarget;
11
+ directTools: boolean;
12
+ keepAlive: boolean;
13
+ }
14
+
15
+ export function writeMcpConfig(
16
+ options: WriteMcpConfigOptions,
17
+ cwd: string,
18
+ ): string {
19
+ const configPath = getMcpConfigPath(options.target, cwd);
20
+ const config = readJsonObject(configPath);
21
+ const mcpServers = asRecord(config.mcpServers);
22
+ const server = {
23
+ url: options.url,
24
+ auth: "oauth",
25
+ lifecycle: options.keepAlive ? "keep-alive" : "lazy",
26
+ exposeResources: true,
27
+ directTools: options.directTools,
28
+ };
29
+
30
+ mcpServers[options.serverName] = server;
31
+ config.mcpServers = mcpServers;
32
+
33
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
34
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
35
+ return configPath;
36
+ }
37
+
38
+ export function getMcpConfigPath(target: ConfigTarget, cwd: string): string {
39
+ switch (target) {
40
+ case "global":
41
+ return getAgentPath("mcp.json");
42
+ case "project":
43
+ return join(cwd, ".pi", "mcp.json");
44
+ case "shared":
45
+ return join(cwd, ".mcp.json");
46
+ }
47
+ }
48
+
49
+ export function getKnownConfigPaths(cwd: string): string[] {
50
+ return [
51
+ getAgentPath("mcp.json"),
52
+ join(cwd, ".pi", "mcp.json"),
53
+ join(cwd, ".mcp.json"),
54
+ ];
55
+ }
56
+
57
+ export function configContainsServer(
58
+ path: string,
59
+ serverName: string,
60
+ ): boolean {
61
+ if (!existsSync(path)) return false;
62
+ try {
63
+ const config = JSON.parse(readFileSync(path, "utf8")) as Record<
64
+ string,
65
+ unknown
66
+ >;
67
+ return Boolean(asRecord(config.mcpServers)[serverName]);
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,342 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import {
5
+ type RegisteredClient,
6
+ type TokenResponse,
7
+ saveAdapterAuth,
8
+ } from "./auth-store.js";
9
+ import {
10
+ base64Url,
11
+ fetchJson,
12
+ optionalNumberField,
13
+ optionalStringField,
14
+ stringField,
15
+ } from "./util.js";
16
+
17
+ const CALLBACK_PATH = "/callback";
18
+ const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
19
+
20
+ export interface OAuthFlowOptions {
21
+ serverName: string;
22
+ url: string;
23
+ clientName: string;
24
+ callbackPort: number;
25
+ signal?: AbortSignal;
26
+ onAuthorizationUrl: (authorizationUrl: string, callbackUrl: string) => void;
27
+ }
28
+
29
+ export async function runOAuthFlow(options: OAuthFlowOptions): Promise<string> {
30
+ const { codeVerifier, codeChallenge } = generatePkce();
31
+ const state = base64Url(randomBytes(32));
32
+ const callbackListener = await waitForCallback(
33
+ options.callbackPort,
34
+ state,
35
+ options.signal,
36
+ );
37
+ const redirectUri = callbackListener.callbackUrl;
38
+
39
+ try {
40
+ const endpoints = await discoverOAuthEndpoints(options.url, options.signal);
41
+ if (!endpoints.registrationEndpoint) {
42
+ throw new Error("OAuth metadata did not include a registration_endpoint");
43
+ }
44
+
45
+ const client = await registerClient(
46
+ endpoints.registrationEndpoint,
47
+ redirectUri,
48
+ options.clientName,
49
+ options.signal,
50
+ );
51
+ const authorizationUrl = buildAuthorizationUrl(
52
+ endpoints.authorizationEndpoint,
53
+ client.clientId,
54
+ redirectUri,
55
+ codeChallenge,
56
+ state,
57
+ );
58
+
59
+ options.onAuthorizationUrl(authorizationUrl, redirectUri);
60
+
61
+ const callback = await callbackListener.result;
62
+ const tokens = await exchangeCodeForTokens(
63
+ endpoints.tokenEndpoint,
64
+ client,
65
+ redirectUri,
66
+ callback.code,
67
+ codeVerifier,
68
+ options.signal,
69
+ );
70
+ return saveAdapterAuth(options.serverName, options.url, client, tokens);
71
+ } finally {
72
+ callbackListener.close();
73
+ }
74
+ }
75
+
76
+ interface OAuthEndpoints {
77
+ authorizationEndpoint: string;
78
+ tokenEndpoint: string;
79
+ registrationEndpoint?: string;
80
+ }
81
+
82
+ async function discoverOAuthEndpoints(
83
+ serverUrl: string,
84
+ signal?: AbortSignal,
85
+ ): Promise<OAuthEndpoints> {
86
+ const base = new URL(serverUrl);
87
+ const wellKnownUrl = new URL(
88
+ "/.well-known/oauth-authorization-server",
89
+ base.origin,
90
+ ).href;
91
+ const metadata = await fetchJson<Record<string, unknown>>(wellKnownUrl, { signal });
92
+ const authorizationEndpoint = stringField(metadata, "authorization_endpoint");
93
+ const tokenEndpoint = stringField(metadata, "token_endpoint");
94
+ const registrationEndpoint = optionalStringField(
95
+ metadata,
96
+ "registration_endpoint",
97
+ );
98
+
99
+ if (!authorizationEndpoint || !tokenEndpoint) {
100
+ throw new Error(`Invalid OAuth metadata from ${wellKnownUrl}`);
101
+ }
102
+
103
+ return { authorizationEndpoint, tokenEndpoint, registrationEndpoint };
104
+ }
105
+
106
+ async function registerClient(
107
+ registrationEndpoint: string,
108
+ redirectUri: string,
109
+ clientName: string,
110
+ signal?: AbortSignal,
111
+ ): Promise<RegisteredClient> {
112
+ const body = {
113
+ redirect_uris: [redirectUri],
114
+ client_name: clientName,
115
+ grant_types: ["authorization_code", "refresh_token"],
116
+ response_types: ["code"],
117
+ token_endpoint_auth_method: "none",
118
+ };
119
+
120
+ const result = await fetchJson<Record<string, unknown>>(
121
+ registrationEndpoint,
122
+ {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify(body),
126
+ signal,
127
+ },
128
+ );
129
+
130
+ const clientId = stringField(result, "client_id");
131
+ if (!clientId)
132
+ throw new Error("OAuth registration response missing client_id");
133
+
134
+ return {
135
+ clientId,
136
+ clientSecret: optionalStringField(result, "client_secret"),
137
+ clientIdIssuedAt: optionalNumberField(result, "client_id_issued_at"),
138
+ clientSecretExpiresAt: optionalNumberField(
139
+ result,
140
+ "client_secret_expires_at",
141
+ ),
142
+ };
143
+ }
144
+
145
+ function generatePkce(): { codeVerifier: string; codeChallenge: string } {
146
+ const codeVerifier = base64Url(randomBytes(32));
147
+ const codeChallenge = base64Url(
148
+ createHash("sha256").update(codeVerifier).digest(),
149
+ );
150
+ return { codeVerifier, codeChallenge };
151
+ }
152
+
153
+ function buildAuthorizationUrl(
154
+ authorizationEndpoint: string,
155
+ clientId: string,
156
+ redirectUri: string,
157
+ codeChallenge: string,
158
+ state: string,
159
+ ): string {
160
+ const url = new URL(authorizationEndpoint);
161
+ url.searchParams.set("response_type", "code");
162
+ url.searchParams.set("client_id", clientId);
163
+ url.searchParams.set("redirect_uri", redirectUri);
164
+ url.searchParams.set("code_challenge", codeChallenge);
165
+ url.searchParams.set("code_challenge_method", "S256");
166
+ url.searchParams.set("state", state);
167
+ return url.href;
168
+ }
169
+
170
+ async function exchangeCodeForTokens(
171
+ tokenEndpoint: string,
172
+ client: RegisteredClient,
173
+ redirectUri: string,
174
+ code: string,
175
+ codeVerifier: string,
176
+ signal?: AbortSignal,
177
+ ): Promise<TokenResponse> {
178
+ const params = new URLSearchParams();
179
+ params.set("grant_type", "authorization_code");
180
+ params.set("code", code);
181
+ params.set("redirect_uri", redirectUri);
182
+ params.set("client_id", client.clientId);
183
+ params.set("code_verifier", codeVerifier);
184
+ if (client.clientSecret) params.set("client_secret", client.clientSecret);
185
+
186
+ const result = await fetchJson<Record<string, unknown>>(tokenEndpoint, {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
189
+ body: params.toString(),
190
+ signal,
191
+ });
192
+
193
+ const accessToken = stringField(result, "access_token");
194
+ if (!accessToken) throw new Error("Token response missing access_token");
195
+
196
+ return {
197
+ access_token: accessToken,
198
+ refresh_token: optionalStringField(result, "refresh_token"),
199
+ expires_in: optionalNumberField(result, "expires_in"),
200
+ scope: optionalStringField(result, "scope"),
201
+ token_type: optionalStringField(result, "token_type") ?? "Bearer",
202
+ };
203
+ }
204
+
205
+ interface CallbackListener {
206
+ callbackUrl: string;
207
+ result: Promise<{ code: string; state: string }>;
208
+ close: () => void;
209
+ }
210
+
211
+ async function waitForCallback(
212
+ port: number,
213
+ expectedState: string,
214
+ signal: AbortSignal | undefined,
215
+ ): Promise<CallbackListener> {
216
+ let resolveResult!: (result: { code: string; state: string }) => void;
217
+ let rejectResult!: (error: unknown) => void;
218
+ const result = new Promise<{ code: string; state: string }>(
219
+ (resolve, reject) => {
220
+ resolveResult = resolve;
221
+ rejectResult = reject;
222
+ },
223
+ );
224
+
225
+ let closed = false;
226
+ let timer: ReturnType<typeof setTimeout> | undefined;
227
+ let abortHandler: (() => void) | undefined;
228
+
229
+ const close = () => {
230
+ if (closed) return;
231
+ closed = true;
232
+ if (timer) clearTimeout(timer);
233
+ if (abortHandler) signal?.removeEventListener("abort", abortHandler);
234
+ try {
235
+ server.close();
236
+ } catch {
237
+ // Server may not be listening yet when a bind error fires.
238
+ }
239
+ };
240
+
241
+ const fail = (error: unknown) => {
242
+ rejectResult(error);
243
+ close();
244
+ };
245
+
246
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
247
+ let url: URL;
248
+ try {
249
+ const host = req.headers.host ?? `localhost:${port}`;
250
+ url = new URL(req.url ?? "/", `http://${host}`);
251
+ } catch {
252
+ res.writeHead(400, { "Content-Type": "text/plain" });
253
+ res.end("Bad request");
254
+ return;
255
+ }
256
+
257
+ if (url.pathname !== CALLBACK_PATH) {
258
+ res.writeHead(404, { "Content-Type": "text/plain" });
259
+ res.end("Not found");
260
+ return;
261
+ }
262
+
263
+ const oauthError = url.searchParams.get("error");
264
+ if (oauthError) {
265
+ const description =
266
+ url.searchParams.get("error_description") ?? "OAuth error";
267
+ res.writeHead(400, { "Content-Type": "text/plain" });
268
+ res.end(`${oauthError}: ${description}`);
269
+ fail(new Error(`${oauthError}: ${description}`));
270
+ return;
271
+ }
272
+
273
+ const code = url.searchParams.get("code");
274
+ const state = url.searchParams.get("state");
275
+ if (!code || !state) {
276
+ res.writeHead(400, { "Content-Type": "text/plain" });
277
+ res.end("OAuth callback missing code or state");
278
+ return;
279
+ }
280
+
281
+ if (state !== expectedState) {
282
+ res.writeHead(400, { "Content-Type": "text/plain" });
283
+ res.end("OAuth state mismatch");
284
+ return;
285
+ }
286
+
287
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
288
+ res.end(successHtml());
289
+ resolveResult({ code, state });
290
+ close();
291
+ });
292
+
293
+ await new Promise<void>((resolve, reject) => {
294
+ const onBindError = (error: unknown) => {
295
+ close();
296
+ reject(error);
297
+ };
298
+ server.once("error", onBindError);
299
+ server.listen(port, () => {
300
+ server.off("error", onBindError);
301
+ server.on("error", fail);
302
+ resolve();
303
+ });
304
+ });
305
+
306
+ timer = setTimeout(() => {
307
+ fail(new Error("Timed out waiting for OAuth callback"));
308
+ }, AUTH_TIMEOUT_MS);
309
+
310
+ if (signal) {
311
+ if (signal.aborted) {
312
+ fail(new Error("Aborted"));
313
+ } else {
314
+ abortHandler = () => fail(new Error("Aborted"));
315
+ signal.addEventListener("abort", abortHandler, { once: true });
316
+ }
317
+ }
318
+
319
+ const address = server.address();
320
+ const actualPort =
321
+ typeof address === "object" && address ? address.port : port;
322
+ return {
323
+ callbackUrl: `http://localhost:${actualPort}${CALLBACK_PATH}`,
324
+ result,
325
+ close,
326
+ };
327
+ }
328
+
329
+ function successHtml(): string {
330
+ return `<!doctype html>
331
+ <html lang="en">
332
+ <head>
333
+ <meta charset="utf-8" />
334
+ <title>Figma MCP authorized</title>
335
+ </head>
336
+ <body style="font-family:system-ui,sans-serif;margin:3rem;max-width:32rem">
337
+ <h1>Figma MCP authorized</h1>
338
+ <p>You can close this tab and return to Pi.</p>
339
+ </body>
340
+ </html>
341
+ `;
342
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+
4
+ export function getAgentDir(): string {
5
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
6
+ if (!configured) return join(homedir(), ".pi", "agent");
7
+ return expandHome(configured);
8
+ }
9
+
10
+ export function getAgentPath(...segments: string[]): string {
11
+ return join(getAgentDir(), ...segments);
12
+ }
13
+
14
+ export function expandHome(path: string): string {
15
+ if (path === "~") return homedir();
16
+ if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
17
+ return resolve(path);
18
+ }
package/src/util.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
3
+ export function asRecord(value: unknown): Record<string, unknown> {
4
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
5
+ return value as Record<string, unknown>;
6
+ }
7
+
8
+ export function stringField(
9
+ record: Record<string, unknown>,
10
+ key: string,
11
+ ): string {
12
+ const value = record[key];
13
+ return typeof value === "string" ? value : "";
14
+ }
15
+
16
+ export function optionalStringField(
17
+ record: Record<string, unknown>,
18
+ key: string,
19
+ ): string | undefined {
20
+ const value = record[key];
21
+ return typeof value === "string" ? value : undefined;
22
+ }
23
+
24
+ export function optionalNumberField(
25
+ record: Record<string, unknown>,
26
+ key: string,
27
+ ): number | undefined {
28
+ const value = record[key];
29
+ return typeof value === "number" ? value : undefined;
30
+ }
31
+
32
+ export function base64Url(buffer: Buffer): string {
33
+ return buffer
34
+ .toString("base64")
35
+ .replace(/=/g, "")
36
+ .replace(/\+/g, "-")
37
+ .replace(/\//g, "_");
38
+ }
39
+
40
+ export function formatUserError(error: unknown): string {
41
+ return error instanceof Error ? error.message : String(error);
42
+ }
43
+
44
+ export function readJsonObject(path: string): Record<string, unknown> {
45
+ if (!existsSync(path)) return {};
46
+ let raw: string;
47
+ try {
48
+ raw = readFileSync(path, "utf8");
49
+ } catch (error) {
50
+ throw new Error(`Failed to read ${path}: ${formatUserError(error)}`);
51
+ }
52
+ try {
53
+ return asRecord(JSON.parse(raw));
54
+ } catch (error) {
55
+ throw new Error(`Invalid JSON in ${path}: ${formatUserError(error)}`);
56
+ }
57
+ }
58
+
59
+ export async function fetchJson<T>(url: string, init?: RequestInit, timeoutMs = 30_000): Promise<T> {
60
+ const method = init?.method ?? "GET";
61
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
62
+ const signal = init?.signal
63
+ ? AbortSignal.any([init.signal, timeoutSignal])
64
+ : timeoutSignal;
65
+ const response = await fetch(url, { ...init, signal });
66
+ if (!response.ok) {
67
+ const text = await response.text().catch(() => "");
68
+ throw new Error(
69
+ `${method} ${url} failed: ${response.status} ${response.statusText}${
70
+ text ? ` - ${text.slice(0, 500)}` : ""
71
+ }`,
72
+ );
73
+ }
74
+ try {
75
+ return (await response.json()) as T;
76
+ } catch {
77
+ throw new Error(`${method} ${url} returned non-JSON body`);
78
+ }
79
+ }