@letta-ai/letta-code 0.28.7 → 0.28.8

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.
@@ -2,23 +2,38 @@
2
2
  /**
3
3
  * MCP HTTP Client - Connect to any MCP server over HTTP
4
4
  *
5
+ * Supports:
6
+ * - Static bearer / custom headers (--header "Authorization: Bearer ...")
7
+ * - OAuth 2.1 with PKCE + dynamic client registration (auto or --auth oauth)
8
+ *
5
9
  * Usage:
6
- * npx tsx mcp-http.ts <url> <command> [args]
10
+ * npx tsx mcp-http.ts <url> [options] <command> [args]
7
11
  *
8
12
  * Commands:
9
13
  * list-tools List available tools
10
14
  * list-resources List available resources
15
+ * info <tool> Show tool schema
11
16
  * call <tool> '<json>' Call a tool with JSON arguments
17
+ * login Run OAuth flow and cache tokens for this server
18
+ * logout Clear cached OAuth tokens for this server
12
19
  *
13
20
  * Options:
14
- * --header "Key: Value" Add HTTP header (can be repeated)
15
- *
16
- * Examples:
17
- * npx tsx mcp-http.ts http://localhost:3001/mcp list-tools
18
- * npx tsx mcp-http.ts http://localhost:3001/mcp call vault '{"action":"list"}'
19
- * npx tsx mcp-http.ts http://localhost:3001/mcp --header "Authorization: Bearer KEY" list-tools
21
+ * --header, -H "K: V" Add HTTP header (repeatable). Disables auto-OAuth.
22
+ * --auth <mode> "auto" (default), "oauth", or "none"
23
+ * --timeout <ms> Request timeout (default: 30000)
24
+ * --help, -h Show this help
20
25
  */
21
26
 
