@barndoor-ai/sdk 0.2.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/src/client.ts ADDED
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Main Barndoor SDK client.
3
+ *
4
+ * This module provides the primary BarndoorSDK class that mirrors the Python
5
+ * SDK's client.py functionality with 100% API compatibility.
6
+ */
7
+
8
+ import { HTTPClient, TimeoutConfig } from './http/client';
9
+ import { ServerSummary, ServerDetail } from './models';
10
+ import { HTTPError, ConfigurationError, TokenError, ServerNotFoundError } from './exceptions';
11
+ import { getStaticConfig, isNode } from './config';
12
+ import { createScopedLogger } from './logging';
13
+ import { spawn } from 'child_process';
14
+ import os from 'os';
15
+
16
+ /**
17
+ * Configuration options for BarndoorSDK constructor.
18
+ */
19
+ export interface BarndoorSDKOptions {
20
+ /** User JWT token (optional - can be set later via authenticate()) */
21
+ token?: string;
22
+ /** Whether to validate token on initialization */
23
+ validateTokenOnInit?: boolean;
24
+ /** Request timeout in seconds */
25
+ timeout?: number;
26
+ /** Maximum number of retries */
27
+ maxRetries?: number;
28
+ }
29
+
30
+ /**
31
+ * Pagination metadata for API responses.
32
+ */
33
+ interface PaginationMetadata {
34
+ page: number;
35
+ limit: number;
36
+ total: number;
37
+ pages: number;
38
+ previous_page: number | null;
39
+ next_page: number | null;
40
+ }
41
+
42
+ /**
43
+ * Paginated API response structure.
44
+ */
45
+ interface PaginatedResponse<T> {
46
+ data: T[];
47
+ pagination: PaginationMetadata;
48
+ }
49
+
50
+ /**
51
+ * Options for ensureServerConnected method.
52
+ */
53
+ export interface EnsureServerConnectedOptions {
54
+ /** Maximum seconds to wait for connection */
55
+ pollSeconds?: number;
56
+ }
57
+
58
+ /**
59
+ * Response from server connection initiation.
60
+ */
61
+ export interface ConnectionInitiationResponse {
62
+ /** OAuth authorization URL */
63
+ auth_url?: string;
64
+ [key: string]: unknown;
65
+ }
66
+
67
+ /**
68
+ * Response from connection status check.
69
+ */
70
+ export interface ConnectionStatusResponse {
71
+ /** Current connection status */
72
+ status: string;
73
+ }
74
+
75
+ /**
76
+ * Async client for interacting with the Barndoor Platform API.
77
+ *
78
+ * This SDK provides methods to:
79
+ * - Manage server connections and OAuth flows
80
+ * - List available MCP servers
81
+ * - Validate user tokens
82
+ *
83
+ * The client handles authentication automatically by including the user's
84
+ * JWT token in all requests.
85
+ */
86
+ export class BarndoorSDK {
87
+ /** Base URL of the Barndoor API */
88
+ public readonly base: string;
89
+ /** User JWT token */
90
+ private _token: string | null;
91
+ /** HTTP client instance */
92
+ private readonly _http: HTTPClient;
93
+ /** Whether token has been validated */
94
+ private _tokenValidated: boolean;
95
+ /** Whether the SDK has been closed */
96
+ private _closed: boolean;
97
+ /** Scoped logger for this SDK instance */
98
+ private readonly _logger = createScopedLogger('client');
99
+
100
+ /**
101
+ * Create a new BarndoorSDK instance.
102
+ * @param apiBaseUrl - Base URL of the Barndoor API
103
+ * @param options - Configuration options (token is optional)
104
+ */
105
+ constructor(apiBaseUrl: string, options: BarndoorSDKOptions = {}) {
106
+ const { token: barndoorToken, timeout = 30.0, maxRetries = 3 } = options;
107
+
108
+ // Validate inputs
109
+ this.base = this._validateUrl(apiBaseUrl, 'API base URL').replace(/\/$/, '');
110
+
111
+ // Token is optional - can be set later via authenticate(). If provided, validate even if empty string.
112
+ const hasTokenProp = Object.prototype.hasOwnProperty.call(options, 'token');
113
+ this._token = hasTokenProp ? this._validateToken(barndoorToken as unknown as string) : null;
114
+
115
+ // Validate configuration
116
+ if (typeof timeout !== 'number' || timeout <= 0) {
117
+ throw new ConfigurationError('timeout must be a positive number');
118
+ }
119
+ if (!Number.isInteger(maxRetries) || maxRetries < 0) {
120
+ throw new ConfigurationError('maxRetries must be a non-negative integer');
121
+ }
122
+
123
+ // Initialize HTTP client
124
+ const timeoutConfig = new TimeoutConfig(timeout, timeout / 3);
125
+ this._http = new HTTPClient(timeoutConfig, maxRetries);
126
+ this._tokenValidated = false;
127
+ this._closed = false;
128
+
129
+ this._logger.info(`Initialized BarndoorSDK for ${this.base}`);
130
+ }
131
+
132
+ /**
133
+ * Get the current token.
134
+ */
135
+ public get token(): string {
136
+ if (!this._token) {
137
+ throw new Error(
138
+ 'No token available. Call authenticate() first or provide token in constructor.'
139
+ );
140
+ }
141
+ return this._token;
142
+ }
143
+
144
+ /**
145
+ * Set authentication token for the SDK.
146
+ * @param token - JWT token to use for authentication
147
+ */
148
+ public async authenticate(token: string): Promise<void> {
149
+ this._token = this._validateToken(token);
150
+ this._tokenValidated = false; // Reset validation status
151
+
152
+ // Optionally validate the token immediately
153
+ await this.ensureValidToken();
154
+
155
+ this._logger.info('Authentication successful');
156
+ }
157
+
158
+ /**
159
+ * Validate URL format.
160
+ * @private
161
+ */
162
+ private _validateUrl(url: string, name: string): string {
163
+ if (!url || typeof url !== 'string') {
164
+ throw new ConfigurationError(`${name} must be a non-empty string`);
165
+ }
166
+
167
+ try {
168
+ new URL(url);
169
+ return url;
170
+ } catch (_error) {
171
+ throw new ConfigurationError(`${name} must be a valid URL`);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Validate token format.
177
+ * @private
178
+ */
179
+ private _validateToken(token: string): string {
180
+ if (!token || typeof token !== 'string') {
181
+ throw new TokenError('Token must be a non-empty string');
182
+ }
183
+
184
+ // Basic JWT format validation
185
+ const parts = token.split('.');
186
+ if (parts.length !== 3) {
187
+ throw new TokenError('Token must be a valid JWT');
188
+ }
189
+
190
+ return token;
191
+ }
192
+
193
+ /**
194
+ * Ensure the SDK hasn't been closed.
195
+ * @private
196
+ */
197
+ private _ensureNotClosed(): void {
198
+ if (this._closed) {
199
+ throw new Error('SDK has been closed. Create a new instance or use as context manager.');
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Make authenticated request with automatic token validation.
205
+ * @private
206
+ */
207
+ private async _req(
208
+ method: string,
209
+ path: string,
210
+ options: Record<string, unknown> = {}
211
+ ): Promise<unknown> {
212
+ this._ensureNotClosed();
213
+ await this.ensureValidToken();
214
+
215
+ const headers = (options['headers'] as Record<string, string>) ?? {};
216
+ headers['Authorization'] = `Bearer ${this.token}`;
217
+
218
+ const url = `${this.base}${path}`;
219
+ return await this._http.request(method, url, { ...options, headers });
220
+ }
221
+
222
+ /**
223
+ * Validate the cached token by making a test API call.
224
+ * @returns True if the token is valid
225
+ */
226
+ public async validateCachedToken(): Promise<boolean> {
227
+ if (!this.token) {
228
+ return false;
229
+ }
230
+
231
+ try {
232
+ // Use Auth0's userinfo endpoint for validation
233
+ const config = getStaticConfig();
234
+ const response = await fetch(`https://${config.authDomain}/userinfo`, {
235
+ headers: {
236
+ Authorization: `Bearer ${this.token}`,
237
+ },
238
+ });
239
+
240
+ const isValid = response.ok;
241
+ // Only set _tokenValidated to true if the token is actually valid
242
+ if (isValid) {
243
+ this._tokenValidated = true;
244
+ }
245
+ return isValid;
246
+ } catch (_error) {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Ensure token is valid, validating if necessary.
253
+ */
254
+ public async ensureValidToken(): Promise<void> {
255
+ if (this._tokenValidated) {
256
+ return;
257
+ }
258
+
259
+ // Skip validation only in explicit test/CI environments
260
+ const env = (isNode ? process.env['BARNDOOR_ENV'] : '') ?? '';
261
+ if (['test', 'ci'].includes(env.toLowerCase())) {
262
+ this._tokenValidated = true;
263
+ return;
264
+ }
265
+
266
+ // Validate token in all other environments (including staging, dev, prod)
267
+ const isValid = await this.validateCachedToken();
268
+ if (!isValid) {
269
+ throw new TokenError('Token validation failed. Please re-authenticate.');
270
+ }
271
+
272
+ this._tokenValidated = true;
273
+ }
274
+
275
+ /**
276
+ * List all MCP servers available to the caller's organization.
277
+ * @returns Array of server summaries
278
+ */
279
+ public async listServers(): Promise<ServerSummary[]> {
280
+ this._logger.debug('Fetching server list');
281
+ try {
282
+ const response = (await this._req('GET', '/servers')) as PaginatedResponse<unknown>;
283
+ const servers = response.data.map(data => ServerSummary.fromApiResponse(data));
284
+ this._logger.info(`Retrieved ${servers.length} servers`);
285
+ return servers;
286
+ } catch (error) {
287
+ this._logger.error('Failed to list servers:', error);
288
+ throw error;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Get detailed information about a specific server.
294
+ * @param serverId - Server ID
295
+ * @returns Server details
296
+ */
297
+ public async getServer(serverId: string): Promise<ServerDetail> {
298
+ const validatedServerId = this._validateServerId(serverId);
299
+
300
+ this._logger.info(`Fetching server details for ${validatedServerId}`);
301
+ const response = await this._req('GET', `/servers/${validatedServerId}`);
302
+ return ServerDetail.fromApiResponse(response);
303
+ }
304
+
305
+ /**
306
+ * Initiate OAuth connection flow for a server.
307
+ * @param serverId - Server ID
308
+ * @param returnUrl - Optional return URL
309
+ * @returns Connection initiation response
310
+ */
311
+ public async initiateConnection(
312
+ serverId: string,
313
+ returnUrl?: string
314
+ ): Promise<ConnectionInitiationResponse> {
315
+ const validatedServerId = this._validateServerId(serverId);
316
+ let validatedReturnUrl: string | undefined;
317
+
318
+ if (returnUrl) {
319
+ validatedReturnUrl = this._validateUrl(returnUrl, 'Return URL');
320
+ }
321
+
322
+ this._logger.info(`Initiating connection for server ${validatedServerId}`);
323
+
324
+ const params = validatedReturnUrl ? { return_url: validatedReturnUrl } : undefined;
325
+
326
+ try {
327
+ const response = await this._req('POST', `/servers/${validatedServerId}/connect`, {
328
+ params,
329
+ json: {},
330
+ });
331
+ return response as ConnectionInitiationResponse;
332
+ } catch (error: unknown) {
333
+ if (
334
+ error instanceof HTTPError &&
335
+ error.statusCode === 500 &&
336
+ error.responseBody?.includes('OAuth server configuration not found')
337
+ ) {
338
+ throw new Error(
339
+ 'Server is missing OAuth configuration. ' +
340
+ 'Ask an admin to configure credentials before initiating a connection.'
341
+ );
342
+ }
343
+ throw error;
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Get the user's connection status for a specific server.
349
+ * @param serverId - Server ID
350
+ * @returns Connection status
351
+ */
352
+ public async getConnectionStatus(serverId: string): Promise<string> {
353
+ const validatedServerId = this._validateServerId(serverId);
354
+
355
+ this._logger.info(`Checking connection status for server ${validatedServerId}`);
356
+ const response = (await this._req(
357
+ 'GET',
358
+ `/servers/${validatedServerId}/connection`
359
+ )) as ConnectionStatusResponse;
360
+ return response.status;
361
+ }
362
+
363
+ /**
364
+ * Disconnect from a specific MCP server.
365
+ *
366
+ * This will remove the connection record and clean up any stored OAuth credentials.
367
+ * The user will need to reconnect to use this server again.
368
+ *
369
+ * @param serverId - Server ID or slug to disconnect from
370
+ */
371
+ public async disconnectServer(serverId: string): Promise<void> {
372
+ const validatedServerId = this._validateServerId(serverId);
373
+
374
+ this._logger.info(`Disconnecting from server ${validatedServerId}`);
375
+
376
+ try {
377
+ await this._req('DELETE', `/servers/${validatedServerId}/connection`);
378
+ this._logger.info(`Successfully disconnected from server ${validatedServerId}`);
379
+ } catch (error: unknown) {
380
+ if (error instanceof HTTPError && error.statusCode === 404) {
381
+ throw new Error(
382
+ `Connection not found for server ${validatedServerId}. Server may not be connected.`
383
+ );
384
+ }
385
+ throw error;
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Validate server ID format.
391
+ * @private
392
+ */
393
+ private _validateServerId(serverId: string): string {
394
+ if (!serverId || typeof serverId !== 'string') {
395
+ throw new Error('Server ID must be a non-empty string');
396
+ }
397
+
398
+ // Accept both UUIDs and slugs (as per OpenAPI spec)
399
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
400
+ const slugRegex = /^[a-z0-9-]+$/;
401
+
402
+ if (!uuidRegex.test(serverId) && !slugRegex.test(serverId)) {
403
+ throw new Error(
404
+ 'Server ID must be a valid UUID or slug (lowercase letters, numbers, and hyphens only)'
405
+ );
406
+ }
407
+
408
+ return serverId;
409
+ }
410
+
411
+ /**
412
+ * Close the SDK and clean up resources.
413
+ */
414
+ public async close(): Promise<void> {
415
+ if (!this._closed) {
416
+ await this._http.close();
417
+ this._closed = true;
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Alias for close() to match Python SDK naming.
423
+ */
424
+ public async aclose(): Promise<void> {
425
+ await this.close();
426
+ }
427
+
428
+ /**
429
+ * Ensure a server is connected, initiating OAuth if needed.
430
+ * @param serverIdentifier - Server slug or provider name
431
+ * @param options - Options
432
+ */
433
+ public async ensureServerConnected(
434
+ serverIdentifier: string,
435
+ options: EnsureServerConnectedOptions = {}
436
+ ): Promise<void> {
437
+ const { pollSeconds = 60 } = options;
438
+
439
+ if (!isNode) {
440
+ throw new Error('ensureServerConnected requires Node.js environment for browser opening');
441
+ }
442
+
443
+ // 1. Locate server
444
+ const servers = await this.listServers();
445
+ const target = servers.find(
446
+ s =>
447
+ s.slug === serverIdentifier ||
448
+ (s.provider && s.provider.toLowerCase() === serverIdentifier.toLowerCase())
449
+ );
450
+
451
+ if (!target) {
452
+ throw new ServerNotFoundError(serverIdentifier);
453
+ }
454
+
455
+ if (target.connection_status === 'connected') {
456
+ return; // Already connected
457
+ }
458
+
459
+ // 2. Start OAuth flow
460
+ const connection = await this.initiateConnection(target.id);
461
+ const authUrl = connection.auth_url;
462
+ if (!authUrl) {
463
+ throw new Error('Registry did not return auth_url');
464
+ }
465
+
466
+ // 3. Open browser (shell-free)
467
+ const platform = os.platform();
468
+
469
+ // Validate URL scheme (require https, allow http only for localhost)
470
+ let parsed: URL;
471
+ try {
472
+ parsed = new URL(authUrl);
473
+ } catch {
474
+ throw new Error('Invalid auth_url returned by server');
475
+ }
476
+ if (
477
+ parsed.protocol !== 'https:' &&
478
+ !(
479
+ parsed.protocol === 'http:' &&
480
+ (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')
481
+ )
482
+ ) {
483
+ throw new Error('Auth URL must use HTTPS (http allowed only for localhost)');
484
+ }
485
+
486
+ try {
487
+ if (platform === 'darwin') {
488
+ spawn('open', [authUrl], { detached: true, stdio: 'ignore' }).unref();
489
+ } else if (platform === 'win32') {
490
+ spawn('powershell', ['-NoProfile', 'Start-Process', authUrl], {
491
+ detached: true,
492
+ stdio: 'ignore',
493
+ }).unref();
494
+ } else {
495
+ spawn('xdg-open', [authUrl], { detached: true, stdio: 'ignore' }).unref();
496
+ }
497
+ } catch (error) {
498
+ this._logger.warn('Failed to open browser', error);
499
+ }
500
+
501
+ // 4. Poll until connected or timeout
502
+ for (let i = 0; i < pollSeconds; i++) {
503
+ const status = await this.getConnectionStatus(target.id);
504
+ if (status === 'connected') {
505
+ return;
506
+ }
507
+ await new Promise(resolve => setTimeout(resolve, 1000));
508
+ }
509
+
510
+ throw new Error('OAuth connection was not completed in time');
511
+ }
512
+ }