@johpaz/hive-sdk 0.4.7 → 0.4.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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Verificación y lectura de los webhooks de la WhatsApp Cloud API.
3
+ *
4
+ * Son funciones puras a propósito: quien las usa decide después qué hacer con
5
+ * el mensaje. El canal del SDK arma un `IncomingMessage`; hive-cloud, que es
6
+ * multi-inquilino sobre Postgres, arma una conversación, lo ingesta al CRM y lo
7
+ * mete en su retén. Las dos cosas comparten exactamente esto: comprobar la
8
+ * firma y entender el payload.
9
+ */
10
+
11
+ import { createHmac, timingSafeEqual } from "node:crypto";
12
+
13
+ export interface WhatsAppReferral {
14
+ /** Id del anuncio que originó la conversación (`source_id`). */
15
+ sourceId?: string;
16
+ sourceUrl?: string;
17
+ sourceType?: string;
18
+ headline?: string;
19
+ body?: string;
20
+ /** El identificador de clic que la Conversions API necesita para cerrar el círculo. */
21
+ ctwaClid?: string;
22
+ }
23
+
24
+ export interface WhatsAppMediaRef {
25
+ id: string;
26
+ mimeType?: string;
27
+ caption?: string;
28
+ fileName?: string;
29
+ /** `true` si es una nota de voz y no un archivo de audio adjunto. */
30
+ voice?: boolean;
31
+ }
32
+
33
+ export type WhatsAppMediaKind = "audio" | "image" | "document" | "video" | "sticker";
34
+
35
+ export interface WhatsAppInbound {
36
+ id: string;
37
+ from: string;
38
+ timestamp?: number;
39
+ /** `text`, `image`, `audio`, `interactive`, `button`, `location`, … */
40
+ type: string;
41
+ text?: string;
42
+ media?: WhatsAppMediaRef;
43
+ mediaKind?: WhatsAppMediaKind;
44
+ /** Respuesta a un botón o a una lista. */
45
+ interactive?: { id: string; title?: string };
46
+ referral?: WhatsAppReferral;
47
+ profileName?: string;
48
+ /** El mensaje tal como llegó, para lo que este tipo no cubra. */
49
+ raw: unknown;
50
+ }
51
+
52
+ export interface WhatsAppStatusEvent {
53
+ id: string;
54
+ recipientId: string;
55
+ /** `sent`, `delivered`, `read`, `failed`. */
56
+ status: string;
57
+ timestamp?: number;
58
+ errors?: { code: number; title?: string }[];
59
+ }
60
+
61
+ export interface WhatsAppWebhookEvent {
62
+ phoneNumberId: string;
63
+ displayPhoneNumber?: string;
64
+ messages: WhatsAppInbound[];
65
+ statuses: WhatsAppStatusEvent[];
66
+ }
67
+
68
+ /**
69
+ * Comprueba la firma `X-Hub-Signature-256` con el secreto de la app.
70
+ *
71
+ * Sin esto, cualquiera que conozca la URL puede inyectar conversaciones.
72
+ */
73
+ export function verifySignature(
74
+ rawBody: string,
75
+ signatureHeader: string | null | undefined,
76
+ appSecret: string
77
+ ): boolean {
78
+ if (!signatureHeader || !appSecret) return false;
79
+ const expected = Buffer.from(
80
+ `sha256=${createHmac("sha256", appSecret).update(rawBody).digest("hex")}`
81
+ );
82
+ const actual = Buffer.from(signatureHeader);
83
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
84
+ }
85
+
86
+ /**
87
+ * Resuelve el `hub.challenge` que Meta manda al registrar el webhook.
88
+ *
89
+ * El token se comprueba con un predicado en vez de compararlo acá porque cada
90
+ * aplicación lo guarda distinto: el SDK tiene uno por canal, y hive-cloud tiene
91
+ * un hash por cuenta de cliente y un token en texto plano para el número de
92
+ * plataforma.
93
+ */
94
+ export function verifyChallenge(
95
+ query: URLSearchParams | Record<string, string | undefined>,
96
+ isValidToken: (token: string) => boolean
97
+ ): string | null {
98
+ const get = (key: string): string | undefined =>
99
+ query instanceof URLSearchParams ? (query.get(key) ?? undefined) : query[key];
100
+
101
+ const mode = get("hub.mode");
102
+ const token = get("hub.verify_token");
103
+ const challenge = get("hub.challenge");
104
+
105
+ if (mode !== "subscribe" || !token || !challenge) return null;
106
+ if (!isValidToken(token)) return null;
107
+ return challenge;
108
+ }
109
+
110
+ /** Compara dos tokens en texto plano sin filtrar información por el tiempo. */
111
+ export function safeTokenEquals(received: string, expected: string): boolean {
112
+ const a = Buffer.from(received);
113
+ const b = Buffer.from(expected);
114
+ return a.length === b.length && timingSafeEqual(a, b);
115
+ }
116
+
117
+ const MEDIA_KINDS: WhatsAppMediaKind[] = ["audio", "image", "document", "video", "sticker"];
118
+
119
+ /**
120
+ * Convierte el payload del webhook en eventos por número.
121
+ *
122
+ * Recorre **todas** las `entry` y `changes`: Meta puede agrupar varias
123
+ * notificaciones en un mismo POST, y quedarse con `entry[0].changes[0]` pierde
124
+ * mensajes en silencio.
125
+ */
126
+ export function parseWebhook(body: unknown): WhatsAppWebhookEvent[] {
127
+ const entries = (body as { entry?: unknown[] })?.entry;
128
+ if (!Array.isArray(entries)) return [];
129
+
130
+ const events: WhatsAppWebhookEvent[] = [];
131
+
132
+ for (const entry of entries) {
133
+ const changes = (entry as { changes?: unknown[] })?.changes;
134
+ if (!Array.isArray(changes)) continue;
135
+
136
+ for (const change of changes) {
137
+ const typed = change as { field?: string; value?: Record<string, any> };
138
+ if (typed.field && typed.field !== "messages") continue;
139
+
140
+ const value = typed.value;
141
+ const phoneNumberId = value?.metadata?.phone_number_id;
142
+ if (!phoneNumberId) continue;
143
+
144
+ const contacts: any[] = Array.isArray(value?.contacts) ? value!.contacts : [];
145
+ const messages: WhatsAppInbound[] = [];
146
+ const statuses: WhatsAppStatusEvent[] = [];
147
+
148
+ for (const msg of Array.isArray(value?.messages) ? value!.messages : []) {
149
+ messages.push(normalizeMessage(msg, contacts));
150
+ }
151
+
152
+ for (const status of Array.isArray(value?.statuses) ? value!.statuses : []) {
153
+ statuses.push({
154
+ id: String(status.id ?? ""),
155
+ recipientId: String(status.recipient_id ?? ""),
156
+ status: String(status.status ?? ""),
157
+ timestamp: status.timestamp ? Number(status.timestamp) : undefined,
158
+ errors: Array.isArray(status.errors)
159
+ ? status.errors.map((e: any) => ({ code: Number(e.code), title: e.title }))
160
+ : undefined,
161
+ });
162
+ }
163
+
164
+ events.push({
165
+ phoneNumberId: String(phoneNumberId),
166
+ displayPhoneNumber: value?.metadata?.display_phone_number,
167
+ messages,
168
+ statuses,
169
+ });
170
+ }
171
+ }
172
+
173
+ return events;
174
+ }
175
+
176
+ function normalizeMessage(msg: any, contacts: any[]): WhatsAppInbound {
177
+ const from = String(msg.from ?? "");
178
+ const type = String(msg.type ?? "unknown");
179
+
180
+ const inbound: WhatsAppInbound = {
181
+ id: String(msg.id ?? ""),
182
+ from,
183
+ timestamp: msg.timestamp ? Number(msg.timestamp) : undefined,
184
+ type,
185
+ profileName: contacts.find((c) => c?.wa_id === from)?.profile?.name,
186
+ raw: msg,
187
+ };
188
+
189
+ if (msg.referral?.source_id || msg.referral?.ctwa_clid) {
190
+ inbound.referral = {
191
+ sourceId: msg.referral.source_id ? String(msg.referral.source_id) : undefined,
192
+ sourceUrl: msg.referral.source_url,
193
+ sourceType: msg.referral.source_type,
194
+ headline: msg.referral.headline,
195
+ body: msg.referral.body,
196
+ ctwaClid: msg.referral.ctwa_clid ? String(msg.referral.ctwa_clid) : undefined,
197
+ };
198
+ }
199
+
200
+ if (type === "text") {
201
+ inbound.text = msg.text?.body ?? "";
202
+ return inbound;
203
+ }
204
+
205
+ if (MEDIA_KINDS.includes(type as WhatsAppMediaKind)) {
206
+ const payload = msg[type] ?? {};
207
+ inbound.mediaKind = type as WhatsAppMediaKind;
208
+ inbound.media = {
209
+ id: String(payload.id ?? ""),
210
+ mimeType: payload.mime_type,
211
+ caption: payload.caption,
212
+ fileName: payload.filename,
213
+ voice: payload.voice === true,
214
+ };
215
+ if (payload.caption) inbound.text = payload.caption;
216
+ return inbound;
217
+ }
218
+
219
+ if (type === "interactive") {
220
+ const reply = msg.interactive?.button_reply ?? msg.interactive?.list_reply;
221
+ if (reply) {
222
+ inbound.interactive = { id: String(reply.id ?? ""), title: reply.title };
223
+ inbound.text = reply.title ?? reply.id;
224
+ }
225
+ return inbound;
226
+ }
227
+
228
+ if (type === "button") {
229
+ inbound.interactive = { id: String(msg.button?.payload ?? ""), title: msg.button?.text };
230
+ inbound.text = msg.button?.text ?? msg.button?.payload;
231
+ return inbound;
232
+ }
233
+
234
+ return inbound;
235
+ }
@@ -1,11 +1,7 @@
1
- import makeWASocket, {
2
- DisconnectReason,
3
- useMultiFileAuthState,
4
- fetchLatestBaileysVersion,
5
- downloadMediaMessage,
6
- type WASocket,
7
- type ConnectionState,
8
- type WAMessage,
1
+ import type {
2
+ WASocket,
3
+ ConnectionState,
4
+ WAMessage,
9
5
  } from "@whiskeysockets/baileys";
10
6
  import type { ChannelConfig, IncomingMessage, OutboundMessage } from "./base.ts";
11
7
  import { BaseChannel } from "./base.ts";
@@ -15,18 +11,44 @@ import { homedir } from "node:os";
15
11
  import { logger } from "../utils/logger.ts";
16
12
  import { updateDoc } from "../storage/hive.ts";
17
13
  import type { ChannelDoc } from "../storage/collections.ts";
18
- // @ts-ignore — no type definitions for qrcode-terminal
19
- import qrcodeTerminal from "qrcode-terminal";
20
-
21
- // Baileys uses the `ws` npm package which triggers "[bun] Warning: ws.WebSocket 'upgrade'
22
- // event is not implemented in bun" etc. Bun writes these directly to stderr from native
23
- // code, bypassing process.emitWarning. Patch process.stderr.write to filter them out.
24
- const _origStderrWrite = process.stderr.write.bind(process.stderr);
25
- (process.stderr as any).write = function (chunk: string | Buffer, ...args: unknown[]) {
26
- const str = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString() : "";
27
- if (str.includes("[bun] Warning:") && str.includes("not implemented in bun")) return true;
28
- return _origStderrWrite(chunk, ...(args as any[]));
29
- };
14
+
15
+ /**
16
+ * Baileys se carga recién cuando alguien conecta este canal de verdad.
17
+ *
18
+ * Antes se importaba arriba de todo. Como el índice del SDK re-exporta este
19
+ * archivo, cualquiera que importara `@johpaz/hive-sdk` —hive-cloud, por
20
+ * ejemplo, que no usa ningún canal del SDK— se traía Baileys entero y, de
21
+ * paso, el parche de `process.stderr.write` de acá abajo, en cada proceso.
22
+ * Diferido, quien no usa WhatsApp por código QR no paga nada.
23
+ */
24
+ let baileys: typeof import("@whiskeysockets/baileys") | null = null;
25
+
26
+ async function loadBaileys(): Promise<NonNullable<typeof baileys>> {
27
+ if (!baileys) {
28
+ filterBunWsWarnings();
29
+ baileys = await import("@whiskeysockets/baileys");
30
+ }
31
+ return baileys;
32
+ }
33
+
34
+ let stderrFiltered = false;
35
+
36
+ /**
37
+ * Baileys usa el paquete `ws`, que dispara "[bun] Warning: ws.WebSocket
38
+ * 'upgrade' event is not implemented in bun" y parecidos. Bun los escribe
39
+ * directo a stderr desde código nativo, sin pasar por `process.emitWarning`,
40
+ * así que la única forma de filtrarlos es envolver `process.stderr.write`.
41
+ */
42
+ function filterBunWsWarnings(): void {
43
+ if (stderrFiltered) return;
44
+ stderrFiltered = true;
45
+ const original = process.stderr.write.bind(process.stderr);
46
+ (process.stderr as any).write = function (chunk: string | Buffer, ...args: unknown[]) {
47
+ const str = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString() : "";
48
+ if (str.includes("[bun] Warning:") && str.includes("not implemented in bun")) return true;
49
+ return original(chunk, ...(args as any[]));
50
+ };
51
+ }
30
52
 
31
53
  export interface WhatsAppConfig extends ChannelConfig {
32
54
  accountId: string;
@@ -114,6 +136,9 @@ export class WhatsAppChannel extends BaseChannel {
114
136
  this.log.info("Connecting to WhatsApp...");
115
137
 
116
138
  try {
139
+ const { default: makeWASocket, useMultiFileAuthState, fetchLatestBaileysVersion } =
140
+ await loadBaileys();
141
+
117
142
  const { state, saveCreds } = await useMultiFileAuthState(this.authPath);
118
143
  const { version } = await fetchLatestBaileysVersion();
119
144
  this.connectionState.waVersion = version.join(".");
@@ -170,13 +195,13 @@ export class WhatsAppChannel extends BaseChannel {
170
195
  if (qr) {
171
196
  this.connectionState.status = "qr";
172
197
  this.connectionState.qrCode = qr;
173
- this.printQR(qr);
198
+ void this.printQR(qr);
174
199
  this.log.info("Scan the QR code above with WhatsApp");
175
200
  }
176
201
 
177
202
  if (connection === "close") {
178
203
  const statusCode = (lastDisconnect?.error as { output?: { statusCode: number } })?.output?.statusCode;
179
- const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
204
+ const shouldReconnect = statusCode !== baileys!.DisconnectReason.loggedOut;
180
205
 
181
206
  this.connectionState.status = "disconnected";
182
207
  this.log.warn(`WhatsApp disconnected: ${statusCode}`);
@@ -186,8 +211,8 @@ export class WhatsAppChannel extends BaseChannel {
186
211
  } catch { /* ignore DB errors */ }
187
212
 
188
213
  const needsSessionClear =
189
- statusCode === DisconnectReason.loggedOut ||
190
- statusCode === DisconnectReason.badSession;
214
+ statusCode === baileys!.DisconnectReason.loggedOut ||
215
+ statusCode === baileys!.DisconnectReason.badSession;
191
216
  // NOTE: 515 (restartRequired) is sent by WhatsApp AFTER a successful pairing
192
217
  // to signal Baileys to reconnect with the new credentials — do NOT clear session.
193
218
 
@@ -233,11 +258,16 @@ export class WhatsAppChannel extends BaseChannel {
233
258
  return { ...this.connectionState };
234
259
  }
235
260
 
236
- private printQR(qr: string): void {
261
+ private async printQR(qr: string): Promise<void> {
237
262
  this.log.info("\n" + "=".repeat(50));
238
263
  this.log.info(" WHATSAPP QR CODE - Scan with your phone");
239
264
  this.log.info("=".repeat(50) + "\n");
240
265
 
266
+ // @ts-ignore — no type definitions for qrcode-terminal
267
+ const qrcodeTerminal = (await import("qrcode-terminal")).default as {
268
+ generate: (text: string, opts: { small: boolean }, cb: (qr: string) => void) => void;
269
+ };
270
+
241
271
  qrcodeTerminal.generate(qr, { small: false }, (qrString: string) => {
242
272
  this.log.info(qrString);
243
273
  });
@@ -307,7 +337,7 @@ export class WhatsAppChannel extends BaseChannel {
307
337
 
308
338
  if (hasAudio && this.socket) {
309
339
  try {
310
- audioBuffer = await downloadMediaMessage(
340
+ audioBuffer = await baileys!.downloadMediaMessage(
311
341
  typedMsg as unknown as WAMessage,
312
342
  "buffer",
313
343
  {},
@@ -320,7 +350,7 @@ export class WhatsAppChannel extends BaseChannel {
320
350
 
321
351
  if (hasImage && this.socket) {
322
352
  try {
323
- imageBuffer = await downloadMediaMessage(
353
+ imageBuffer = await baileys!.downloadMediaMessage(
324
354
  typedMsg as unknown as WAMessage,
325
355
  "buffer",
326
356
  {},
@@ -336,7 +366,7 @@ export class WhatsAppChannel extends BaseChannel {
336
366
 
337
367
  if (hasDocument && this.socket) {
338
368
  try {
339
- documentBuffer = await downloadMediaMessage(
369
+ documentBuffer = await baileys!.downloadMediaMessage(
340
370
  typedMsg as unknown as WAMessage,
341
371
  "buffer",
342
372
  {},
@@ -6,7 +6,7 @@ import { availableParallelism, homedir } from "node:os";
6
6
 
7
7
  const LogLevelSchema = z.enum(["debug", "info", "warn", "error"]);
8
8
  const DMPolicySchema = z.enum(["open", "pairing", "allowlist"]);
9
- const TransportSchema = z.enum(["stdio", "sse", "websocket"]);
9
+ const TransportSchema = z.enum(["stdio", "sse", "websocket", "http"]);
10
10
 
11
11
  export function loadEnv(hiveDir: string): void {
12
12
  const envPath = path.join(hiveDir, ".env");
@@ -559,6 +559,8 @@ function buildDefaultConfig(): Config {
559
559
  slack: 40000,
560
560
  webchat: 100000,
561
561
  whatsapp: 65536,
562
+ // Meta rechaza cualquier `text.body` de más de 4096 caracteres.
563
+ whatsapp_cloud: 4096,
562
564
  },
563
565
  skillScanning: true,
564
566
  warnOnInsecureConfig: true,
@@ -5,11 +5,13 @@
5
5
  * - POST /chat — chat with an agent
6
6
  * - GET /status — health check
7
7
  * - WebSocket /ws — real-time streaming
8
+ * - GET|POST /webhooks/whatsapp-cloud/:accountId — WhatsApp por la API oficial
8
9
  */
9
10
 
10
11
  import { logger } from "../utils/logger.ts";
11
12
  import { runAgent } from "../agent/agent-loop.ts";
12
13
  import type { MCPClientManager } from "../mcp/index.ts";
14
+ import type { ChannelManager } from "../channels/manager.ts";
13
15
 
14
16
  const log = logger.child("gateway");
15
17
 
@@ -18,6 +20,12 @@ export interface GatewayConfig {
18
20
  port?: number;
19
21
  agentId?: string;
20
22
  mcpManager?: MCPClientManager | null;
23
+ /**
24
+ * Hace falta para los canales que reciben por webhook, como WhatsApp por la
25
+ * API oficial de Meta. Sin él, el gateway no tiene a quién entregarle lo que
26
+ * Meta empuja.
27
+ */
28
+ channelManager?: ChannelManager | null;
21
29
  }
22
30
 
23
31
  export async function startGateway(config: GatewayConfig = {}) {
@@ -54,6 +62,22 @@ export async function startGateway(config: GatewayConfig = {}) {
54
62
  return handleChat(req, agentId, config.mcpManager);
55
63
  }
56
64
 
65
+ // WhatsApp por la API oficial: Meta empuja acá cada mensaje del número.
66
+ // El gateway escucha en 127.0.0.1 y Meta exige HTTPS público, así que
67
+ // esta ruta necesita un proxy inverso o un túnel por delante.
68
+ const whatsappCloud = url.pathname.match(/^\/webhooks\/whatsapp-cloud\/([^/]+)$/);
69
+ if (whatsappCloud) {
70
+ if (!config.channelManager) {
71
+ log.warn("llegó un webhook de WhatsApp pero el gateway no tiene ChannelManager");
72
+ return new Response("Channels not configured", { status: 503 });
73
+ }
74
+ return config.channelManager.handleWebhook(
75
+ "whatsapp_cloud",
76
+ decodeURIComponent(whatsappCloud[1]!),
77
+ req
78
+ );
79
+ }
80
+
57
81
  return new Response("Not Found", { status: 404 });
58
82
  },
59
83
  websocket: {
@@ -84,6 +84,23 @@ export { BaseChannel } from "./channels/base.ts";
84
84
  export { TelegramChannel } from "./channels/telegram.ts";
85
85
  export { DiscordChannel } from "./channels/discord.ts";
86
86
  export { WhatsAppChannel } from "./channels/whatsapp.ts";
87
+ export {
88
+ WhatsAppCloudChannel,
89
+ WhatsAppCloudClient,
90
+ WhatsAppCloudError,
91
+ splitWhatsAppText,
92
+ parseWebhook as parseWhatsAppWebhook,
93
+ verifySignature as verifyWhatsAppSignature,
94
+ verifyChallenge as verifyWhatsAppChallenge,
95
+ } from "./channels/whatsapp-cloud/index.ts";
96
+ export type {
97
+ WhatsAppCloudConfig,
98
+ WhatsAppTemplate,
99
+ WhatsAppInbound,
100
+ WhatsAppWebhookEvent,
101
+ WhatsAppStatusEvent,
102
+ WhatsAppReferral,
103
+ } from "./channels/whatsapp-cloud/index.ts";
87
104
  export { SlackChannel } from "./channels/slack.ts";
88
105
  export { WebChatChannel } from "./channels/webchat.ts";
89
106
 
@@ -183,6 +183,15 @@ export class MCPClientManager {
183
183
  });
184
184
  }
185
185
 
186
+ case "http": {
187
+ const url = state.config.url;
188
+ if (!url) throw new Error("Streamable HTTP transport requires 'url' config");
189
+ return createTransport({
190
+ type: "http",
191
+ http: { url, headers: state.config.headers },
192
+ });
193
+ }
194
+
186
195
  default:
187
196
  throw new Error(`Unknown transport type: ${transportType}`);
188
197
  }
@@ -3,7 +3,8 @@ export interface MCPConfig {
3
3
  }
4
4
 
5
5
  export interface MCPServerConfig {
6
- transport: "stdio" | "sse" | "websocket";
6
+ /** "http" es Streamable HTTP, el transporte remoto estándar de MCP. "sse" es el HTTP+SSE original, de dos endpoints. */
7
+ transport: "stdio" | "sse" | "websocket" | "http";
7
8
  enabled?: boolean;
8
9
  command?: string;
9
10
  args?: string[];
@@ -1,4 +1,5 @@
1
1
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2
3
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
4
 
4
5
  // CORRECCIÓN 1 — quitar extensión .ts de los imports
@@ -18,13 +19,37 @@ export interface StdioTransportConfig {
18
19
  env?: Record<string, string>;
19
20
  }
20
21
 
21
- export type TransportType = "stdio" | "sse" | "websocket";
22
+ /**
23
+ * Streamable HTTP: el transporte remoto ESTÁNDAR de MCP hoy.
24
+ *
25
+ * No es lo mismo que `sse`. Aquel es el transporte HTTP+SSE original, de dos
26
+ * endpoints (un GET que abre el stream y un POST aparte para enviar), que la
27
+ * especificación reemplazó por éste: un único endpoint que acepta POST y
28
+ * devuelve JSON o un stream SSE según haga falta.
29
+ *
30
+ * Sin este caso, un servidor MCP remoto moderno es inalcanzable: no expone el
31
+ * `/sse` de dos endpoints que espera `SSETransport` y la conexión muere en un
32
+ * 404. Se descubrió intentando conectar un navegador headless que sólo sirve
33
+ * Streamable HTTP, pero no es un caso particular de ese servidor: es lo que
34
+ * publica hoy cualquier MCP remoto.
35
+ */
36
+ export interface HttpTransportConfig {
37
+ /** URL del endpoint MCP, normalmente terminado en `/mcp`. */
38
+ url: string;
39
+ /** Cabeceras extra en cada petición — típicamente la autorización. */
40
+ headers?: Record<string, string>;
41
+ /** Sesión previa a reanudar, si el servidor emitió un id de sesión. */
42
+ sessionId?: string;
43
+ }
44
+
45
+ export type TransportType = "stdio" | "sse" | "websocket" | "http";
22
46
 
23
47
  export interface TransportOptions {
24
48
  type: TransportType;
25
49
  stdio?: StdioTransportConfig;
26
50
  sse?: SSETransportConfig;
27
51
  websocket?: WebSocketTransportConfig;
52
+ http?: HttpTransportConfig;
28
53
  }
29
54
 
30
55
  export function createTransport(options: TransportOptions): Transport {
@@ -58,6 +83,24 @@ export function createTransport(options: TransportOptions): Transport {
58
83
  return new WebSocketTransport(options.websocket);
59
84
  }
60
85
 
86
+ case "http": {
87
+ if (!options.http) {
88
+ throw new Error("http config required for Streamable HTTP transport");
89
+ }
90
+ // La implementación la pone el SDK oficial de MCP, que ya es dependencia
91
+ // nuestra: no hay nada que reescribir acá, sólo faltaba el caso.
92
+ let url: URL;
93
+ try {
94
+ url = new URL(options.http.url);
95
+ } catch {
96
+ throw new Error(`Invalid MCP endpoint URL: ${options.http.url}`);
97
+ }
98
+ return new StreamableHTTPClientTransport(url, {
99
+ requestInit: options.http.headers ? { headers: options.http.headers } : undefined,
100
+ sessionId: options.http.sessionId,
101
+ });
102
+ }
103
+
61
104
  default: {
62
105
  // exhaustive check — TypeScript avisa si falta un caso
63
106
  const _exhaustive: never = options.type;