27
+ import { spawn } from "node:child_process";
28
+ import * as crypto from "node:crypto";
29
+ import * as fs from "node:fs";
30
+ import * as http from "node:http";
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+ import { URL } from "node:url";
34
+
35
+ // -------------------- Types --------------------
36
+
22
37
  interface JsonRpcRequest {
23
38
  jsonrpc: "2.0";
24
39
  method: string;
@@ -29,11 +44,7 @@ interface JsonRpcRequest {
29
44
  interface JsonRpcResponse {
30
45
  jsonrpc: "2.0";
31
46
  result?: unknown;
32
- error?: {
33
- code: number;
34
- message: string;
35
- data?: unknown;
36
- };
47
+ error?: { code: number; message: string; data?: unknown };
37
48
  id: number;
38
49
  }
39
50
 
@@ -42,14 +53,56 @@ interface ParsedArgs {
42
53
  command: string;
43
54
  commandArgs: string[];
44
55
  headers: Record<string, string>;
56
+ authMode: "auto" | "oauth" | "none";
57
+ timeoutMs: number;
58
+ }
59
+
60
+ interface OAuthServerMetadata {
61
+ issuer: string;
62
+ authorization_endpoint: string;
63
+ token_endpoint: string;
64
+ registration_endpoint?: string;
65
+ revocation_endpoint?: string;
66
+ code_challenge_methods_supported?: string[];
67
+ grant_types_supported?: string[];
68
+ scopes_supported?: string[];
45
69
  }
46
70
 
71
+ interface RegisteredClient {
72
+ client_id: string;
73
+ client_secret?: string;
74
+ registration_access_token?: string;
75
+ registration_client_uri?: string;
76
+ redirect_uris: string[];
77
+ token_endpoint_auth_method?: string;
78
+ }
79
+
80
+ interface TokenSet {
81
+ access_token: string;
82
+ refresh_token?: string;
83
+ token_type: string;
84
+ scope?: string;
85
+ expires_at?: number; // epoch seconds
86
+ }
87
+
88
+ interface CachedAuth {
89
+ server_url: string;
90
+ authorization_server: string;
91
+ metadata: OAuthServerMetadata;
92
+ client: RegisteredClient;
93
+ tokens?: TokenSet;
94
+ }
95
+
96
+ // -------------------- Arg parsing --------------------
97
+
47
98
  function parseArgs(): ParsedArgs {
48
99
  const args = process.argv.slice(2);
49
100
  const headers: Record<string, string> = {};
50
101
  let url = "";
51
102
  let command = "";
52
103
  const commandArgs: string[] = [];
104
+ let authMode: "auto" | "oauth" | "none" = "auto";
105
+ let timeoutMs = 30000;
53
106
 
54
107
  let i = 0;
55
108
  while (i < args.length) {
@@ -69,6 +122,12 @@ function parseArgs(): ParsedArgs {
69
122
  headers[key] = value;
70
123
  }
71
124
  }
125
+ } else if (arg === "--auth") {
126
+ const v = args[++i];
127
+ if (v === "auto" || v === "oauth" || v === "none") authMode = v;
128
+ } else if (arg === "--timeout") {
129
+ const v = args[++i];
130
+ if (v) timeoutMs = Number(v) || timeoutMs;
72
131
  } else if (arg === "--help" || arg === "-h") {
73
132
  printUsage();
74
133
  process.exit(0);
@@ -82,18 +141,471 @@ function parseArgs(): ParsedArgs {
82
141
  i++;
83
142
  }
84
143
 
85
- return { url, command, commandArgs, headers };
144
+ return { url, command, commandArgs, headers, authMode, timeoutMs };
145
+ }
146
+
147
+ // -------------------- Token cache --------------------
148
+
149
+ function cacheDir(): string {
150
+ const dir = path.join(os.homedir(), ".letta", "mcp-oauth");
151
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
152
+ return dir;
153
+ }
154
+
155
+ function cachePathForUrl(serverUrl: string): string {
156
+ const u = new URL(serverUrl);
157
+ // Include host + path so different MCP servers on the same host stay separate.
158
+ const key = `${u.host}${u.pathname}`.replace(/[^a-zA-Z0-9._-]+/g, "_");
159
+ return path.join(cacheDir(), `${key}.json`);
160
+ }
161
+
162
+ function loadCache(serverUrl: string): CachedAuth | null {
163
+ const p = cachePathForUrl(serverUrl);
164
+ try {
165
+ const raw = fs.readFileSync(p, "utf8");
166
+ return JSON.parse(raw) as CachedAuth;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ function saveCache(serverUrl: string, data: CachedAuth): void {
173
+ const p = cachePathForUrl(serverUrl);
174
+ fs.writeFileSync(p, JSON.stringify(data, null, 2), { mode: 0o600 });
175
+ try {
176
+ fs.chmodSync(p, 0o600);
177
+ } catch {
178
+ /* best effort */
179
+ }
180
+ }
181
+
182
+ function clearCache(serverUrl: string): boolean {
183
+ const p = cachePathForUrl(serverUrl);
184
+ try {
185
+ fs.unlinkSync(p);
186
+ return true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ // -------------------- OAuth helpers --------------------
193
+
194
+ function b64url(buf: Buffer): string {
195
+ return buf
196
+ .toString("base64")
197
+ .replace(/\+/g, "-")
198
+ .replace(/\//g, "_")
199
+ .replace(/=+$/, "");
200
+ }
201
+
202
+ function makePkce(): { verifier: string; challenge: string } {
203
+ const verifier = b64url(crypto.randomBytes(32));
204
+ const challenge = b64url(
205
+ crypto.createHash("sha256").update(verifier).digest(),
206
+ );
207
+ return { verifier, challenge };
208
+ }
209
+
210
+ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
211
+ const res = await fetch(url, init);
212
+ const text = await res.text();
213
+ if (!res.ok) {
214
+ throw new Error(`HTTP ${res.status} from ${url}: ${text.slice(0, 400)}`);
215
+ }
216
+ try {
217
+ return JSON.parse(text) as T;
218
+ } catch {
219
+ throw new Error(`Non-JSON response from ${url}: ${text.slice(0, 200)}`);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Parse `WWW-Authenticate: Bearer realm="...", resource_metadata="..."` etc.
225
+ * Returns a flat map of params. Case-insensitive keys.
226
+ */
227
+ function parseWwwAuthenticate(header: string): Record<string, string> {
228
+ const idx = header.indexOf(" ");
229
+ const params = idx >= 0 ? header.slice(idx + 1) : header;
230
+ const out: Record<string, string> = {};
231
+ const re =
232
+ /([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"|([a-zA-Z0-9_-]+)\s*=\s*([^,\s]+)/g;
233
+ let m: RegExpExecArray | null;
234
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex.exec loop
235
+ while ((m = re.exec(params))) {
236
+ const key = (m[1] || m[3] || "").toLowerCase();
237
+ const val = m[2] ?? m[4] ?? "";
238
+ if (key) out[key] = val;
239
+ }
240
+ return out;
241
+ }
242
+
243
+ /**
244
+ * Given an MCP server URL and a 401 WWW-Authenticate value, discover the OAuth
245
+ * authorization server metadata. Prefers `resource_metadata` when present, then
246
+ * falls back to `realm`, then to the server's own origin.
247
+ */
248
+ async function discoverAuthServer(
249
+ serverUrl: string,
250
+ wwwAuth: string | null,
251
+ ): Promise<{ authServer: string; metadata: OAuthServerMetadata }> {
252
+ const candidates: string[] = [];
253
+ const params = wwwAuth ? parseWwwAuthenticate(wwwAuth) : {};
254
+
255
+ if (params.resource_metadata) {
256
+ try {
257
+ const rm = await fetchJson<{ authorization_servers?: string[] }>(
258
+ params.resource_metadata,
259
+ );
260
+ if (rm.authorization_servers?.length) {
261
+ candidates.push(...rm.authorization_servers);
262
+ }
263
+ } catch {
264
+ /* fall through */
265
+ }
266
+ }
267
+ if (params.realm) candidates.push(params.realm);
268
+ candidates.push(new URL(serverUrl).origin);
269
+
270
+ const tried: string[] = [];
271
+ for (const issuer of candidates) {
272
+ const base = issuer.replace(/\/$/, "");
273
+ const wellKnowns = [
274
+ `${base}/.well-known/oauth-authorization-server`,
275
+ `${base}/.well-known/openid-configuration`,
276
+ ];
277
+ for (const w of wellKnowns) {
278
+ tried.push(w);
279
+ try {
280
+ const metadata = await fetchJson<OAuthServerMetadata>(w);
281
+ if (metadata.authorization_endpoint && metadata.token_endpoint) {
282
+ return { authServer: base, metadata };
283
+ }
284
+ } catch {
285
+ /* try next */
286
+ }
287
+ }
288
+ }
289
+ throw new Error(
290
+ `Could not discover OAuth server metadata. Tried:\n ${tried.join("\n ")}`,
291
+ );
292
+ }
293
+
294
+ async function registerClient(
295
+ metadata: OAuthServerMetadata,
296
+ redirectUri: string,
297
+ ): Promise<RegisteredClient> {
298
+ if (!metadata.registration_endpoint) {
299
+ throw new Error(
300
+ `Auth server has no registration_endpoint. Manual client registration is required; ` +
301
+ `pass a client_id via a static bearer header instead.`,
302
+ );
303
+ }
304
+ const body = {
305
+ client_name: "letta-code mcp-http",
306
+ redirect_uris: [redirectUri],
307
+ grant_types: ["authorization_code", "refresh_token"],
308
+ response_types: ["code"],
309
+ token_endpoint_auth_method: "none", // public client (PKCE)
310
+ application_type: "native",
311
+ };
312
+ const res = await fetch(metadata.registration_endpoint, {
313
+ method: "POST",
314
+ headers: { "Content-Type": "application/json" },
315
+ body: JSON.stringify(body),
316
+ });
317
+ const text = await res.text();
318
+ if (!res.ok) {
319
+ throw new Error(
320
+ `Dynamic client registration failed (${res.status}): ${text.slice(0, 400)}`,
321
+ );
322
+ }
323
+ const parsed = JSON.parse(text) as RegisteredClient;
324
+ return {
325
+ client_id: parsed.client_id,
326
+ client_secret: parsed.client_secret,
327
+ registration_access_token: parsed.registration_access_token,
328
+ registration_client_uri: parsed.registration_client_uri,
329
+ redirect_uris: parsed.redirect_uris || [redirectUri],
330
+ token_endpoint_auth_method: parsed.token_endpoint_auth_method || "none",
331
+ };
332
+ }
333
+
334
+ function openBrowser(url: string): void {
335
+ const platform = process.platform;
336
+ const cmd =
337
+ platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
338
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
339
+ try {
340
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
341
+ } catch {
342
+ /* user can copy the URL from stderr */
343
+ }
344
+ }
345
+
346
+ interface LoopbackResult {
347
+ code: string;
348
+ state: string;
349
+ }
350
+
351
+ interface LoopbackListener {
352
+ port: number;
353
+ done: Promise<LoopbackResult>;
86
354
  }
87
355
 
88
- // Session state
356
+ function startLoopback(expectedState: string): Promise<LoopbackListener> {
357
+ return new Promise((resolveOuter, rejectOuter) => {
358
+ let resolveDone!: (r: LoopbackResult) => void;
359
+ let rejectDone!: (e: Error) => void;
360
+ const done = new Promise<LoopbackResult>((res, rej) => {
361
+ resolveDone = res;
362
+ rejectDone = rej;
363
+ });
364
+ const server = http.createServer((req, res) => {
365
+ try {
366
+ const reqUrl = new URL(req.url || "/", "http://127.0.0.1");
367
+ if (reqUrl.pathname !== "/callback") {
368
+ res.writeHead(404).end("not found");
369
+ return;
370
+ }
371
+ const code = reqUrl.searchParams.get("code");
372
+ const state = reqUrl.searchParams.get("state");
373
+ const error = reqUrl.searchParams.get("error");
374
+ if (error) {
375
+ res.writeHead(400, { "Content-Type": "text/html" });
376
+ res.end(`<h1>Authorization failed</h1><p>${error}</p>`);
377
+ server.close();
378
+ rejectDone(new Error(`Authorization error: ${error}`));
379
+ return;
380
+ }
381
+ if (!code || !state) {
382
+ res.writeHead(400).end("missing code or state");
383
+ return;
384
+ }
385
+ if (state !== expectedState) {
386
+ res.writeHead(400).end("state mismatch");
387
+ server.close();
388
+ rejectDone(new Error("OAuth state mismatch"));
389
+ return;
390
+ }
391
+ res.writeHead(200, { "Content-Type": "text/html" });
392
+ res.end(
393
+ `<!doctype html><html><body style="font-family:system-ui;padding:2rem"><h1>Signed in.</h1><p>You can close this tab and return to the terminal.</p></body></html>`,
394
+ );
395
+ server.close();
396
+ resolveDone({ code, state });
397
+ } catch (e) {
398
+ rejectDone(e as Error);
399
+ }
400
+ });
401
+ server.on("error", (e) => {
402
+ rejectDone(e);
403
+ rejectOuter(e);
404
+ });
405
+ server.listen(0, "127.0.0.1", () => {
406
+ const port = (server.address() as { port: number }).port;
407
+ resolveOuter({ port, done });
408
+ });
409
+ });
410
+ }
411
+
412
+ async function exchangeCodeForTokens(
413
+ metadata: OAuthServerMetadata,
414
+ client: RegisteredClient,
415
+ code: string,
416
+ verifier: string,
417
+ redirectUri: string,
418
+ resource: string,
419
+ ): Promise<TokenSet> {
420
+ const body = new URLSearchParams({
421
+ grant_type: "authorization_code",
422
+ code,
423
+ redirect_uri: redirectUri,
424
+ client_id: client.client_id,
425
+ code_verifier: verifier,
426
+ resource,
427
+ });
428
+ if (client.client_secret) body.set("client_secret", client.client_secret);
429
+
430
+ const res = await fetch(metadata.token_endpoint, {
431
+ method: "POST",
432
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
433
+ body: body.toString(),
434
+ });
435
+ const text = await res.text();
436
+ if (!res.ok) {
437
+ throw new Error(
438
+ `Token exchange failed (${res.status}): ${text.slice(0, 400)}`,
439
+ );
440
+ }
441
+ const parsed = JSON.parse(text) as {
442
+ access_token: string;
443
+ refresh_token?: string;
444
+ token_type: string;
445
+ expires_in?: number;
446
+ scope?: string;
447
+ };
448
+ const now = Math.floor(Date.now() / 1000);
449
+ return {
450
+ access_token: parsed.access_token,
451
+ refresh_token: parsed.refresh_token,
452
+ token_type: parsed.token_type || "Bearer",
453
+ scope: parsed.scope,
454
+ expires_at: parsed.expires_in ? now + parsed.expires_in - 30 : undefined,
455
+ };
456
+ }
457
+
458
+ async function refreshTokens(
459
+ metadata: OAuthServerMetadata,
460
+ client: RegisteredClient,
461
+ refreshToken: string,
462
+ resource: string,
463
+ ): Promise<TokenSet> {
464
+ const body = new URLSearchParams({
465
+ grant_type: "refresh_token",
466
+ refresh_token: refreshToken,
467
+ client_id: client.client_id,
468
+ resource,
469
+ });
470
+ if (client.client_secret) body.set("client_secret", client.client_secret);
471
+ const res = await fetch(metadata.token_endpoint, {
472
+ method: "POST",
473
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
474
+ body: body.toString(),
475
+ });
476
+ const text = await res.text();
477
+ if (!res.ok) {
478
+ throw new Error(
479
+ `Token refresh failed (${res.status}): ${text.slice(0, 400)}`,
480
+ );
481
+ }
482
+ const parsed = JSON.parse(text) as {
483
+ access_token: string;
484
+ refresh_token?: string;
485
+ token_type: string;
486
+ expires_in?: number;
487
+ scope?: string;
488
+ };
489
+ const now = Math.floor(Date.now() / 1000);
490
+ return {
491
+ access_token: parsed.access_token,
492
+ refresh_token: parsed.refresh_token || refreshToken,
493
+ token_type: parsed.token_type || "Bearer",
494
+ scope: parsed.scope,
495
+ expires_at: parsed.expires_in ? now + parsed.expires_in - 30 : undefined,
496
+ };
497
+ }
498
+
499
+ async function runOAuthLogin(
500
+ serverUrl: string,
501
+ wwwAuth: string | null,
502
+ ): Promise<CachedAuth> {
503
+ process.stderr.write(`Discovering OAuth for ${serverUrl}...\n`);
504
+ const { authServer, metadata } = await discoverAuthServer(serverUrl, wwwAuth);
505
+ process.stderr.write(`Authorization server: ${authServer}\n`);
506
+
507
+ const state = b64url(crypto.randomBytes(16));
508
+ const { verifier, challenge } = makePkce();
509
+
510
+ const listener = await startLoopback(state);
511
+ const redirectUri = `http://127.0.0.1:${listener.port}/callback`;
512
+
513
+ process.stderr.write(`Registering client dynamically...\n`);
514
+ const client = await registerClient(metadata, redirectUri);
515
+
516
+ const authUrl = new URL(metadata.authorization_endpoint);
517
+ authUrl.searchParams.set("response_type", "code");
518
+ authUrl.searchParams.set("client_id", client.client_id);
519
+ authUrl.searchParams.set("redirect_uri", redirectUri);
520
+ authUrl.searchParams.set("state", state);
521
+ authUrl.searchParams.set("code_challenge", challenge);
522
+ authUrl.searchParams.set("code_challenge_method", "S256");
523
+ authUrl.searchParams.set("resource", serverUrl);
524
+ const scopes = metadata.scopes_supported?.includes("offline_access")
525
+ ? "openid offline_access"
526
+ : "openid";
527
+ authUrl.searchParams.set("scope", scopes);
528
+
529
+ process.stderr.write(
530
+ `Opening browser to sign in:\n ${authUrl.toString()}\n`,
531
+ );
532
+ openBrowser(authUrl.toString());
533
+
534
+ const result = await listener.done;
535
+ process.stderr.write(`Exchanging code for tokens...\n`);
536
+ const tokens = await exchangeCodeForTokens(
537
+ metadata,
538
+ client,
539
+ result.code,
540
+ verifier,
541
+ redirectUri,
542
+ serverUrl,
543
+ );
544
+
545
+ const cache: CachedAuth = {
546
+ server_url: serverUrl,
547
+ authorization_server: authServer,
548
+ metadata,
549
+ client,
550
+ tokens,
551
+ };
552
+ saveCache(serverUrl, cache);
553
+ process.stderr.write(
554
+ `Signed in. Tokens cached at ${cachePathForUrl(serverUrl)}\n`,
555
+ );
556
+ return cache;
557
+ }
558
+
559
+ async function ensureAccessToken(cache: CachedAuth): Promise<string> {
560
+ const now = Math.floor(Date.now() / 1000);
561
+ const tokens = cache.tokens;
562
+ if (!tokens) throw new Error("No tokens in cache; run `login`.");
563
+ if (!tokens.expires_at || tokens.expires_at > now) {
564
+ return tokens.access_token;
565
+ }
566
+ if (!tokens.refresh_token) {
567
+ throw new Error(
568
+ "Access token expired and no refresh token; run `login` again.",
569
+ );
570
+ }
571
+ const fresh = await refreshTokens(
572
+ cache.metadata,
573
+ cache.client,
574
+ tokens.refresh_token,
575
+ cache.server_url,
576
+ );
577
+ cache.tokens = fresh;
578
+ saveCache(cache.server_url, cache);
579
+ return fresh.access_token;
580
+ }
581
+
582
+ // -------------------- MCP session --------------------
583
+
89
584
  let sessionId: string | null = null;
90
585
  let initialized = false;
91
586
  let requestHeaders: Record<string, string> = {};
92
587
  let serverUrl = "";
588
+ let timeoutMs = 30000;
589
+ let authMode: "auto" | "oauth" | "none" = "auto";
590
+ let currentAuth: CachedAuth | null = null;
591
+
592
+ async function fetchWithTimeout(
593
+ url: string,
594
+ init: RequestInit,
595
+ ): Promise<Response> {
596
+ const ctrl = new AbortController();
597
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
598
+ try {
599
+ return await fetch(url, { ...init, signal: ctrl.signal });
600
+ } finally {
601
+ clearTimeout(timer);
602
+ }
603
+ }
93
604
 
94
605
  async function rawMcpRequest(
95
606
  method: string,
96
607
  params?: object,
608
+ opts: { allowOAuth?: boolean } = {},
97
609
  ): Promise<{ response: JsonRpcResponse; newSessionId?: string }> {
98
610
  const request: JsonRpcRequest = {
99
611
  jsonrpc: "2.0",
@@ -108,106 +620,127 @@ async function rawMcpRequest(
108
620
  ...requestHeaders,
109
621
  };
110
622
 
111
- if (sessionId) {
112
- headers["Mcp-Session-Id"] = sessionId;
623
+ // If we have an OAuth token and no explicit Authorization header, attach it.
624
+ if (!headers.Authorization && currentAuth?.tokens) {
625
+ const access = await ensureAccessToken(currentAuth);
626
+ headers.Authorization = `Bearer ${access}`;
113
627
  }
114
628
 
629
+ if (sessionId) headers["Mcp-Session-Id"] = sessionId;
630
+
631
+ let fetchResponse: Response;
115
632
  try {
116
- const fetchResponse = await fetch(serverUrl, {
633
+ fetchResponse = await fetchWithTimeout(serverUrl, {
117
634
  method: "POST",
118
635
  headers,
119
636
  body: JSON.stringify(request),
120
637
  });
121
-
122
- // Capture session ID from response
123
- const newSessionId =
124
- fetchResponse.headers.get("Mcp-Session-Id") || undefined;
125
-
126
- if (!fetchResponse.ok) {
127
- const text = await fetchResponse.text();
128
- if (fetchResponse.status === 401) {
129
- throw new Error(
130
- `Authentication required.\n` +
131
- `Add --header "Authorization: Bearer YOUR_KEY" or similar.`,
132
- );
133
- }
134
-
135
- // Try to parse as JSON-RPC error
136
- try {
137
- const errorResponse = JSON.parse(text) as JsonRpcResponse;
138
- return { response: errorResponse, newSessionId };
139
- } catch {
140
- throw new Error(
141
- `HTTP ${fetchResponse.status}: ${fetchResponse.statusText}\n${text}`,
142
- );
143
- }
144
- }
145
-
146
- const contentType = fetchResponse.headers.get("content-type") || "";
147
-
148
- // Handle JSON response
149
- if (contentType.includes("application/json")) {
150
- const jsonResponse = (await fetchResponse.json()) as JsonRpcResponse;
151
- return { response: jsonResponse, newSessionId };
638
+ } catch (error) {
639
+ if (error instanceof TypeError && error.message.includes("fetch")) {
640
+ throw new Error(
641
+ `Cannot connect to ${serverUrl}\nIs the MCP server running?`,
642
+ );
152
643
  }
644
+ throw error;
645
+ }
153
646
 
154
- // Handle SSE stream (simplified - just collect all events)
155
- if (contentType.includes("text/event-stream")) {
156
- const text = await fetchResponse.text();
157
- const dataLines = text
158
- .split("\n")
159
- .filter((line) => line.startsWith("data: "))
160
- .map((line) => line.slice(6));
161
-
162
- for (let i = dataLines.length - 1; i >= 0; i--) {
163
- const line = dataLines[i];
164
- if (!line) continue;
647
+ const newSessionId = fetchResponse.headers.get("Mcp-Session-Id") || undefined;
648
+
649
+ if (fetchResponse.status === 401) {
650
+ const wwwAuth = fetchResponse.headers.get("www-authenticate");
651
+ const hasStaticAuth = Boolean(requestHeaders.Authorization);
652
+ const canOAuth =
653
+ opts.allowOAuth !== false &&
654
+ !hasStaticAuth &&
655
+ (authMode === "oauth" ||
656
+ (authMode === "auto" && wwwAuth?.toLowerCase().startsWith("bearer")));
657
+
658
+ if (canOAuth) {
659
+ await fetchResponse.text().catch(() => "");
660
+ if (!currentAuth) currentAuth = loadCache(serverUrl);
661
+ if (currentAuth?.tokens?.refresh_token) {
165
662
  try {
166
- const parsed = JSON.parse(line);
167
- if (parsed.jsonrpc === "2.0") {
168
- return { response: parsed as JsonRpcResponse, newSessionId };
169
- }
663
+ currentAuth.tokens = await refreshTokens(
664
+ currentAuth.metadata,
665
+ currentAuth.client,
666
+ currentAuth.tokens.refresh_token,
667
+ serverUrl,
668
+ );
669
+ saveCache(serverUrl, currentAuth);
170
670
  } catch {
171
- // Continue to previous line
671
+ currentAuth = await runOAuthLogin(serverUrl, wwwAuth);
172
672
  }
673
+ } else {
674
+ currentAuth = await runOAuthLogin(serverUrl, wwwAuth);
173
675
  }
174
- throw new Error("No valid JSON-RPC response found in SSE stream");
676
+ return rawMcpRequest(method, params, { allowOAuth: false });
175
677
  }
176
678
 
177
- throw new Error(`Unexpected content type: ${contentType}`);
178
- } catch (error) {
179
- if (error instanceof TypeError && error.message.includes("fetch")) {
679
+ const text = await fetchResponse.text().catch(() => "");
680
+ throw new Error(
681
+ `Authentication required (401).${
682
+ wwwAuth ? ` WWW-Authenticate: ${wwwAuth}` : ""
683
+ }\n${text}\n` +
684
+ `Add --header "Authorization: Bearer YOUR_KEY", or run \`login\` first, or pass --auth oauth.`,
685
+ );
686
+ }
687
+
688
+ if (!fetchResponse.ok) {
689
+ const text = await fetchResponse.text();
690
+ try {
691
+ const errorResponse = JSON.parse(text) as JsonRpcResponse;
692
+ return { response: errorResponse, newSessionId };
693
+ } catch {
180
694
  throw new Error(
181
- `Cannot connect to ${serverUrl}\nIs the MCP server running?`,
695
+ `HTTP ${fetchResponse.status}: ${fetchResponse.statusText}\n${text}`,
182
696
  );
183
697
  }
184
- throw error;
185
698
  }
699
+
700
+ const contentType = fetchResponse.headers.get("content-type") || "";
701
+
702
+ if (contentType.includes("application/json")) {
703
+ const jsonResponse = (await fetchResponse.json()) as JsonRpcResponse;
704
+ return { response: jsonResponse, newSessionId };
705
+ }
706
+
707
+ if (contentType.includes("text/event-stream")) {
708
+ const text = await fetchResponse.text();
709
+ const dataLines = text
710
+ .split("\n")
711
+ .filter((line) => line.startsWith("data: "))
712
+ .map((line) => line.slice(6));
713
+
714
+ for (let i = dataLines.length - 1; i >= 0; i--) {
715
+ const line = dataLines[i];
716
+ if (!line) continue;
717
+ try {
718
+ const parsed = JSON.parse(line);
719
+ if (parsed.jsonrpc === "2.0") {
720
+ return { response: parsed as JsonRpcResponse, newSessionId };
721
+ }
722
+ } catch {
723
+ /* continue */
724
+ }
725
+ }
726
+ throw new Error("No valid JSON-RPC response found in SSE stream");
727
+ }
728
+
729
+ throw new Error(`Unexpected content type: ${contentType}`);
186
730
  }
187
731
 
188
732
  async function ensureInitialized(): Promise<void> {
189
733
  if (initialized) return;
190
-
191
734
  const { response, newSessionId } = await rawMcpRequest("initialize", {
192
735
  protocolVersion: "2024-11-05",
193
736
  capabilities: {},
194
- clientInfo: {
195
- name: "mcp-http-cli",
196
- version: "1.0.0",
197
- },
737
+ clientInfo: { name: "mcp-http-cli", version: "1.1.0" },
198
738
  });
199
-
200
- if (newSessionId) {
201
- sessionId = newSessionId;
202
- }
203
-
739
+ if (newSessionId) sessionId = newSessionId;
204
740
  if (response.error) {
205
741
  throw new Error(`Initialization failed: ${response.error.message}`);
206
742
  }
207
-
208
- // Send initialized notification
209
743
  await rawMcpRequest("notifications/initialized", {});
210
-
211
744
  initialized = true;
212
745
  }
213
746
 
@@ -216,66 +749,50 @@ async function mcpRequest(
216
749
  params?: object,
217
750
  ): Promise<JsonRpcResponse> {
218
751
  await ensureInitialized();
219
-
220
752
  const { response, newSessionId } = await rawMcpRequest(method, params);
221
-
222
- if (newSessionId) {
223
- sessionId = newSessionId;
224
- }
225
-
753
+ if (newSessionId) sessionId = newSessionId;
226
754
  return response;
227
755
  }
228
756
 
757
+ // -------------------- Commands --------------------
758
+
229
759
  async function listTools(): Promise<void> {
230
760
  const response = await mcpRequest("tools/list");
231
-
232
761
  if (response.error) {
233
762
  console.error("Error:", response.error.message);
234
763
  process.exit(1);
235
764
  }
236
-
237
765
  const result = response.result as {
238
766
  tools: Array<{ name: string; description: string; inputSchema: object }>;
239
767
  };
240
-
241
768
  console.log("Available tools:\n");
242
769
  for (const tool of result.tools) {
243
770
  console.log(` ${tool.name}`);
244
- if (tool.description) {
245
- console.log(` ${tool.description}\n`);
246
- } else {
247
- console.log();
248
- }
771
+ if (tool.description) console.log(` ${tool.description}\n`);
772
+ else console.log();
249
773
  }
250
-
251
774
  console.log(`\nTotal: ${result.tools.length} tools`);
252
775
  console.log("\nUse 'call <tool> <json-args>' to invoke a tool");
253
776
  }
254
777
 
255
778
  async function listResources(): Promise<void> {
256
779
  const response = await mcpRequest("resources/list");
257
-
258
780
  if (response.error) {
259
781
  console.error("Error:", response.error.message);
260
782
  process.exit(1);
261
783
  }
262
-
263
784
  const result = response.result as {
264
785
  resources: Array<{ uri: string; name: string; description?: string }>;
265
786
  };
266
-
267
787
  if (!result.resources || result.resources.length === 0) {
268
788
  console.log("No resources available.");
269
789
  return;
270
790
  }
271
-
272
791
  console.log("Available resources:\n");
273
792
  for (const resource of result.resources) {
274
793
  console.log(` ${resource.uri}`);
275
794
  console.log(` ${resource.name}`);
276
- if (resource.description) {
277
- console.log(` ${resource.description}`);
278
- }
795
+ if (resource.description) console.log(` ${resource.description}`);
279
796
  console.log();
280
797
  }
281
798
  }
@@ -288,12 +805,10 @@ async function callTool(toolName: string, argsJson: string): Promise<void> {
288
805
  console.error(`Invalid JSON: ${argsJson}`);
289
806
  process.exit(1);
290
807
  }
291
-
292
808
  const response = await mcpRequest("tools/call", {
293
809
  name: toolName,
294
810
  arguments: args,
295
811
  });
296
-
297
812
  if (response.error) {
298
813
  console.error("Error:", response.error.message);
299
814
  if (response.error.data) {
@@ -301,22 +816,18 @@ async function callTool(toolName: string, argsJson: string): Promise<void> {
301
816
  }
302
817
  process.exit(1);
303
818
  }
304
-
305
819
  console.log(JSON.stringify(response.result, null, 2));
306
820
  }
307
821
 
308
822
  async function getToolSchema(toolName: string): Promise<void> {
309
823
  const response = await mcpRequest("tools/list");
310
-
311
824
  if (response.error) {
312
825
  console.error("Error:", response.error.message);
313
826
  process.exit(1);
314
827
  }
315
-
316
828
  const result = response.result as {
317
829
  tools: Array<{ name: string; description: string; inputSchema: object }>;
318
830
  };
319
-
320
831
  const tool = result.tools.find((t) => t.name === toolName);
321
832
  if (!tool) {
322
833
  console.error(`Tool not found: ${toolName}`);
@@ -325,15 +836,48 @@ async function getToolSchema(toolName: string): Promise<void> {
325
836
  );
326
837
  process.exit(1);
327
838
  }
328
-
329
839
  console.log(`Tool: ${tool.name}\n`);
330
- if (tool.description) {
331
- console.log(`Description: ${tool.description}\n`);
332
- }
840
+ if (tool.description) console.log(`Description: ${tool.description}\n`);
333
841
  console.log("Input Schema:");
334
842
  console.log(JSON.stringify(tool.inputSchema, null, 2));
335
843
  }
336
844
 
845
+ async function loginCommand(): Promise<void> {
846
+ let wwwAuth: string | null = null;
847
+ try {
848
+ const res = await fetchWithTimeout(serverUrl, {
849
+ method: "POST",
850
+ headers: {
851
+ "Content-Type": "application/json",
852
+ Accept: "application/json, text/event-stream",
853
+ },
854
+ body: JSON.stringify({
855
+ jsonrpc: "2.0",
856
+ method: "initialize",
857
+ params: {
858
+ protocolVersion: "2024-11-05",
859
+ capabilities: {},
860
+ clientInfo: { name: "mcp-http-cli", version: "1.1.0" },
861
+ },
862
+ id: 1,
863
+ }),
864
+ });
865
+ wwwAuth = res.headers.get("www-authenticate");
866
+ } catch {
867
+ /* proceed anyway */
868
+ }
869
+ await runOAuthLogin(serverUrl, wwwAuth);
870
+ }
871
+
872
+ function logoutCommand(): void {
873
+ const removed = clearCache(serverUrl);
874
+ console.log(
875
+ removed
876
+ ? `Cleared cached tokens for ${serverUrl}`
877
+ : `No cached tokens for ${serverUrl}`,
878
+ );
879
+ }
880
+
337
881
  function printUsage(): void {
338
882
  console.log(`MCP HTTP Client - Connect to any MCP server over HTTP
339
883
 
@@ -344,79 +888,86 @@ Commands:
344
888
  list-resources List available resources
345
889
  info <tool> Show tool schema/parameters
346
890
  call <tool> '<json>' Call a tool with JSON arguments
891
+ login Run OAuth flow and cache tokens for this server
892
+ logout Clear cached OAuth tokens for this server
347
893
 
348
894
  Options:
349
- --header, -H "K: V" Add HTTP header (repeatable)
895
+ --header, -H "K: V" Add HTTP header (repeatable). Disables auto-OAuth.
896
+ --auth <mode> "auto" (default), "oauth", or "none"
897
+ --timeout <ms> Request timeout (default: 30000)
350
898
  --help, -h Show this help
351
899
 
900
+ Authentication:
901
+ Static bearer: --header "Authorization: Bearer YOUR_KEY"
902
+ OAuth 2.1: Just run any command; a 401 with WWW-Authenticate triggers
903
+ browser-based login via PKCE + dynamic client registration.
904
+ Tokens are cached at ~/.letta/mcp-oauth/ and auto-refreshed.
905
+
352
906
  Examples:
353
- # List tools from a server
354
907
  npx tsx mcp-http.ts http://localhost:3001/mcp list-tools
355
-
356
- # With authentication
357
- npx tsx mcp-http.ts http://localhost:3001/mcp --header "Authorization: Bearer KEY" list-tools
358
-
359
- # Get tool schema
360
- npx tsx mcp-http.ts http://localhost:3001/mcp info vault
361
-
362
- # Call a tool
363
- npx tsx mcp-http.ts http://localhost:3001/mcp call vault '{"action":"list"}'
908
+ npx tsx mcp-http.ts https://example.com/mcp login
909
+ npx tsx mcp-http.ts https://example.com/mcp list-tools
910
+ npx tsx mcp-http.ts https://example.com/mcp call some_tool '{"x":1}'
364
911
  `);
365
912
  }
366
913
 
367
914
  async function main(): Promise<void> {
368
- const { url, command, commandArgs, headers } = parseArgs();
915
+ const parsed = parseArgs();
369
916
 
370
- if (!url) {
917
+ if (!parsed.url) {
371
918
  console.error("Error: URL is required\n");
372
919
  printUsage();
373
920
  process.exit(1);
374
921
  }
375
922
 
376
- if (!command) {
923
+ serverUrl = parsed.url;
924
+ requestHeaders = parsed.headers;
925
+ timeoutMs = parsed.timeoutMs;
926
+ authMode = parsed.authMode;
927
+ currentAuth = authMode === "none" ? null : loadCache(serverUrl);
928
+
929
+ if (!parsed.command) {
377
930
  console.error("Error: Command is required\n");
378
931
  printUsage();
379
932
  process.exit(1);
380
933
  }
381
934
 
382
- // Set globals
383
- serverUrl = url;
384
- requestHeaders = headers;
385
-
386
935
  try {
387
- switch (command) {
936
+ switch (parsed.command) {
388
937
  case "list-tools":
389
938
  await listTools();
390
939
  break;
391
-
392
940
  case "list-resources":
393
941
  await listResources();
394
942
  break;
395
-
396
943
  case "info": {
397
- const [toolName] = commandArgs;
944
+ const [toolName] = parsed.commandArgs;
398
945
  if (!toolName) {
399
- console.error("Error: Tool name required");
400
- console.error("Usage: info <tool>");
946
+ console.error("Error: Tool name required\nUsage: info <tool>");
401
947
  process.exit(1);
402
948
  }
403
949
  await getToolSchema(toolName);
404
950
  break;
405
951
  }
406
-
407
952
  case "call": {
408
- const [toolName, argsJson] = commandArgs;
953
+ const [toolName, argsJson] = parsed.commandArgs;
409
954
  if (!toolName) {
410
- console.error("Error: Tool name required");
411
- console.error("Usage: call <tool> '<json-args>'");
955
+ console.error(
956
+ "Error: Tool name required\nUsage: call <tool> '<json-args>'",
957
+ );
412
958
  process.exit(1);
413
959
  }
414
960
  await callTool(toolName, argsJson || "{}");
415
961
  break;
416
962
  }
417
-
963
+ case "login":
964
+ await loginCommand();
965
+ break;
966
+ case "logout":
967
+ logoutCommand();
968
+ break;
418
969
  default:
419
- console.error(`Unknown command: ${command}\n`);
970
+ console.error(`Unknown command: ${parsed.command}\n`);
420
971
  printUsage();
421
972
  process.exit(1);
422
973
  }