@lobu/gateway 3.0.5 → 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.
- package/package.json +2 -2
- package/src/__tests__/agent-config-routes.test.ts +254 -0
- package/src/__tests__/agent-history-routes.test.ts +72 -0
- package/src/__tests__/agent-routes.test.ts +68 -0
- package/src/__tests__/agent-schedules-routes.test.ts +59 -0
- package/src/__tests__/agent-settings-store.test.ts +323 -0
- package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
- package/src/__tests__/chat-response-bridge.test.ts +131 -0
- package/src/__tests__/config-memory-plugins.test.ts +92 -0
- package/src/__tests__/config-request-store.test.ts +127 -0
- package/src/__tests__/connection-routes.test.ts +144 -0
- package/src/__tests__/core-services-store-selection.test.ts +92 -0
- package/src/__tests__/docker-deployment.test.ts +1211 -0
- package/src/__tests__/embedded-deployment.test.ts +342 -0
- package/src/__tests__/grant-store.test.ts +148 -0
- package/src/__tests__/http-proxy.test.ts +281 -0
- package/src/__tests__/instruction-service.test.ts +37 -0
- package/src/__tests__/link-buttons.test.ts +112 -0
- package/src/__tests__/lobu.test.ts +32 -0
- package/src/__tests__/mcp-config-service.test.ts +347 -0
- package/src/__tests__/mcp-proxy.test.ts +696 -0
- package/src/__tests__/message-handler-bridge.test.ts +17 -0
- package/src/__tests__/model-selection.test.ts +172 -0
- package/src/__tests__/oauth-templates.test.ts +39 -0
- package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
- package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
- package/src/__tests__/provider-inheritance.test.ts +212 -0
- package/src/__tests__/routes/cli-auth.test.ts +337 -0
- package/src/__tests__/routes/interactions.test.ts +121 -0
- package/src/__tests__/secret-proxy.test.ts +85 -0
- package/src/__tests__/session-manager.test.ts +572 -0
- package/src/__tests__/setup.ts +133 -0
- package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
- package/src/__tests__/slack-routes.test.ts +161 -0
- package/src/__tests__/system-config-resolver.test.ts +75 -0
- package/src/__tests__/system-message-limiter.test.ts +89 -0
- package/src/__tests__/system-skills-service.test.ts +362 -0
- package/src/__tests__/transcription-service.test.ts +222 -0
- package/src/__tests__/utils/rate-limiter.test.ts +102 -0
- package/src/__tests__/worker-connection-manager.test.ts +497 -0
- package/src/__tests__/worker-job-router.test.ts +722 -0
- package/src/api/index.ts +1 -0
- package/src/api/platform.ts +292 -0
- package/src/api/response-renderer.ts +157 -0
- package/src/auth/agent-metadata-store.ts +168 -0
- package/src/auth/api-auth-middleware.ts +69 -0
- package/src/auth/api-key-provider-module.ts +213 -0
- package/src/auth/base-provider-module.ts +201 -0
- package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
- package/src/auth/chatgpt/device-code-client.ts +218 -0
- package/src/auth/chatgpt/index.ts +1 -0
- package/src/auth/claude/oauth-module.ts +280 -0
- package/src/auth/cli/token-service.ts +249 -0
- package/src/auth/external/client.ts +560 -0
- package/src/auth/external/device-code-client.ts +225 -0
- package/src/auth/mcp/config-service.ts +392 -0
- package/src/auth/mcp/proxy.ts +1088 -0
- package/src/auth/mcp/string-substitution.ts +17 -0
- package/src/auth/mcp/tool-cache.ts +90 -0
- package/src/auth/oauth/base-client.ts +267 -0
- package/src/auth/oauth/client.ts +153 -0
- package/src/auth/oauth/credentials.ts +7 -0
- package/src/auth/oauth/providers.ts +69 -0
- package/src/auth/oauth/state-store.ts +150 -0
- package/src/auth/oauth-templates.ts +179 -0
- package/src/auth/provider-catalog.ts +220 -0
- package/src/auth/provider-model-options.ts +41 -0
- package/src/auth/settings/agent-settings-store.ts +565 -0
- package/src/auth/settings/auth-profiles-manager.ts +216 -0
- package/src/auth/settings/index.ts +12 -0
- package/src/auth/settings/model-preference-store.ts +52 -0
- package/src/auth/settings/model-selection.ts +135 -0
- package/src/auth/settings/resolved-settings-view.ts +298 -0
- package/src/auth/settings/template-utils.ts +44 -0
- package/src/auth/settings/token-service.ts +88 -0
- package/src/auth/system-env-store.ts +98 -0
- package/src/auth/user-agents-store.ts +68 -0
- package/src/channels/binding-service.ts +214 -0
- package/src/channels/index.ts +4 -0
- package/src/cli/gateway.ts +1304 -0
- package/src/cli/index.ts +74 -0
- package/src/commands/built-in-commands.ts +80 -0
- package/src/commands/command-dispatcher.ts +94 -0
- package/src/commands/command-reply-adapters.ts +27 -0
- package/src/config/file-loader.ts +618 -0
- package/src/config/index.ts +588 -0
- package/src/config/network-allowlist.ts +71 -0
- package/src/connections/chat-instance-manager.ts +1284 -0
- package/src/connections/chat-response-bridge.ts +618 -0
- package/src/connections/index.ts +7 -0
- package/src/connections/interaction-bridge.ts +831 -0
- package/src/connections/message-handler-bridge.ts +415 -0
- package/src/connections/platform-auth-methods.ts +15 -0
- package/src/connections/types.ts +84 -0
- package/src/gateway/connection-manager.ts +291 -0
- package/src/gateway/index.ts +700 -0
- package/src/gateway/job-router.ts +201 -0
- package/src/gateway-main.ts +200 -0
- package/src/index.ts +41 -0
- package/src/infrastructure/queue/index.ts +12 -0
- package/src/infrastructure/queue/queue-producer.ts +148 -0
- package/src/infrastructure/queue/redis-queue.ts +361 -0
- package/src/infrastructure/queue/types.ts +133 -0
- package/src/infrastructure/redis/system-message-limiter.ts +94 -0
- package/src/interactions/config-request-store.ts +198 -0
- package/src/interactions.ts +363 -0
- package/src/lobu.ts +311 -0
- package/src/metrics/prometheus.ts +159 -0
- package/src/modules/module-system.ts +179 -0
- package/src/orchestration/base-deployment-manager.ts +900 -0
- package/src/orchestration/deployment-utils.ts +98 -0
- package/src/orchestration/impl/docker-deployment.ts +620 -0
- package/src/orchestration/impl/embedded-deployment.ts +268 -0
- package/src/orchestration/impl/index.ts +8 -0
- package/src/orchestration/impl/k8s/deployment.ts +1061 -0
- package/src/orchestration/impl/k8s/helpers.ts +610 -0
- package/src/orchestration/impl/k8s/index.ts +1 -0
- package/src/orchestration/index.ts +333 -0
- package/src/orchestration/message-consumer.ts +584 -0
- package/src/orchestration/scheduled-wakeup.ts +704 -0
- package/src/permissions/approval-policy.ts +36 -0
- package/src/permissions/grant-store.ts +219 -0
- package/src/platform/file-handler.ts +66 -0
- package/src/platform/link-buttons.ts +57 -0
- package/src/platform/renderer-utils.ts +44 -0
- package/src/platform/response-renderer.ts +84 -0
- package/src/platform/unified-thread-consumer.ts +187 -0
- package/src/platform.ts +318 -0
- package/src/proxy/http-proxy.ts +752 -0
- package/src/proxy/proxy-manager.ts +81 -0
- package/src/proxy/secret-proxy.ts +402 -0
- package/src/proxy/token-refresh-job.ts +143 -0
- package/src/routes/internal/audio.ts +141 -0
- package/src/routes/internal/device-auth.ts +566 -0
- package/src/routes/internal/files.ts +226 -0
- package/src/routes/internal/history.ts +69 -0
- package/src/routes/internal/images.ts +127 -0
- package/src/routes/internal/interactions.ts +84 -0
- package/src/routes/internal/middleware.ts +23 -0
- package/src/routes/internal/schedule.ts +226 -0
- package/src/routes/internal/types.ts +22 -0
- package/src/routes/openapi-auto.ts +239 -0
- package/src/routes/public/agent-access.ts +23 -0
- package/src/routes/public/agent-config.ts +675 -0
- package/src/routes/public/agent-history.ts +422 -0
- package/src/routes/public/agent-schedules.ts +296 -0
- package/src/routes/public/agent.ts +1086 -0
- package/src/routes/public/agents.ts +373 -0
- package/src/routes/public/channels.ts +191 -0
- package/src/routes/public/cli-auth.ts +883 -0
- package/src/routes/public/connections.ts +574 -0
- package/src/routes/public/landing.ts +16 -0
- package/src/routes/public/oauth.ts +147 -0
- package/src/routes/public/settings-auth.ts +104 -0
- package/src/routes/public/slack.ts +173 -0
- package/src/routes/shared/agent-ownership.ts +101 -0
- package/src/routes/shared/token-verifier.ts +34 -0
- package/src/services/core-services.ts +1053 -0
- package/src/services/image-generation-service.ts +257 -0
- package/src/services/instruction-service.ts +318 -0
- package/src/services/mcp-registry.ts +94 -0
- package/src/services/platform-helpers.ts +287 -0
- package/src/services/session-manager.ts +262 -0
- package/src/services/settings-resolver.ts +74 -0
- package/src/services/system-config-resolver.ts +90 -0
- package/src/services/system-skills-service.ts +229 -0
- package/src/services/transcription-service.ts +684 -0
- package/src/session.ts +110 -0
- package/src/spaces/index.ts +1 -0
- package/src/spaces/space-resolver.ts +17 -0
- package/src/stores/in-memory-agent-store.ts +403 -0
- package/src/stores/redis-agent-store.ts +279 -0
- package/src/utils/public-url.ts +44 -0
- package/src/utils/rate-limiter.ts +94 -0
- 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
|
+
}
|