@lobu/gateway 2.8.0 → 3.0.6

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.
Files changed (175) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/agent-config-routes.test.ts +254 -0
  3. package/src/__tests__/agent-history-routes.test.ts +72 -0
  4. package/src/__tests__/agent-routes.test.ts +68 -0
  5. package/src/__tests__/agent-schedules-routes.test.ts +59 -0
  6. package/src/__tests__/agent-settings-store.test.ts +323 -0
  7. package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
  8. package/src/__tests__/chat-response-bridge.test.ts +131 -0
  9. package/src/__tests__/config-memory-plugins.test.ts +92 -0
  10. package/src/__tests__/config-request-store.test.ts +127 -0
  11. package/src/__tests__/connection-routes.test.ts +144 -0
  12. package/src/__tests__/core-services-store-selection.test.ts +92 -0
  13. package/src/__tests__/docker-deployment.test.ts +1211 -0
  14. package/src/__tests__/embedded-deployment.test.ts +342 -0
  15. package/src/__tests__/grant-store.test.ts +148 -0
  16. package/src/__tests__/http-proxy.test.ts +281 -0
  17. package/src/__tests__/instruction-service.test.ts +37 -0
  18. package/src/__tests__/link-buttons.test.ts +112 -0
  19. package/src/__tests__/lobu.test.ts +32 -0
  20. package/src/__tests__/mcp-config-service.test.ts +347 -0
  21. package/src/__tests__/mcp-proxy.test.ts +696 -0
  22. package/src/__tests__/message-handler-bridge.test.ts +17 -0
  23. package/src/__tests__/model-selection.test.ts +172 -0
  24. package/src/__tests__/oauth-templates.test.ts +39 -0
  25. package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
  26. package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
  27. package/src/__tests__/provider-inheritance.test.ts +212 -0
  28. package/src/__tests__/routes/cli-auth.test.ts +337 -0
  29. package/src/__tests__/routes/interactions.test.ts +121 -0
  30. package/src/__tests__/secret-proxy.test.ts +85 -0
  31. package/src/__tests__/session-manager.test.ts +572 -0
  32. package/src/__tests__/setup.ts +133 -0
  33. package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
  34. package/src/__tests__/slack-routes.test.ts +161 -0
  35. package/src/__tests__/system-config-resolver.test.ts +75 -0
  36. package/src/__tests__/system-message-limiter.test.ts +89 -0
  37. package/src/__tests__/system-skills-service.test.ts +362 -0
  38. package/src/__tests__/transcription-service.test.ts +222 -0
  39. package/src/__tests__/utils/rate-limiter.test.ts +102 -0
  40. package/src/__tests__/worker-connection-manager.test.ts +497 -0
  41. package/src/__tests__/worker-job-router.test.ts +722 -0
  42. package/src/api/index.ts +1 -0
  43. package/src/api/platform.ts +292 -0
  44. package/src/api/response-renderer.ts +157 -0
  45. package/src/auth/agent-metadata-store.ts +168 -0
  46. package/src/auth/api-auth-middleware.ts +69 -0
  47. package/src/auth/api-key-provider-module.ts +213 -0
  48. package/src/auth/base-provider-module.ts +201 -0
  49. package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
  50. package/src/auth/chatgpt/device-code-client.ts +218 -0
  51. package/src/auth/chatgpt/index.ts +1 -0
  52. package/src/auth/claude/oauth-module.ts +280 -0
  53. package/src/auth/cli/token-service.ts +249 -0
  54. package/src/auth/external/client.ts +560 -0
  55. package/src/auth/external/device-code-client.ts +225 -0
  56. package/src/auth/mcp/config-service.ts +392 -0
  57. package/src/auth/mcp/proxy.ts +1088 -0
  58. package/src/auth/mcp/string-substitution.ts +17 -0
  59. package/src/auth/mcp/tool-cache.ts +90 -0
  60. package/src/auth/oauth/base-client.ts +267 -0
  61. package/src/auth/oauth/client.ts +153 -0
  62. package/src/auth/oauth/credentials.ts +7 -0
  63. package/src/auth/oauth/providers.ts +69 -0
  64. package/src/auth/oauth/state-store.ts +150 -0
  65. package/src/auth/oauth-templates.ts +179 -0
  66. package/src/auth/provider-catalog.ts +220 -0
  67. package/src/auth/provider-model-options.ts +41 -0
  68. package/src/auth/settings/agent-settings-store.ts +565 -0
  69. package/src/auth/settings/auth-profiles-manager.ts +216 -0
  70. package/src/auth/settings/index.ts +12 -0
  71. package/src/auth/settings/model-preference-store.ts +52 -0
  72. package/src/auth/settings/model-selection.ts +135 -0
  73. package/src/auth/settings/resolved-settings-view.ts +298 -0
  74. package/src/auth/settings/template-utils.ts +44 -0
  75. package/src/auth/settings/token-service.ts +88 -0
  76. package/src/auth/system-env-store.ts +98 -0
  77. package/src/auth/user-agents-store.ts +68 -0
  78. package/src/channels/binding-service.ts +214 -0
  79. package/src/channels/index.ts +4 -0
  80. package/src/cli/gateway.ts +1304 -0
  81. package/src/cli/index.ts +74 -0
  82. package/src/commands/built-in-commands.ts +80 -0
  83. package/src/commands/command-dispatcher.ts +94 -0
  84. package/src/commands/command-reply-adapters.ts +27 -0
  85. package/src/config/file-loader.ts +618 -0
  86. package/src/config/index.ts +588 -0
  87. package/src/config/network-allowlist.ts +71 -0
  88. package/src/connections/chat-instance-manager.ts +1284 -0
  89. package/src/connections/chat-response-bridge.ts +618 -0
  90. package/src/connections/index.ts +7 -0
  91. package/src/connections/interaction-bridge.ts +831 -0
  92. package/src/connections/message-handler-bridge.ts +415 -0
  93. package/src/connections/platform-auth-methods.ts +15 -0
  94. package/src/connections/types.ts +84 -0
  95. package/src/gateway/connection-manager.ts +291 -0
  96. package/src/gateway/index.ts +700 -0
  97. package/src/gateway/job-router.ts +201 -0
  98. package/src/gateway-main.ts +200 -0
  99. package/src/index.ts +41 -0
  100. package/src/infrastructure/queue/index.ts +12 -0
  101. package/src/infrastructure/queue/queue-producer.ts +148 -0
  102. package/src/infrastructure/queue/redis-queue.ts +361 -0
  103. package/src/infrastructure/queue/types.ts +133 -0
  104. package/src/infrastructure/redis/system-message-limiter.ts +94 -0
  105. package/src/interactions/config-request-store.ts +198 -0
  106. package/src/interactions.ts +363 -0
  107. package/src/lobu.ts +311 -0
  108. package/src/metrics/prometheus.ts +159 -0
  109. package/src/modules/module-system.ts +179 -0
  110. package/src/orchestration/base-deployment-manager.ts +900 -0
  111. package/src/orchestration/deployment-utils.ts +98 -0
  112. package/src/orchestration/impl/docker-deployment.ts +620 -0
  113. package/src/orchestration/impl/embedded-deployment.ts +268 -0
  114. package/src/orchestration/impl/index.ts +8 -0
  115. package/src/orchestration/impl/k8s/deployment.ts +1061 -0
  116. package/src/orchestration/impl/k8s/helpers.ts +610 -0
  117. package/src/orchestration/impl/k8s/index.ts +1 -0
  118. package/src/orchestration/index.ts +333 -0
  119. package/src/orchestration/message-consumer.ts +584 -0
  120. package/src/orchestration/scheduled-wakeup.ts +704 -0
  121. package/src/permissions/approval-policy.ts +36 -0
  122. package/src/permissions/grant-store.ts +219 -0
  123. package/src/platform/file-handler.ts +66 -0
  124. package/src/platform/link-buttons.ts +57 -0
  125. package/src/platform/renderer-utils.ts +44 -0
  126. package/src/platform/response-renderer.ts +84 -0
  127. package/src/platform/unified-thread-consumer.ts +187 -0
  128. package/src/platform.ts +318 -0
  129. package/src/proxy/http-proxy.ts +752 -0
  130. package/src/proxy/proxy-manager.ts +81 -0
  131. package/src/proxy/secret-proxy.ts +402 -0
  132. package/src/proxy/token-refresh-job.ts +143 -0
  133. package/src/routes/internal/audio.ts +141 -0
  134. package/src/routes/internal/device-auth.ts +566 -0
  135. package/src/routes/internal/files.ts +226 -0
  136. package/src/routes/internal/history.ts +69 -0
  137. package/src/routes/internal/images.ts +127 -0
  138. package/src/routes/internal/interactions.ts +84 -0
  139. package/src/routes/internal/middleware.ts +23 -0
  140. package/src/routes/internal/schedule.ts +226 -0
  141. package/src/routes/internal/types.ts +22 -0
  142. package/src/routes/openapi-auto.ts +239 -0
  143. package/src/routes/public/agent-access.ts +23 -0
  144. package/src/routes/public/agent-config.ts +675 -0
  145. package/src/routes/public/agent-history.ts +422 -0
  146. package/src/routes/public/agent-schedules.ts +296 -0
  147. package/src/routes/public/agent.ts +1086 -0
  148. package/src/routes/public/agents.ts +373 -0
  149. package/src/routes/public/channels.ts +191 -0
  150. package/src/routes/public/cli-auth.ts +883 -0
  151. package/src/routes/public/connections.ts +574 -0
  152. package/src/routes/public/landing.ts +16 -0
  153. package/src/routes/public/oauth.ts +147 -0
  154. package/src/routes/public/settings-auth.ts +104 -0
  155. package/src/routes/public/slack.ts +173 -0
  156. package/src/routes/shared/agent-ownership.ts +101 -0
  157. package/src/routes/shared/token-verifier.ts +34 -0
  158. package/src/services/core-services.ts +1053 -0
  159. package/src/services/image-generation-service.ts +257 -0
  160. package/src/services/instruction-service.ts +318 -0
  161. package/src/services/mcp-registry.ts +94 -0
  162. package/src/services/platform-helpers.ts +287 -0
  163. package/src/services/session-manager.ts +262 -0
  164. package/src/services/settings-resolver.ts +74 -0
  165. package/src/services/system-config-resolver.ts +90 -0
  166. package/src/services/system-skills-service.ts +229 -0
  167. package/src/services/transcription-service.ts +684 -0
  168. package/src/session.ts +110 -0
  169. package/src/spaces/index.ts +1 -0
  170. package/src/spaces/space-resolver.ts +17 -0
  171. package/src/stores/in-memory-agent-store.ts +403 -0
  172. package/src/stores/redis-agent-store.ts +279 -0
  173. package/src/utils/public-url.ts +44 -0
  174. package/src/utils/rate-limiter.ts +94 -0
  175. package/tsconfig.json +33 -0
