mcpcac 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +21 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +122 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +311 -0
- package/dist/local-callback-server.d.ts +14 -0
- package/dist/local-callback-server.d.ts.map +1 -0
- package/dist/local-callback-server.js +162 -0
- package/dist/oauth-provider.d.ts +63 -0
- package/dist/oauth-provider.d.ts.map +1 -0
- package/dist/oauth-provider.js +96 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +42 -0
- package/src/auth.ts +138 -0
- package/src/index.ts +445 -0
- package/src/local-callback-server.ts +185 -0
- package/src/oauth-provider.ts +134 -0
- package/src/types.ts +87 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # MCP to CLI
|
|
3
|
+
*
|
|
4
|
+
* Dynamically generates CLI commands from MCP (Model Context Protocol) server tools.
|
|
5
|
+
* This module connects to any MCP server, discovers available tools, and creates
|
|
6
|
+
* corresponding CLI commands with proper argument parsing and validation.
|
|
7
|
+
*
|
|
8
|
+
* ## Features
|
|
9
|
+
*
|
|
10
|
+
* - **Auto-discovery**: Fetches all tools from the MCP server and creates CLI commands
|
|
11
|
+
* - **Caching**: Tools are cached for 1 hour to avoid reconnecting on every invocation
|
|
12
|
+
* - **Session reuse**: MCP session IDs are cached to skip initialization handshake
|
|
13
|
+
* - **Type-aware parsing**: Handles string, number, boolean, object, and array arguments
|
|
14
|
+
* - **JSON schema support**: Generates CLI options from tool input schemas
|
|
15
|
+
* - **OAuth support**: Automatic OAuth authentication on 401 errors (lazy auth)
|
|
16
|
+
*
|
|
17
|
+
* ## Example Usage
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { cac } from '@xmorse/cac'
|
|
21
|
+
* import { addMcpCommands } from 'mcpcac'
|
|
22
|
+
*
|
|
23
|
+
* const cli = cac('mycli')
|
|
24
|
+
*
|
|
25
|
+
* await addMcpCommands({
|
|
26
|
+
* cli,
|
|
27
|
+
* commandPrefix: 'mcp',
|
|
28
|
+
* clientName: 'my-mcp-client',
|
|
29
|
+
* getMcpUrl: () => loadConfig().mcpUrl,
|
|
30
|
+
* oauth: {
|
|
31
|
+
* clientName: 'My CLI',
|
|
32
|
+
* load: () => loadConfig().mcpOauth,
|
|
33
|
+
* save: (state) => saveConfig({ mcpOauth: state }),
|
|
34
|
+
* },
|
|
35
|
+
* loadCache: () => loadConfig().cachedMcpTools,
|
|
36
|
+
* saveCache: (cache) => saveConfig({ cachedMcpTools: cache }),
|
|
37
|
+
* })
|
|
38
|
+
*
|
|
39
|
+
* // Login command just saves URL - no auth check, fast!
|
|
40
|
+
* cli.command('login [url]').action((url) => {
|
|
41
|
+
* saveConfig({ mcpUrl: url })
|
|
42
|
+
* console.log('URL saved.')
|
|
43
|
+
* })
|
|
44
|
+
*
|
|
45
|
+
* cli.parse()
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @module mcpcac
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
52
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
53
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
54
|
+
import type { CAC } from "@xmorse/cac";
|
|
55
|
+
import { FileOAuthProvider } from "./oauth-provider.js";
|
|
56
|
+
import { startOAuthFlow, isAuthRequiredError } from "./auth.js";
|
|
57
|
+
import type { McpOAuthConfig, McpOAuthState } from "./types.js";
|
|
58
|
+
|
|
59
|
+
// Public exports - only types that consumers need
|
|
60
|
+
export type { Transport };
|
|
61
|
+
export type { McpOAuthConfig, McpOAuthState } from "./types.js";
|
|
62
|
+
|
|
63
|
+
export interface CachedMcpTools {
|
|
64
|
+
tools: Array<{
|
|
65
|
+
name: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
inputSchema?: unknown;
|
|
68
|
+
}>;
|
|
69
|
+
timestamp: number;
|
|
70
|
+
sessionId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
74
|
+
|
|
75
|
+
export interface AddMcpCommandsOptions {
|
|
76
|
+
cli: CAC;
|
|
77
|
+
commandPrefix: string;
|
|
78
|
+
/**
|
|
79
|
+
* Name used when connecting to the MCP server.
|
|
80
|
+
* @default 'mcp-cli-client'
|
|
81
|
+
*/
|
|
82
|
+
clientName?: string;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns the MCP server URL, or undefined if not configured.
|
|
86
|
+
* Required when using the oauth option.
|
|
87
|
+
*/
|
|
88
|
+
getMcpUrl?: () => string | undefined;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Returns a transport to connect to the MCP server, or null if not configured.
|
|
92
|
+
* If null is returned, no MCP tool commands will be registered.
|
|
93
|
+
* @param sessionId - Optional session ID from cache to reuse existing session
|
|
94
|
+
*
|
|
95
|
+
* @deprecated Use getMcpUrl + oauth instead for simpler setup
|
|
96
|
+
*/
|
|
97
|
+
getMcpTransport?: (sessionId?: string) => Transport | null | Promise<Transport | null>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* OAuth configuration. When provided, enables automatic OAuth authentication.
|
|
101
|
+
*
|
|
102
|
+
* OAuth is lazy - no auth check happens on startup. Authentication is only
|
|
103
|
+
* triggered when a tool call returns 401. After successful auth, the tool
|
|
104
|
+
* call is automatically retried.
|
|
105
|
+
*
|
|
106
|
+
* The library handles everything internally:
|
|
107
|
+
* - Detecting 401 errors
|
|
108
|
+
* - Starting local callback server on random port
|
|
109
|
+
* - Opening browser for authorization
|
|
110
|
+
* - Exchanging code for tokens
|
|
111
|
+
* - Persisting tokens via save()
|
|
112
|
+
* - Retrying the failed tool call
|
|
113
|
+
*/
|
|
114
|
+
oauth?: McpOAuthConfig;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load cached MCP tools. Return undefined if no cache exists.
|
|
118
|
+
*/
|
|
119
|
+
loadCache: () => CachedMcpTools | undefined;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Save cached MCP tools. Pass undefined to clear the cache.
|
|
123
|
+
*/
|
|
124
|
+
saveCache: (cache: CachedMcpTools | undefined) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface JsonSchemaProperty {
|
|
128
|
+
type?: string;
|
|
129
|
+
description?: string;
|
|
130
|
+
enum?: string[];
|
|
131
|
+
default?: unknown;
|
|
132
|
+
properties?: Record<string, JsonSchemaProperty>;
|
|
133
|
+
items?: JsonSchemaProperty;
|
|
134
|
+
required?: string[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface InputSchema {
|
|
138
|
+
type: "object";
|
|
139
|
+
properties?: Record<string, JsonSchemaProperty>;
|
|
140
|
+
required?: string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Convert JSON schema to compact JSON string for display
|
|
145
|
+
*/
|
|
146
|
+
function schemaToString(schema: JsonSchemaProperty): string {
|
|
147
|
+
const compact = { ...schema };
|
|
148
|
+
delete compact.description;
|
|
149
|
+
return JSON.stringify(compact);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseToolArguments(
|
|
153
|
+
options: Record<string, unknown>,
|
|
154
|
+
inputSchema: InputSchema | undefined,
|
|
155
|
+
): Record<string, unknown> {
|
|
156
|
+
const args: Record<string, unknown> = {};
|
|
157
|
+
if (!inputSchema?.properties) {
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
for (const [name, schema] of Object.entries(inputSchema.properties)) {
|
|
161
|
+
let value = options[name];
|
|
162
|
+
if (value === undefined) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(value) && value.length === 1) {
|
|
166
|
+
value = value[0];
|
|
167
|
+
}
|
|
168
|
+
const type = schema.type || "string";
|
|
169
|
+
if ((type === "object" || type === "array") && typeof value === "string") {
|
|
170
|
+
try {
|
|
171
|
+
args[name] = JSON.parse(value);
|
|
172
|
+
} catch {
|
|
173
|
+
console.error(`Invalid JSON for --${name}: ${value}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
args[name] = value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return args;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function outputResult(result: {
|
|
184
|
+
content: Array<{ type: string; text?: string; data?: string }>;
|
|
185
|
+
}): void {
|
|
186
|
+
for (const block of result.content) {
|
|
187
|
+
if (block.type === "text" && block.text) {
|
|
188
|
+
console.log(block.text);
|
|
189
|
+
} else if (block.type === "image") {
|
|
190
|
+
console.log("[Image content omitted]");
|
|
191
|
+
} else {
|
|
192
|
+
console.log(JSON.stringify(block, null, 2));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create a transport with optional OAuth authentication
|
|
199
|
+
*/
|
|
200
|
+
function createTransportWithAuth(
|
|
201
|
+
url: URL,
|
|
202
|
+
sessionId: string | undefined,
|
|
203
|
+
oauthState: McpOAuthState | undefined,
|
|
204
|
+
oauth: McpOAuthConfig | undefined,
|
|
205
|
+
): StreamableHTTPClientTransport {
|
|
206
|
+
let authProvider: FileOAuthProvider | undefined;
|
|
207
|
+
|
|
208
|
+
if (oauth && oauthState?.tokens) {
|
|
209
|
+
authProvider = new FileOAuthProvider({
|
|
210
|
+
serverUrl: url.toString(),
|
|
211
|
+
redirectUri: "http://localhost/callback", // Placeholder, real one set during auth flow
|
|
212
|
+
clientName: oauth.clientName,
|
|
213
|
+
tokens: oauthState.tokens,
|
|
214
|
+
clientInformation: oauthState.clientInformation,
|
|
215
|
+
codeVerifier: oauthState.codeVerifier,
|
|
216
|
+
onStateUpdated: (newState) => {
|
|
217
|
+
oauth.save(newState);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return new StreamableHTTPClientTransport(url, {
|
|
223
|
+
sessionId,
|
|
224
|
+
authProvider,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Normalize MCP URL for StreamableHTTP transport
|
|
230
|
+
*/
|
|
231
|
+
function normalizeUrl(mcpUrl: string): URL {
|
|
232
|
+
const url = new URL(mcpUrl);
|
|
233
|
+
if (url.pathname.endsWith("/sse")) {
|
|
234
|
+
url.pathname = url.pathname.replace(/\/sse$/, "/mcp");
|
|
235
|
+
}
|
|
236
|
+
return url;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Adds MCP tool commands to a cac CLI instance.
|
|
241
|
+
*
|
|
242
|
+
* Tools are cached for 1 hour to avoid connecting on every CLI invocation.
|
|
243
|
+
* Session ID is also cached to skip MCP initialization handshake.
|
|
244
|
+
*
|
|
245
|
+
* OAuth is lazy - authentication only happens when a 401 error occurs.
|
|
246
|
+
* After successful auth, the operation is automatically retried.
|
|
247
|
+
*/
|
|
248
|
+
export async function addMcpCommands(options: AddMcpCommandsOptions): Promise<void> {
|
|
249
|
+
const {
|
|
250
|
+
cli,
|
|
251
|
+
commandPrefix,
|
|
252
|
+
clientName = "mcp-cli-client",
|
|
253
|
+
getMcpUrl,
|
|
254
|
+
getMcpTransport,
|
|
255
|
+
oauth,
|
|
256
|
+
loadCache,
|
|
257
|
+
saveCache,
|
|
258
|
+
} = options;
|
|
259
|
+
|
|
260
|
+
// Helper to get transport - supports both old and new API
|
|
261
|
+
const getTransport = async (sessionId?: string): Promise<Transport | null> => {
|
|
262
|
+
// New API: getMcpUrl + oauth
|
|
263
|
+
if (getMcpUrl) {
|
|
264
|
+
const mcpUrl = getMcpUrl();
|
|
265
|
+
if (!mcpUrl) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const url = normalizeUrl(mcpUrl);
|
|
270
|
+
const oauthState = oauth?.load();
|
|
271
|
+
|
|
272
|
+
return createTransportWithAuth(url, sessionId, oauthState, oauth);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Legacy API: getMcpTransport
|
|
276
|
+
if (getMcpTransport) {
|
|
277
|
+
return getMcpTransport(sessionId);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return null;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Handle auth required - triggers OAuth flow internally
|
|
284
|
+
const handleAuthRequired = async (serverUrl: string): Promise<boolean> => {
|
|
285
|
+
if (!oauth) {
|
|
286
|
+
console.error("Authentication required but OAuth not configured.");
|
|
287
|
+
console.error("Add oauth config to addMcpCommands() to enable automatic authentication.");
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
console.log("\n🔐 Authentication required. Opening browser...\n");
|
|
292
|
+
|
|
293
|
+
const result = await startOAuthFlow({
|
|
294
|
+
serverUrl,
|
|
295
|
+
clientName: oauth.clientName,
|
|
296
|
+
existingState: oauth.load(),
|
|
297
|
+
onAuthUrl: oauth.onAuthUrl,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (result.success && result.state) {
|
|
301
|
+
oauth.save(result.state);
|
|
302
|
+
oauth.onAuthSuccess?.();
|
|
303
|
+
console.log("✓ Authentication successful! Retrying...\n");
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
oauth.onAuthError?.(result.error || "Unknown error");
|
|
308
|
+
console.error(`✗ Authentication failed: ${result.error}\n`);
|
|
309
|
+
return false;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Try to use cached tools first (fast path - no network)
|
|
313
|
+
const cachedTools = loadCache();
|
|
314
|
+
const isCacheValid = cachedTools && (Date.now() - cachedTools.timestamp) < CACHE_TTL_MS;
|
|
315
|
+
|
|
316
|
+
let tools: CachedMcpTools["tools"];
|
|
317
|
+
let cachedSessionId: string | undefined;
|
|
318
|
+
|
|
319
|
+
if (isCacheValid) {
|
|
320
|
+
tools = cachedTools.tools;
|
|
321
|
+
cachedSessionId = cachedTools.sessionId;
|
|
322
|
+
} else {
|
|
323
|
+
// Cache invalid/missing - connect to fetch tools
|
|
324
|
+
const transport = await getTransport();
|
|
325
|
+
if (!transport) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const client = new Client({ name: clientName, version: "1.0.0" }, { capabilities: {} });
|
|
330
|
+
try {
|
|
331
|
+
await client.connect(transport);
|
|
332
|
+
const result = await client.listTools();
|
|
333
|
+
tools = result.tools;
|
|
334
|
+
|
|
335
|
+
const sessionId = (transport as { sessionId?: string }).sessionId;
|
|
336
|
+
|
|
337
|
+
saveCache({
|
|
338
|
+
tools: tools.map((t) => ({
|
|
339
|
+
name: t.name,
|
|
340
|
+
description: t.description,
|
|
341
|
+
inputSchema: t.inputSchema,
|
|
342
|
+
})),
|
|
343
|
+
timestamp: Date.now(),
|
|
344
|
+
sessionId,
|
|
345
|
+
});
|
|
346
|
+
cachedSessionId = sessionId;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
// Check if auth is required during tool discovery
|
|
349
|
+
if (isAuthRequiredError(err) && oauth && getMcpUrl) {
|
|
350
|
+
const mcpUrl = getMcpUrl();
|
|
351
|
+
if (mcpUrl) {
|
|
352
|
+
const authSuccess = await handleAuthRequired(normalizeUrl(mcpUrl).toString());
|
|
353
|
+
if (authSuccess) {
|
|
354
|
+
// Retry after auth
|
|
355
|
+
return addMcpCommands(options);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
console.error(`Failed to connect to MCP server: ${err instanceof Error ? err.message : err}`);
|
|
360
|
+
return;
|
|
361
|
+
} finally {
|
|
362
|
+
await client.close();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Register CLI commands for each tool
|
|
367
|
+
for (const tool of tools) {
|
|
368
|
+
const inputSchema = tool.inputSchema as InputSchema | undefined;
|
|
369
|
+
const cmdName = `${commandPrefix} ${tool.name}`;
|
|
370
|
+
const description = tool.description || `Run MCP tool ${tool.name}`;
|
|
371
|
+
|
|
372
|
+
const cmd = cli.command(cmdName, description);
|
|
373
|
+
|
|
374
|
+
if (inputSchema?.properties) {
|
|
375
|
+
for (const [propName, propSchema] of Object.entries(inputSchema.properties)) {
|
|
376
|
+
const isRequired = inputSchema.required?.includes(propName) ?? false;
|
|
377
|
+
const schemaType = propSchema.type || "string";
|
|
378
|
+
|
|
379
|
+
const optionStr =
|
|
380
|
+
schemaType === "boolean" ? `--${propName}` : `--${propName} <${propName}>`;
|
|
381
|
+
|
|
382
|
+
let optionDesc = propSchema.description || propName;
|
|
383
|
+
if (isRequired) {
|
|
384
|
+
optionDesc += " (required)";
|
|
385
|
+
}
|
|
386
|
+
if (schemaType === "object" || schemaType === "array") {
|
|
387
|
+
optionDesc += ` (JSON: ${schemaToString(propSchema)})`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const optionConfig: { default?: unknown; type?: unknown[] } = {};
|
|
391
|
+
if (propSchema.default !== undefined) {
|
|
392
|
+
optionConfig.default = propSchema.default;
|
|
393
|
+
}
|
|
394
|
+
if (schemaType === "number" || schemaType === "integer") {
|
|
395
|
+
optionConfig.type = [Number];
|
|
396
|
+
} else if (schemaType !== "boolean") {
|
|
397
|
+
optionConfig.type = [String];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
cmd.option(optionStr, optionDesc, optionConfig);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
cmd.action(async (cliOptions: Record<string, unknown>) => {
|
|
405
|
+
const parsedArgs = parseToolArguments(cliOptions, inputSchema);
|
|
406
|
+
|
|
407
|
+
const executeWithRetry = async (isRetry = false): Promise<void> => {
|
|
408
|
+
const transport = await getTransport(isRetry ? undefined : cachedSessionId);
|
|
409
|
+
if (!transport) {
|
|
410
|
+
console.error("MCP transport not available. Run login command first.");
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const actionClient = new Client({ name: clientName, version: "1.0.0" }, { capabilities: {} });
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
await actionClient.connect(transport);
|
|
418
|
+
const result = await actionClient.callTool({ name: tool.name, arguments: parsedArgs });
|
|
419
|
+
outputResult(result as { content: Array<{ type: string; text?: string }> });
|
|
420
|
+
} catch (err) {
|
|
421
|
+
// On 401, trigger OAuth and retry (only once)
|
|
422
|
+
if (!isRetry && isAuthRequiredError(err) && oauth && getMcpUrl) {
|
|
423
|
+
const mcpUrl = getMcpUrl();
|
|
424
|
+
if (mcpUrl) {
|
|
425
|
+
const authSuccess = await handleAuthRequired(normalizeUrl(mcpUrl).toString());
|
|
426
|
+
if (authSuccess) {
|
|
427
|
+
await actionClient.close();
|
|
428
|
+
return executeWithRetry(true);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Clear cache on error (might be stale)
|
|
434
|
+
saveCache(undefined);
|
|
435
|
+
console.error(`Error calling ${tool.name}:`, err instanceof Error ? err.message : err);
|
|
436
|
+
process.exit(1);
|
|
437
|
+
} finally {
|
|
438
|
+
await actionClient.close();
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
await executeWithRetry();
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import type { CallbackResult, CallbackServerOptions } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Find a random available port by letting the OS assign one.
|
|
9
|
+
*/
|
|
10
|
+
async function findAvailablePort(): Promise<number> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const server = net.createServer();
|
|
13
|
+
server.on("error", reject);
|
|
14
|
+
server.listen(0, () => {
|
|
15
|
+
const address = server.address();
|
|
16
|
+
if (!address || typeof address === "string") {
|
|
17
|
+
server.close();
|
|
18
|
+
reject(new Error("Failed to get port from server"));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const port = address.port;
|
|
22
|
+
server.close(() => {
|
|
23
|
+
resolve(port);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate HTML response for the callback page
|
|
31
|
+
*/
|
|
32
|
+
function generateCallbackHtml(success: boolean, message: string): string {
|
|
33
|
+
const color = success ? "#22c55e" : "#ef4444";
|
|
34
|
+
const icon = success ? "✓" : "✗";
|
|
35
|
+
|
|
36
|
+
return `<!DOCTYPE html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="UTF-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
41
|
+
<title>${success ? "Authentication Successful" : "Authentication Failed"}</title>
|
|
42
|
+
<style>
|
|
43
|
+
body {
|
|
44
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
justify-content: center;
|
|
48
|
+
min-height: 100vh;
|
|
49
|
+
margin: 0;
|
|
50
|
+
background-color: #f5f5f5;
|
|
51
|
+
}
|
|
52
|
+
.container {
|
|
53
|
+
background: white;
|
|
54
|
+
padding: 3rem;
|
|
55
|
+
border-radius: 12px;
|
|
56
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
57
|
+
text-align: center;
|
|
58
|
+
max-width: 400px;
|
|
59
|
+
}
|
|
60
|
+
.icon {
|
|
61
|
+
font-size: 4rem;
|
|
62
|
+
color: ${color};
|
|
63
|
+
margin-bottom: 1rem;
|
|
64
|
+
}
|
|
65
|
+
h1 {
|
|
66
|
+
color: #1f2937;
|
|
67
|
+
margin-bottom: 0.5rem;
|
|
68
|
+
}
|
|
69
|
+
p {
|
|
70
|
+
color: #6b7280;
|
|
71
|
+
margin-bottom: 1.5rem;
|
|
72
|
+
}
|
|
73
|
+
.hint {
|
|
74
|
+
font-size: 0.875rem;
|
|
75
|
+
color: #9ca3af;
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<div class="container">
|
|
81
|
+
<div class="icon">${icon}</div>
|
|
82
|
+
<h1>${success ? "Authentication Successful" : "Authentication Failed"}</h1>
|
|
83
|
+
<p>${message}</p>
|
|
84
|
+
<p class="hint">You can close this window and return to your terminal.</p>
|
|
85
|
+
</div>
|
|
86
|
+
<script>
|
|
87
|
+
// Try to close the window after a short delay
|
|
88
|
+
setTimeout(() => { window.close(); }, 2000);
|
|
89
|
+
</script>
|
|
90
|
+
</body>
|
|
91
|
+
</html>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start a local HTTP server to receive OAuth callbacks.
|
|
96
|
+
* Uses a random available port to avoid conflicts.
|
|
97
|
+
*
|
|
98
|
+
* @returns Object with port, redirectUri, waitForCallback promise, and close function
|
|
99
|
+
*/
|
|
100
|
+
export async function startCallbackServer(options: CallbackServerOptions = {}): Promise<{
|
|
101
|
+
port: number;
|
|
102
|
+
redirectUri: string;
|
|
103
|
+
waitForCallback: () => Promise<CallbackResult>;
|
|
104
|
+
close: () => void;
|
|
105
|
+
}> {
|
|
106
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
107
|
+
const port = await findAvailablePort();
|
|
108
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
109
|
+
|
|
110
|
+
let resolveCallback: ((result: CallbackResult) => void) | undefined;
|
|
111
|
+
let rejectCallback: ((error: Error) => void) | undefined;
|
|
112
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
+
|
|
114
|
+
const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
|
|
115
|
+
resolveCallback = resolve;
|
|
116
|
+
rejectCallback = reject;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const server = http.createServer((req, res) => {
|
|
120
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
121
|
+
|
|
122
|
+
// Only handle the callback path
|
|
123
|
+
if (url.pathname !== "/callback") {
|
|
124
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
125
|
+
res.end("Not Found");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const code = url.searchParams.get("code");
|
|
130
|
+
const state = url.searchParams.get("state");
|
|
131
|
+
const error = url.searchParams.get("error");
|
|
132
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
133
|
+
|
|
134
|
+
if (error) {
|
|
135
|
+
const message = errorDescription || error;
|
|
136
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
137
|
+
res.end(generateCallbackHtml(false, message));
|
|
138
|
+
rejectCallback?.(new Error(`OAuth error: ${message}`));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!code) {
|
|
143
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
144
|
+
res.end(generateCallbackHtml(false, "Missing authorization code"));
|
|
145
|
+
rejectCallback?.(new Error("Missing authorization code"));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
150
|
+
res.end(generateCallbackHtml(true, "You have been authenticated successfully."));
|
|
151
|
+
resolveCallback?.({ code, state: state || "" });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
server.listen(port, () => {
|
|
155
|
+
options.onReady?.(redirectUri);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Set up timeout
|
|
159
|
+
timeoutId = setTimeout(() => {
|
|
160
|
+
rejectCallback?.(new Error(`OAuth callback timed out after ${timeout / 1000} seconds`));
|
|
161
|
+
server.close();
|
|
162
|
+
}, timeout);
|
|
163
|
+
|
|
164
|
+
const close = () => {
|
|
165
|
+
if (timeoutId) {
|
|
166
|
+
clearTimeout(timeoutId);
|
|
167
|
+
}
|
|
168
|
+
server.close();
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const waitForCallback = async (): Promise<CallbackResult> => {
|
|
172
|
+
try {
|
|
173
|
+
return await callbackPromise;
|
|
174
|
+
} finally {
|
|
175
|
+
close();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
port,
|
|
181
|
+
redirectUri,
|
|
182
|
+
waitForCallback,
|
|
183
|
+
close,
|
|
184
|
+
};
|
|
185
|
+
}
|