@@ -0,0 +1,1088 @@
1
+ import dns from "node:dns/promises";
2
+ import { createLogger, verifyWorkerToken } from "@lobu/core";
3
+ import type { Context } from "hono";
4
+ import { Hono } from "hono";
5
+ import type { IMessageQueue } from "../../infrastructure/queue";
6
+ import { requiresToolApproval } from "../../permissions/approval-policy";
7
+ import type { GrantStore } from "../../permissions/grant-store";
8
+ import {
9
+ getStoredCredential,
10
+ refreshCredential,
11
+ tryCompletePendingDeviceAuth,
12
+ } from "../../routes/internal/device-auth";
13
+ import type { CachedMcpServer, McpTool, McpToolCache } from "./tool-cache";
14
+
15
+ const logger = createLogger("mcp-proxy");
16
+
17
+ const MAX_BODY_SIZE = 1024 * 1024; // 1MB
18
+
19
+ /**
20
+ * Check whether a resolved IP address belongs to a reserved/internal range.
21
+ */
22
+ function isReservedIp(ip: string): boolean {
23
+ // IPv6 loopback
24
+ if (ip === "::1") return true;
25
+
26
+ // IPv6 unique local (fc00::/7)
27
+ if (/^f[cd]/i.test(ip)) return true;
28
+
29
+ // IPv4
30
+ const parts = ip.split(".").map(Number);
31
+ if (parts.length === 4) {
32
+ const [a, b] = parts as [number, number, number, number];
33
+ // 127.0.0.0/8
34
+ if (a === 127) return true;
35
+ // 10.0.0.0/8
36
+ if (a === 10) return true;
37
+ // 172.16.0.0/12
38
+ if (a === 172 && b >= 16 && b <= 31) return true;
39
+ // 192.168.0.0/16
40
+ if (a === 192 && b === 168) return true;
41
+ // 169.254.0.0/16 (link-local)
42
+ if (a === 169 && b === 254) return true;
43
+ }
44
+
45
+ return false;
46
+ }
47
+
48
+ /**
49
+ * Resolve a URL's hostname and check whether it points to an internal/reserved network.
50
+ */
51
+ async function isInternalUrl(url: string): Promise<boolean> {
52
+ try {
53
+ const parsed = new URL(url);
54
+ const hostname = parsed.hostname;
55
+
56
+ // Check if hostname is already an IP literal
57
+ if (isReservedIp(hostname)) return true;
58
+
59
+ // Resolve hostname to IP addresses
60
+ const addresses = await dns.resolve4(hostname).catch(() => [] as string[]);
61
+ const addresses6 = await dns.resolve6(hostname).catch(() => [] as string[]);
62
+
63
+ for (const addr of [...addresses, ...addresses6]) {
64
+ if (isReservedIp(addr)) return true;
65
+ }
66
+
67
+ return false;
68
+ } catch {
69
+ // If URL parsing fails, block it
70
+ return true;
71
+ }
72
+ }
73
+
74
+ interface JsonRpcResponse {
75
+ jsonrpc: string;
76
+ id: unknown;
77
+ result?: {
78
+ tools?: McpTool[];
79
+ content?: unknown[];
80
+ isError?: boolean;
81
+ };
82
+ error?: { code: number; message: string };
83
+ }
84
+
85
+ interface HttpMcpServerConfig {
86
+ id: string;
87
+ upstreamUrl: string;
88
+ oauth?: unknown;
89
+ inputs?: unknown[];
90
+ headers?: Record<string, string>;
91
+ loginUrl?: string;
92
+ resource?: string;
93
+ }
94
+
95
+ interface McpConfigSource {
96
+ getHttpServer(
97
+ id: string,
98
+ agentId?: string
99
+ ): Promise<HttpMcpServerConfig | undefined>;
100
+ getAllHttpServers(
101
+ agentId?: string
102
+ ): Promise<Map<string, HttpMcpServerConfig>>;
103
+ }
104
+
105
+ function authenticateRequest(
106
+ c: Context
107
+ ): { tokenData: any; token: string } | null {
108
+ const sessionToken = extractSessionToken(c);
109
+ if (!sessionToken) return null;
110
+
111
+ const tokenData = verifyWorkerToken(sessionToken);
112
+ if (!tokenData) return null;
113
+
114
+ return { tokenData, token: sessionToken };
115
+ }
116
+
117
+ function extractSessionToken(c: Context): string | null {
118
+ const authHeader = c.req.header("authorization");
119
+ if (authHeader?.startsWith("Bearer ")) {
120
+ return authHeader.substring(7);
121
+ }
122
+
123
+ const tokenFromQuery = c.req.query("workerToken");
124
+ if (typeof tokenFromQuery === "string") {
125
+ return tokenFromQuery;
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ export class McpProxy {
132
+ private readonly SESSION_TTL_SECONDS = 30 * 60; // 30 minutes
133
+ private readonly redisClient: any;
134
+ private app: Hono;
135
+ private toolCache?: McpToolCache;
136
+
137
+ constructor(
138
+ private readonly configService: McpConfigSource,
139
+ queue: IMessageQueue,
140
+ toolCache?: McpToolCache,
141
+ private readonly grantStore?: GrantStore
142
+ ) {
143
+ this.redisClient = queue.getRedisClient();
144
+ this.toolCache = toolCache;
145
+ this.app = new Hono();
146
+ this.setupRoutes();
147
+ logger.debug("MCP proxy initialized");
148
+ }
149
+
150
+ getApp(): Hono {
151
+ return this.app;
152
+ }
153
+
154
+ /**
155
+ * Check if this request is an MCP proxy request (has X-Mcp-Id header)
156
+ * Used by gateway to determine if root path requests should be handled by MCP proxy
157
+ */
158
+ isMcpRequest(c: Context): boolean {
159
+ return !!c.req.header("x-mcp-id");
160
+ }
161
+
162
+ /**
163
+ * Fetch tools and instructions for a specific MCP server.
164
+ * Performs MCP initialize handshake first to capture server instructions,
165
+ * then fetches tool list.
166
+ */
167
+ async fetchToolsForMcp(
168
+ mcpId: string,
169
+ agentId: string,
170
+ tokenData: any
171
+ ): Promise<{ tools: McpTool[]; instructions?: string }> {
172
+ if (this.toolCache) {
173
+ const cached = await this.toolCache.getServerInfo(mcpId, agentId);
174
+ if (cached) return cached;
175
+ }
176
+
177
+ const httpServer = await this.configService.getHttpServer(mcpId, agentId);
178
+ if (!httpServer) {
179
+ return { tools: [] };
180
+ }
181
+
182
+ const userId = tokenData?.userId;
183
+
184
+ try {
185
+ // Clear any stale session before fresh tool discovery
186
+ const sessionKey = `mcp:session:${agentId}:${mcpId}`;
187
+ await this.redisClient.del(sessionKey).catch(() => {
188
+ /* noop */
189
+ });
190
+
191
+ // Step 1: Send initialize to capture server instructions
192
+ let instructions: string | undefined;
193
+ try {
194
+ const initBody = JSON.stringify({
195
+ jsonrpc: "2.0",
196
+ method: "initialize",
197
+ params: {
198
+ protocolVersion: "2024-11-05",
199
+ capabilities: {},
200
+ clientInfo: { name: "lobu-gateway", version: "1.0.0" },
201
+ },
202
+ id: 0,
203
+ });
204
+
205
+ const initResponse = await this.sendUpstreamRequest(
206
+ httpServer,
207
+ agentId,
208
+ mcpId,
209
+ "POST",
210
+ initBody,
211
+ userId
212
+ );
213
+
214
+ const initData = (await initResponse.json()) as {
215
+ result?: { instructions?: string };
216
+ error?: { code: number; message: string };
217
+ };
218
+
219
+ if (initData?.result?.instructions) {
220
+ instructions = initData.result.instructions;
221
+ logger.info("Captured MCP server instructions", {
222
+ mcpId,
223
+ length: instructions.length,
224
+ });
225
+ }
226
+
227
+ // Step 2: Send initialized notification (required by MCP spec)
228
+ const notifyBody = JSON.stringify({
229
+ jsonrpc: "2.0",
230
+ method: "notifications/initialized",
231
+ });
232
+ await this.sendUpstreamRequest(
233
+ httpServer,
234
+ agentId,
235
+ mcpId,
236
+ "POST",
237
+ notifyBody,
238
+ userId
239
+ ).catch(() => {
240
+ /* noop */
241
+ });
242
+ } catch (initError) {
243
+ logger.warn("MCP initialize failed (continuing with tools/list)", {
244
+ mcpId,
245
+ error:
246
+ initError instanceof Error ? initError.message : String(initError),
247
+ });
248
+ }
249
+
250
+ // Step 3: Fetch tools list
251
+ const jsonRpcBody = JSON.stringify({
252
+ jsonrpc: "2.0",
253
+ method: "tools/list",
254
+ params: {},
255
+ id: 1,
256
+ });
257
+
258
+ const response = await this.sendUpstreamRequest(
259
+ httpServer,
260
+ agentId,
261
+ mcpId,
262
+ "POST",
263
+ jsonRpcBody,
264
+ userId
265
+ );
266
+
267
+ const data = (await response.json()) as JsonRpcResponse;
268
+ const tools: McpTool[] = data?.result?.tools || [];
269
+
270
+ const serverInfo: CachedMcpServer = { tools, instructions };
271
+ if (this.toolCache && tools.length > 0) {
272
+ await this.toolCache.setServerInfo(mcpId, serverInfo, agentId);
273
+ }
274
+
275
+ return serverInfo;
276
+ } catch (error) {
277
+ logger.warn("Failed to fetch tools for MCP, retrying once", {
278
+ mcpId,
279
+ error: error instanceof Error ? error.message : String(error),
280
+ });
281
+
282
+ // Retry once after a short delay (upstream may still be starting)
283
+ await new Promise((r) => setTimeout(r, 2000));
284
+ try {
285
+ const retryBody = JSON.stringify({
286
+ jsonrpc: "2.0",
287
+ method: "tools/list",
288
+ params: {},
289
+ id: 1,
290
+ });
291
+ const retryResponse = await this.sendUpstreamRequest(
292
+ httpServer,
293
+ agentId,
294
+ mcpId,
295
+ "POST",
296
+ retryBody,
297
+ userId
298
+ );
299
+ const retryData = (await retryResponse.json()) as JsonRpcResponse;
300
+ const retryTools: McpTool[] = retryData?.result?.tools || [];
301
+ if (retryTools.length > 0) {
302
+ const serverInfo: CachedMcpServer = { tools: retryTools };
303
+ if (this.toolCache) {
304
+ await this.toolCache.setServerInfo(mcpId, serverInfo, agentId);
305
+ }
306
+ logger.info("Retry succeeded for MCP tool fetch", {
307
+ mcpId,
308
+ toolCount: retryTools.length,
309
+ });
310
+ return serverInfo;
311
+ }
312
+ } catch (retryError) {
313
+ logger.error("Retry also failed for MCP tool fetch", {
314
+ mcpId,
315
+ error:
316
+ retryError instanceof Error
317
+ ? retryError.message
318
+ : String(retryError),
319
+ });
320
+ }
321
+ return { tools: [] };
322
+ }
323
+ }
324
+
325
+ private setupRoutes() {
326
+ // REST API endpoints for curl-based tool access (registered BEFORE catch-all)
327
+ this.app.get("/tools", (c) => this.handleListAllTools(c));
328
+ this.app.get("/:mcpId/tools", (c) => this.handleListTools(c));
329
+ this.app.post("/:mcpId/tools/:toolName", (c) => this.handleCallTool(c));
330
+
331
+ // Legacy endpoints (if needed for other MCP transports)
332
+ this.app.all("/register", (c) => this.handleProxyRequest(c));
333
+ this.app.all("/message", (c) => this.handleProxyRequest(c));
334
+
335
+ // Path-based routes (for SSE or other transports)
336
+ this.app.all("/:mcpId", (c) => this.handleProxyRequest(c));
337
+ this.app.all("/:mcpId/*", (c) => this.handleProxyRequest(c));
338
+ }
339
+
340
+ private async handleListTools(c: Context): Promise<Response> {
341
+ const mcpId = c.req.param("mcpId");
342
+ if (!mcpId) return c.json({ error: "Missing MCP server id" }, 400);
343
+ const auth = authenticateRequest(c);
344
+ if (!auth) return c.json({ error: "Invalid authentication token" }, 401);
345
+
346
+ const agentId = auth.tokenData.agentId || auth.tokenData.userId;
347
+ const requesterUserId = auth.tokenData.userId;
348
+ if (!agentId || !requesterUserId) {
349
+ return c.json({ error: "Invalid authentication token" }, 401);
350
+ }
351
+ const httpServer = await this.configService.getHttpServer(mcpId, agentId);
352
+ if (!httpServer) {
353
+ return c.json({ error: `MCP server '${mcpId}' not found` }, 404);
354
+ }
355
+
356
+ // Check cache
357
+ if (this.toolCache) {
358
+ const cached = await this.toolCache.get(mcpId, agentId);
359
+ if (cached) return c.json({ tools: cached });
360
+ }
361
+
362
+ try {
363
+ const jsonRpcBody = JSON.stringify({
364
+ jsonrpc: "2.0",
365
+ method: "tools/list",
366
+ params: {},
367
+ id: 1,
368
+ });
369
+
370
+ const response = await this.sendUpstreamRequest(
371
+ httpServer,
372
+ agentId,
373
+ mcpId,
374
+ "POST",
375
+ jsonRpcBody,
376
+ requesterUserId
377
+ );
378
+
379
+ const data = (await response.json()) as JsonRpcResponse;
380
+ if (data?.error) {
381
+ logger.error("Upstream returned JSON-RPC error", {
382
+ mcpId,
383
+ error: data.error,
384
+ });
385
+ return c.json({ error: data.error.message || "Upstream error" }, 502);
386
+ }
387
+
388
+ const tools: McpTool[] = data?.result?.tools || [];
389
+
390
+ // Cache result
391
+ if (this.toolCache && tools.length > 0) {
392
+ await this.toolCache.set(mcpId, tools, agentId);
393
+ }
394
+
395
+ return c.json({ tools });
396
+ } catch (error) {
397
+ logger.error("Failed to list tools", { mcpId, error });
398
+ return c.json(
399
+ {
400
+ error: `Failed to connect to MCP '${mcpId}': ${error instanceof Error ? error.message : "Unknown error"}`,
401
+ },
402
+ 502
403
+ );
404
+ }
405
+ }
406
+
407
+ private async handleCallTool(c: Context): Promise<Response> {
408
+ const mcpId = c.req.param("mcpId");
409
+ const toolName = c.req.param("toolName");
410
+ if (!mcpId || !toolName) {
411
+ return c.json({ error: "Missing MCP server id or tool name" }, 400);
412
+ }
413
+ const auth = authenticateRequest(c);
414
+ if (!auth) return c.json({ error: "Invalid authentication token" }, 401);
415
+
416
+ const agentId = auth.tokenData.agentId || auth.tokenData.userId;
417
+ const requesterUserId = auth.tokenData.userId;
418
+ if (!agentId || !requesterUserId) {
419
+ return c.json({ error: "Invalid authentication token" }, 401);
420
+ }
421
+ const httpServer = await this.configService.getHttpServer(mcpId, agentId);
422
+ if (!httpServer) {
423
+ return c.json({ error: `MCP server '${mcpId}' not found` }, 404);
424
+ }
425
+
426
+ // Check tool approval based on annotations and grants.
427
+ if (this.grantStore) {
428
+ const { found, annotations } = await this.getToolAnnotations(
429
+ mcpId,
430
+ toolName,
431
+ agentId,
432
+ auth.tokenData
433
+ );
434
+ if (found && requiresToolApproval(annotations)) {
435
+ const pattern = `/mcp/${mcpId}/tools/${toolName}`;
436
+ const hasGrant = await this.grantStore.hasGrant(agentId, pattern);
437
+ if (!hasGrant) {
438
+ logger.info("Tool call blocked: requires approval", {
439
+ agentId,
440
+ mcpId,
441
+ toolName,
442
+ pattern,
443
+ });
444
+ return c.json(
445
+ {
446
+ content: [
447
+ {
448
+ type: "text",
449
+ text: `Tool call requires approval. Request access approval in chat for: ${mcpId} → ${toolName}`,
450
+ },
451
+ ],
452
+ isError: true,
453
+ },
454
+ 403
455
+ );
456
+ }
457
+ }
458
+ }
459
+
460
+ let toolArguments: Record<string, unknown> = {};
461
+ try {
462
+ const body = await c.req.text();
463
+ if (body) {
464
+ if (body.length > MAX_BODY_SIZE) {
465
+ return c.json({ error: "Request body too large" }, 413);
466
+ }
467
+ toolArguments = JSON.parse(body);
468
+ }
469
+ } catch {
470
+ return c.json({ error: "Invalid JSON body" }, 400);
471
+ }
472
+
473
+ try {
474
+ const jsonRpcBody = JSON.stringify({
475
+ jsonrpc: "2.0",
476
+ method: "tools/call",
477
+ params: { name: toolName, arguments: toolArguments },
478
+ id: 1,
479
+ });
480
+
481
+ let response = await this.sendUpstreamRequest(
482
+ httpServer,
483
+ agentId,
484
+ mcpId,
485
+ "POST",
486
+ jsonRpcBody,
487
+ requesterUserId
488
+ );
489
+
490
+ let data = (await response.json()) as JsonRpcResponse;
491
+
492
+ // Re-initialize session and retry on "Server not initialized"
493
+ if (data?.error && /not initialized/i.test(data.error.message || "")) {
494
+ logger.info("MCP session expired, re-initializing before retry", {
495
+ mcpId,
496
+ toolName,
497
+ });
498
+ await this.reinitializeSession(
499
+ httpServer,
500
+ agentId,
501
+ mcpId,
502
+ requesterUserId
503
+ );
504
+
505
+ response = await this.sendUpstreamRequest(
506
+ httpServer,
507
+ agentId,
508
+ mcpId,
509
+ "POST",
510
+ jsonRpcBody,
511
+ requesterUserId
512
+ );
513
+ data = (await response.json()) as JsonRpcResponse;
514
+ }
515
+
516
+ if (data?.error) {
517
+ const errorMsg =
518
+ data.error.message ||
519
+ (typeof data.error === "string" ? data.error : "Upstream error");
520
+ logger.error("Upstream returned JSON-RPC error on tool call", {
521
+ mcpId,
522
+ toolName,
523
+ error: data.error,
524
+ });
525
+
526
+ // Detect auth errors — auto-start device-code auth flow
527
+ if (/unauthorized|unauthenticated|forbidden/i.test(errorMsg)) {
528
+ const autoAuthResult = await this.tryAutoDeviceAuth(
529
+ mcpId,
530
+ agentId,
531
+ requesterUserId
532
+ );
533
+ if (autoAuthResult) {
534
+ return c.json(
535
+ {
536
+ content: [
537
+ {
538
+ type: "text",
539
+ text: autoAuthResult,
540
+ },
541
+ ],
542
+ isError: true,
543
+ },
544
+ 200
545
+ );
546
+ }
547
+ return c.json(
548
+ {
549
+ content: [
550
+ {
551
+ type: "text",
552
+ text: `Authentication required for ${mcpId}. Call owletto_login to authenticate.`,
553
+ },
554
+ ],
555
+ isError: true,
556
+ },
557
+ 200
558
+ );
559
+ }
560
+
561
+ return c.json(
562
+ {
563
+ content: [],
564
+ isError: true,
565
+ error: errorMsg,
566
+ },
567
+ 502
568
+ );
569
+ }
570
+
571
+ const result = data?.result || {};
572
+ return c.json({
573
+ content: result.content || [],
574
+ isError: result.isError || false,
575
+ });
576
+ } catch (error) {
577
+ logger.error("Failed to call tool", { mcpId, toolName, error });
578
+ return c.json(
579
+ {
580
+ content: [],
581
+ isError: true,
582
+ error: `Failed to connect to MCP '${mcpId}': ${error instanceof Error ? error.message : "Unknown error"}`,
583
+ },
584
+ 502
585
+ );
586
+ }
587
+ }
588
+
589
+ private async handleListAllTools(c: Context): Promise<Response> {
590
+ const auth = authenticateRequest(c);
591
+ if (!auth) return c.json({ error: "Invalid authentication token" }, 401);
592
+
593
+ const agentId = auth.tokenData.agentId || auth.tokenData.userId;
594
+
595
+ const allHttpServers = await this.configService.getAllHttpServers(agentId);
596
+ const allMcpIds = Array.from(allHttpServers.keys());
597
+
598
+ const mcpServers: Record<string, { tools: McpTool[] }> = {};
599
+
600
+ // Fetch tools in parallel, tolerate failures
601
+ const results = await Promise.allSettled(
602
+ allMcpIds.map(async (mcpId) => {
603
+ const { tools } = await this.fetchToolsForMcp(
604
+ mcpId,
605
+ agentId,
606
+ auth.tokenData
607
+ );
608
+ return { mcpId, tools };
609
+ })
610
+ );
611
+
612
+ for (const result of results) {
613
+ if (result.status === "fulfilled" && result.value.tools.length > 0) {
614
+ mcpServers[result.value.mcpId] = { tools: result.value.tools };
615
+ }
616
+ }
617
+
618
+ return c.json({ mcpServers });
619
+ }
620
+
621
+ private async handleProxyRequest(c: Context): Promise<Response> {
622
+ const mcpId = c.req.param("mcpId") || c.req.header("x-mcp-id");
623
+ const sessionToken = extractSessionToken(c);
624
+
625
+ logger.info("Handling MCP proxy request", {
626
+ method: c.req.method,
627
+ path: c.req.path,
628
+ mcpId,
629
+ hasSessionToken: !!sessionToken,
630
+ });
631
+
632
+ if (!mcpId) {
633
+ return this.sendJsonRpcError(c, -32600, "Missing MCP ID");
634
+ }
635
+
636
+ if (!sessionToken) {
637
+ return this.sendJsonRpcError(c, -32600, "Missing authentication token");
638
+ }
639
+
640
+ const tokenData = verifyWorkerToken(sessionToken);
641
+ if (!tokenData) {
642
+ return this.sendJsonRpcError(c, -32600, "Invalid authentication token");
643
+ }
644
+
645
+ const agentId = tokenData.agentId || tokenData.userId;
646
+ const httpServer = await this.configService.getHttpServer(mcpId!, agentId);
647
+
648
+ if (!httpServer) {
649
+ return this.sendJsonRpcError(
650
+ c,
651
+ -32601,
652
+ `MCP server '${mcpId}' not found`
653
+ );
654
+ }
655
+
656
+ try {
657
+ return await this.forwardRequest(
658
+ c,
659
+ httpServer,
660
+ agentId,
661
+ mcpId!,
662
+ tokenData.userId
663
+ );
664
+ } catch (error) {
665
+ logger.error("Failed to proxy MCP request", { error, mcpId });
666
+ return this.sendJsonRpcError(
667
+ c,
668
+ -32603,
669
+ `Failed to connect to MCP '${mcpId}': ${error instanceof Error ? error.message : "Unknown error"}`
670
+ );
671
+ }
672
+ }
673
+
674
+ private async getToolAnnotations(
675
+ mcpId: string,
676
+ toolName: string,
677
+ agentId: string,
678
+ tokenData: any
679
+ ): Promise<{ found: boolean; annotations?: McpTool["annotations"] }> {
680
+ let tools: McpTool[] | null = null;
681
+ if (this.toolCache) {
682
+ tools = await this.toolCache.get(mcpId, agentId);
683
+ }
684
+
685
+ if (!tools) {
686
+ const result = await this.fetchToolsForMcp(mcpId, agentId, tokenData);
687
+ tools = result.tools;
688
+ }
689
+
690
+ if (tools.length === 0) {
691
+ return { found: false };
692
+ }
693
+
694
+ const tool = tools.find((t) => t.name === toolName);
695
+ return { found: true, annotations: tool?.annotations };
696
+ }
697
+
698
+ private buildUpstreamHeaders(
699
+ sessionId: string | null,
700
+ configHeaders?: Record<string, string>,
701
+ credentialToken?: string
702
+ ): Record<string, string> {
703
+ const headers: Record<string, string> = {
704
+ "Content-Type": "application/json",
705
+ Accept: "application/json",
706
+ };
707
+
708
+ // Merge custom headers from server config (e.g. static auth tokens)
709
+ if (configHeaders) {
710
+ for (const [key, value] of Object.entries(configHeaders)) {
711
+ headers[key] = value;
712
+ }
713
+ }
714
+
715
+ // Per-user credential takes precedence over config headers for Authorization
716
+ if (credentialToken) {
717
+ headers.Authorization = `Bearer ${credentialToken}`;
718
+ }
719
+
720
+ if (sessionId) {
721
+ headers["Mcp-Session-Id"] = sessionId;
722
+ }
723
+
724
+ return headers;
725
+ }
726
+
727
+ private async resolveCredentialToken(
728
+ agentId: string,
729
+ userId: string,
730
+ mcpId: string
731
+ ): Promise<string | null> {
732
+ const credential = await getStoredCredential(
733
+ this.redisClient,
734
+ agentId,
735
+ userId,
736
+ mcpId
737
+ );
738
+ if (!credential) {
739
+ // No stored credential — check if there's a pending device-auth to complete
740
+ return tryCompletePendingDeviceAuth(
741
+ this.redisClient,
742
+ agentId,
743
+ userId,
744
+ mcpId
745
+ );
746
+ }
747
+
748
+ // Check if token is still valid (5 minute buffer)
749
+ if (credential.expiresAt > Date.now() + 5 * 60 * 1000) {
750
+ return credential.accessToken;
751
+ }
752
+
753
+ // Token expired or expiring soon — refresh
754
+ const refreshed = await refreshCredential(
755
+ this.redisClient,
756
+ agentId,
757
+ userId,
758
+ mcpId,
759
+ credential
760
+ );
761
+ return refreshed?.accessToken ?? null;
762
+ }
763
+
764
+ private async sendUpstreamRequest(
765
+ httpServer: HttpMcpServerConfig,
766
+ agentId: string,
767
+ mcpId: string,
768
+ method: string,
769
+ body?: string,
770
+ userId?: string
771
+ ): Promise<Response> {
772
+ const sessionKey = `mcp:session:${agentId}:${mcpId}`;
773
+ const sessionId = await this.getSession(sessionKey);
774
+
775
+ // Look up per-user credential for this MCP
776
+ let credentialToken: string | undefined;
777
+ if (userId) {
778
+ const token = await this.resolveCredentialToken(agentId, userId, mcpId);
779
+ if (token) credentialToken = token;
780
+ }
781
+
782
+ // SSRF protection: block requests to internal networks
783
+ if (await isInternalUrl(httpServer.upstreamUrl)) {
784
+ logger.warn("Blocked SSRF attempt to internal URL", {
785
+ url: httpServer.upstreamUrl,
786
+ mcpId,
787
+ agentId,
788
+ });
789
+ return new Response(
790
+ JSON.stringify({
791
+ jsonrpc: "2.0",
792
+ id: null,
793
+ error: {
794
+ code: -32600,
795
+ message: "Upstream URL resolves to a blocked internal network",
796
+ },
797
+ }),
798
+ { status: 403, headers: { "Content-Type": "application/json" } }
799
+ );
800
+ }
801
+
802
+ const headers = this.buildUpstreamHeaders(
803
+ sessionId,
804
+ httpServer.headers,
805
+ credentialToken
806
+ );
807
+
808
+ const response = await fetch(httpServer.upstreamUrl, {
809
+ method,
810
+ headers,
811
+ body: body || undefined,
812
+ });
813
+
814
+ // Track session
815
+ const newSessionId = response.headers.get("Mcp-Session-Id");
816
+ if (newSessionId) {
817
+ await this.setSession(sessionKey, newSessionId);
818
+ }
819
+
820
+ return response;
821
+ }
822
+
823
+ private async forwardRequest(
824
+ c: Context,
825
+ httpServer: HttpMcpServerConfig,
826
+ agentId: string,
827
+ mcpId: string,
828
+ userId?: string
829
+ ): Promise<Response> {
830
+ // SSRF protection: block requests to internal networks
831
+ if (await isInternalUrl(httpServer.upstreamUrl)) {
832
+ logger.warn("Blocked SSRF attempt to internal URL", {
833
+ url: httpServer.upstreamUrl,
834
+ mcpId,
835
+ agentId,
836
+ });
837
+ return this.sendJsonRpcError(
838
+ c,
839
+ -32600,
840
+ "Upstream URL resolves to a blocked internal network"
841
+ );
842
+ }
843
+
844
+ const sessionKey = `mcp:session:${agentId}:${mcpId}`;
845
+ let sessionId = await this.getSession(sessionKey);
846
+
847
+ const bodyText = await this.getRequestBodyAsText(c);
848
+
849
+ // Body size validation
850
+ if (bodyText.length > MAX_BODY_SIZE) {
851
+ logger.warn("Request body too large", {
852
+ mcpId,
853
+ agentId,
854
+ size: bodyText.length,
855
+ });
856
+ return new Response("Request body too large", { status: 413 });
857
+ }
858
+
859
+ // If no active session exists, re-initialize before forwarding
860
+ if (!sessionId && c.req.method === "POST") {
861
+ try {
862
+ await this.reinitializeSession(httpServer, agentId, mcpId, userId);
863
+ sessionId = await this.getSession(sessionKey);
864
+ } catch (error) {
865
+ logger.warn("Pre-emptive MCP re-initialization failed", {
866
+ mcpId,
867
+ error: error instanceof Error ? error.message : String(error),
868
+ });
869
+ }
870
+ }
871
+
872
+ logger.info("Proxying MCP request", {
873
+ mcpId,
874
+ agentId,
875
+ method: c.req.method,
876
+ hasSession: !!sessionId,
877
+ bodyLength: bodyText.length,
878
+ });
879
+
880
+ // Look up per-user credential for this MCP
881
+ let credentialToken: string | undefined;
882
+ if (userId) {
883
+ const token = await this.resolveCredentialToken(agentId, userId, mcpId);
884
+ if (token) credentialToken = token;
885
+ }
886
+
887
+ const headers = this.buildUpstreamHeaders(
888
+ sessionId,
889
+ httpServer.headers,
890
+ credentialToken
891
+ );
892
+
893
+ const response = await fetch(httpServer.upstreamUrl, {
894
+ method: c.req.method,
895
+ headers,
896
+ body: bodyText || undefined,
897
+ });
898
+
899
+ const newSessionId = response.headers.get("Mcp-Session-Id");
900
+ if (newSessionId) {
901
+ await this.setSession(sessionKey, newSessionId);
902
+ logger.debug("Stored MCP session ID", {
903
+ mcpId,
904
+ agentId,
905
+ sessionId: newSessionId,
906
+ });
907
+ }
908
+
909
+ const responseHeaders = new Headers();
910
+ const contentType = response.headers.get("content-type");
911
+ if (contentType) {
912
+ responseHeaders.set("Content-Type", contentType);
913
+ }
914
+ if (newSessionId) {
915
+ responseHeaders.set("Mcp-Session-Id", newSessionId);
916
+ }
917
+
918
+ return new Response(response.body, {
919
+ status: response.status,
920
+ headers: responseHeaders,
921
+ });
922
+ }
923
+
924
+ private async getRequestBodyAsText(c: Context): Promise<string> {
925
+ if (c.req.method === "GET" || c.req.method === "HEAD") {
926
+ return "";
927
+ }
928
+
929
+ try {
930
+ return await c.req.text();
931
+ } catch {
932
+ return "";
933
+ }
934
+ }
935
+
936
+ /**
937
+ * Re-initialize an MCP session by sending initialize + notifications/initialized.
938
+ * Called when upstream returns "Server not initialized" (stale session).
939
+ */
940
+ private async reinitializeSession(
941
+ httpServer: HttpMcpServerConfig,
942
+ agentId: string,
943
+ mcpId: string,
944
+ userId?: string
945
+ ): Promise<void> {
946
+ // Clear stale session
947
+ const sessionKey = `mcp:session:${agentId}:${mcpId}`;
948
+ await this.redisClient.del(sessionKey).catch(() => {
949
+ /* noop */
950
+ });
951
+
952
+ // Send initialize
953
+ const initBody = JSON.stringify({
954
+ jsonrpc: "2.0",
955
+ method: "initialize",
956
+ params: {
957
+ protocolVersion: "2024-11-05",
958
+ capabilities: {},
959
+ clientInfo: { name: "lobu-gateway", version: "1.0.0" },
960
+ },
961
+ id: 0,
962
+ });
963
+
964
+ const initResponse = await this.sendUpstreamRequest(
965
+ httpServer,
966
+ agentId,
967
+ mcpId,
968
+ "POST",
969
+ initBody,
970
+ userId
971
+ );
972
+
973
+ await initResponse.json(); // consume response
974
+
975
+ // Send notifications/initialized
976
+ const notifyBody = JSON.stringify({
977
+ jsonrpc: "2.0",
978
+ method: "notifications/initialized",
979
+ });
980
+ await this.sendUpstreamRequest(
981
+ httpServer,
982
+ agentId,
983
+ mcpId,
984
+ "POST",
985
+ notifyBody,
986
+ userId
987
+ ).catch(() => {
988
+ /* noop */
989
+ });
990
+
991
+ logger.info("Re-initialized MCP session", { mcpId, agentId });
992
+ }
993
+
994
+ /**
995
+ * Auto-start device-code auth when an MCP upstream returns an auth error.
996
+ * Returns a user-facing message with the verification URL, or null on failure.
997
+ */
998
+ private async tryAutoDeviceAuth(
999
+ mcpId: string,
1000
+ agentId: string,
1001
+ userId: string
1002
+ ): Promise<string | null> {
1003
+ try {
1004
+ const { startDeviceAuth } = await import(
1005
+ "../../routes/internal/device-auth"
1006
+ );
1007
+
1008
+ // Check if a device auth flow is already pending (avoid duplicate starts)
1009
+ const pendingKey = `device-auth:${agentId}:${userId}:${mcpId}`;
1010
+ const pending = await this.redisClient.get(pendingKey);
1011
+ if (pending) {
1012
+ // Return the existing pending flow's info instead of starting a new one
1013
+ return JSON.stringify({
1014
+ status: "login_required",
1015
+ message:
1016
+ "Authentication is required. A login flow is already in progress. STOP calling tools and tell the user to complete login in their browser. Do NOT retry this tool call.",
1017
+ note: "Do NOT call any owletto tools until the user completes login.",
1018
+ });
1019
+ }
1020
+
1021
+ const result = await startDeviceAuth(
1022
+ this.redisClient,
1023
+ this.configService as any,
1024
+ mcpId,
1025
+ agentId,
1026
+ userId
1027
+ );
1028
+ if (!result) return null;
1029
+ const url = result.verificationUriComplete || result.verificationUri;
1030
+ return JSON.stringify({
1031
+ status: "login_required",
1032
+ message:
1033
+ "Authentication is required. STOP calling tools and show the user this login link and code. Do NOT retry this tool call — wait for the user to complete login first.",
1034
+ verification_url: url,
1035
+ user_code: result.userCode,
1036
+ expires_in_seconds: result.expiresIn,
1037
+ });
1038
+ } catch (error) {
1039
+ logger.warn("Auto device-auth failed", {
1040
+ mcpId,
1041
+ error: error instanceof Error ? error.message : String(error),
1042
+ });
1043
+ return null;
1044
+ }
1045
+ }
1046
+
1047
+ private async getSession(key: string): Promise<string | null> {
1048
+ try {
1049
+ const sessionId = await this.redisClient.get(key);
1050
+ if (sessionId) {
1051
+ await this.redisClient.expire(key, this.SESSION_TTL_SECONDS);
1052
+ }
1053
+ return sessionId;
1054
+ } catch (error) {
1055
+ logger.error("Failed to get MCP session from Redis", { key, error });
1056
+ return null;
1057
+ }
1058
+ }
1059
+
1060
+ private async setSession(key: string, sessionId: string): Promise<void> {
1061
+ try {
1062
+ await this.redisClient.set(
1063
+ key,
1064
+ sessionId,
1065
+ "EX",
1066
+ this.SESSION_TTL_SECONDS
1067
+ );
1068
+ } catch (error) {
1069
+ logger.error("Failed to store MCP session in Redis", { key, error });
1070
+ }
1071
+ }
1072
+
1073
+ private sendJsonRpcError(
1074
+ c: Context,
1075
+ code: number,
1076
+ message: string,
1077
+ id: any = null
1078
+ ): Response {
1079
+ return c.json(
1080
+ {
1081
+ jsonrpc: "2.0",
1082
+ id,
1083
+ error: { code, message },
1084
+ },
1085
+ 200
1086
+ );
1087
+ }
1088
+ }