modality-mcp-kit 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * MCP Proxy Utility for WebSocket Server
3
+ *
4
+ * Provides HTTP proxy functionality for MCP servers with caching,
5
+ * schema fixing, and fallback support.
6
+ *
7
+ * Usage: /proxy/:mcpName - proxies requests to configured MCP servers
8
+ * Example: /proxy/figma → http://127.0.0.1:3845/mcp
9
+ */
10
+ interface MCPServerConfig {
11
+ url: string;
12
+ description?: string;
13
+ }
14
+ export type McpProxyConfig = Record<string, MCPServerConfig>;
15
+ /**
16
+ * Hono handler for MCP proxy
17
+ * @param c - Hono context with mcpName param
18
+ * @returns Response proxied from MCP server
19
+ */
20
+ export declare const mcpProxyHandler: (MCP_SERVERS: McpProxyConfig) => (c: any) => Promise<any>;
21
+ /**
22
+ * Hono handler for listing available MCP servers
23
+ * @param c - Hono context
24
+ * @returns List of available MCP servers
25
+ */
26
+ export declare const mcpProxyListHandler: (MCP_SERVERS: McpProxyConfig) => (c: any) => any;
27
+ /**
28
+ * Hono handler for MCP proxy cache inspection
29
+ * @param c - Hono context with mcpName param
30
+ * @returns Cache contents for the specified MCP server
31
+ */
32
+ export declare function mcpProxyCacheHandler(c: any): Promise<any>;
33
+ export {};
@@ -0,0 +1,521 @@
1
+ /**
2
+ * MCP Proxy Utility for WebSocket Server
3
+ *
4
+ * Provides HTTP proxy functionality for MCP servers with caching,
5
+ * schema fixing, and fallback support.
6
+ *
7
+ * Usage: /proxy/:mcpName - proxies requests to configured MCP servers
8
+ * Example: /proxy/figma → http://127.0.0.1:3845/mcp
9
+ */
10
+ import { SimpleCache } from "modality-kit";
11
+ // ============================================
12
+ // CACHE CONFIGURATION
13
+ // ============================================
14
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
15
+ // Method-specific TTL configuration
16
+ const METHOD_TTL_MS = {
17
+ initialize: 30 * 60 * 1000, // 30 min
18
+ "tools/list": 5 * 60 * 1000, // 5 min
19
+ "resources/list": 1 * 60 * 1000, // 1 min
20
+ "prompts/list": 5 * 60 * 1000, // 5 min
21
+ };
22
+ // Cacheable MCP methods (read-only operations)
23
+ const CACHEABLE_METHODS = new Set([
24
+ "tools/list",
25
+ "resources/list",
26
+ "prompts/list",
27
+ "tools/call",
28
+ ]);
29
+ // Special methods that should be cached for fallback but NOT served from cache directly
30
+ const FALLBACK_ONLY_METHODS = new Set(["initialize"]);
31
+ // Per-MCP server cache instances
32
+ const serverCaches = new Map();
33
+ function getServerCache(mcpName) {
34
+ let cache = serverCaches.get(mcpName);
35
+ if (!cache) {
36
+ cache = new SimpleCache({
37
+ ttlMs: DEFAULT_TTL_MS,
38
+ enableLru: true,
39
+ maxSize: 500,
40
+ });
41
+ serverCaches.set(mcpName, cache);
42
+ }
43
+ return cache;
44
+ }
45
+ function getMethodsFromRequest(requestData) {
46
+ if (!requestData)
47
+ return [];
48
+ if (Array.isArray(requestData)) {
49
+ return requestData
50
+ .map((r) => (r && typeof r === "object" ? r.method : undefined))
51
+ .filter((m) => typeof m === "string");
52
+ }
53
+ if (requestData?.method && typeof requestData.method === "string")
54
+ return [requestData.method];
55
+ return [];
56
+ }
57
+ function isRequestCacheable(requestData) {
58
+ const methods = getMethodsFromRequest(requestData);
59
+ if (methods.length === 0)
60
+ return false;
61
+ return methods.every((m) => CACHEABLE_METHODS.has(m));
62
+ }
63
+ function isRequestFallbackOnly(requestData) {
64
+ const methods = getMethodsFromRequest(requestData);
65
+ if (methods.length === 0)
66
+ return false;
67
+ return methods.some((m) => FALLBACK_ONLY_METHODS.has(m));
68
+ }
69
+ function shouldStoreRequestInCache(requestData) {
70
+ const methods = getMethodsFromRequest(requestData);
71
+ if (methods.length === 0)
72
+ return false;
73
+ return methods.every((m) => CACHEABLE_METHODS.has(m) || FALLBACK_ONLY_METHODS.has(m));
74
+ }
75
+ function getTTLForMethod(method) {
76
+ return METHOD_TTL_MS[method] || DEFAULT_TTL_MS;
77
+ }
78
+ function getTTLForMethods(methods) {
79
+ if (!methods || methods.length === 0)
80
+ return DEFAULT_TTL_MS;
81
+ const ttls = methods.map((m) => getTTLForMethod(m));
82
+ return Math.min(...ttls);
83
+ }
84
+ function stripMetaFromParams(params) {
85
+ if (!params || typeof params !== "object")
86
+ return params;
87
+ const { _meta, ...rest } = params;
88
+ return Object.keys(rest).length > 0 ? rest : null;
89
+ }
90
+ function generateCacheKey(requestData) {
91
+ try {
92
+ if (!requestData)
93
+ return null;
94
+ if (Array.isArray(requestData)) {
95
+ const parts = requestData
96
+ .map((r) => {
97
+ if (!r || typeof r !== "object")
98
+ return null;
99
+ const m = r.method;
100
+ if (!m || typeof m !== "string")
101
+ return null;
102
+ const strippedParams = stripMetaFromParams(r.params);
103
+ const p = strippedParams ? JSON.stringify(strippedParams) : null;
104
+ return p ? `${m}:${p}` : m;
105
+ })
106
+ .filter(Boolean);
107
+ if (parts.length === 0)
108
+ return null;
109
+ return parts.join("|");
110
+ }
111
+ const method = requestData?.method;
112
+ if (!method ||
113
+ typeof method !== "string" ||
114
+ method === "notifications/initialized")
115
+ return null;
116
+ const strippedParams = stripMetaFromParams(requestData?.params);
117
+ if (!strippedParams) {
118
+ return method;
119
+ }
120
+ return `${method}:${JSON.stringify(strippedParams)}`;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ function replaceCachedResponseId(cachedResponse, requestId) {
127
+ if (requestId === undefined || requestId === null)
128
+ return cachedResponse;
129
+ try {
130
+ if (cachedResponse.includes("data:")) {
131
+ const lines = cachedResponse.split("\n");
132
+ const processedLines = lines.map((line) => {
133
+ if (line.startsWith("data: ")) {
134
+ const jsonStr = line.substring(6);
135
+ try {
136
+ const parsed = JSON.parse(jsonStr);
137
+ if (parsed && typeof parsed === "object" && "jsonrpc" in parsed) {
138
+ parsed.id = requestId;
139
+ return "data: " + JSON.stringify(parsed);
140
+ }
141
+ }
142
+ catch {
143
+ // Not valid JSON, return as-is
144
+ }
145
+ }
146
+ return line;
147
+ });
148
+ return processedLines.join("\n");
149
+ }
150
+ else {
151
+ const parsed = JSON.parse(cachedResponse);
152
+ if (parsed && typeof parsed === "object" && "jsonrpc" in parsed) {
153
+ parsed.id = requestId;
154
+ return JSON.stringify(parsed);
155
+ }
156
+ }
157
+ }
158
+ catch {
159
+ // Parse error, return original
160
+ }
161
+ return cachedResponse;
162
+ }
163
+ function getStaleCache(cache, cacheKey) {
164
+ try {
165
+ const entry = cache.get(cacheKey, true);
166
+ if (!entry)
167
+ return null;
168
+ return typeof entry === "string" ? entry : entry.data;
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ function getAnyCacheForMethod(cache, method) {
175
+ try {
176
+ let cached = cache.get(method, true);
177
+ if (cached?.data)
178
+ return cached.data;
179
+ if (typeof cached === "string")
180
+ return cached;
181
+ const keys = cache.keys();
182
+ for (const k of keys) {
183
+ if (k === method || k.startsWith(`${method}:`)) {
184
+ const entry = cache.get(k, true);
185
+ if (entry?.data)
186
+ return entry.data;
187
+ if (typeof entry === "string")
188
+ return entry;
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ // ============================================
198
+ // HONO HANDLERS
199
+ // ============================================
200
+ /**
201
+ * Hono handler for MCP proxy
202
+ * @param c - Hono context with mcpName param
203
+ * @returns Response proxied from MCP server
204
+ */
205
+ export const mcpProxyHandler = (MCP_SERVERS) => async (c) => {
206
+ const mcpName = c.req.param("mcpName");
207
+ if (!mcpName) {
208
+ return c.json({
209
+ error: "MCP server name is required",
210
+ availableServers: Object.keys(MCP_SERVERS),
211
+ }, 400);
212
+ }
213
+ // Handle CORS preflight
214
+ if (c.req.method === "OPTIONS") {
215
+ return new Response(null, {
216
+ status: 200,
217
+ headers: {
218
+ "Access-Control-Allow-Origin": "*",
219
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS, DELETE",
220
+ "Access-Control-Allow-Headers": "Content-Type, Accept, mcp-session-id, mcp-protocol-version, Authorization",
221
+ "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version",
222
+ },
223
+ });
224
+ }
225
+ // GET request - either SSE stream or server info
226
+ if (c.req.method === "GET") {
227
+ const serverConfig = MCP_SERVERS[mcpName];
228
+ if (!serverConfig) {
229
+ return c.json({
230
+ error: "MCP server not found",
231
+ availableServers: Object.keys(MCP_SERVERS),
232
+ }, 404);
233
+ }
234
+ const accept = c.req.header("accept") || "";
235
+ // If client wants SSE, proxy the GET request to upstream for SSE connection
236
+ if (accept.includes("text/event-stream")) {
237
+ console.log(`[MCP-PROXY] SSE GET request to ${mcpName}`);
238
+ // Forward headers to upstream
239
+ const incomingHeaders = c.req.raw.headers;
240
+ const upstreamHeaders = {
241
+ Accept: "text/event-stream",
242
+ };
243
+ // Forward MCP-specific headers
244
+ const headersToForward = [
245
+ "mcp-session-id",
246
+ "mcp-protocol-version",
247
+ "authorization",
248
+ "last-event-id",
249
+ ];
250
+ for (const header of headersToForward) {
251
+ const value = incomingHeaders.get(header);
252
+ if (value) {
253
+ upstreamHeaders[header] = value;
254
+ }
255
+ }
256
+ try {
257
+ const upstreamResponse = await fetch(serverConfig.url, {
258
+ method: "GET",
259
+ headers: upstreamHeaders,
260
+ });
261
+ // Forward response headers
262
+ const responseHeaders = new Headers();
263
+ upstreamResponse.headers.forEach((value, key) => {
264
+ if (!["transfer-encoding", "content-length", "connection"].includes(key.toLowerCase())) {
265
+ responseHeaders.set(key, value);
266
+ }
267
+ });
268
+ responseHeaders.set("Access-Control-Allow-Origin", "*");
269
+ responseHeaders.set("Access-Control-Expose-Headers", "mcp-session-id, mcp-protocol-version");
270
+ // Stream the SSE response
271
+ if (upstreamResponse.body) {
272
+ return new Response(upstreamResponse.body, {
273
+ status: upstreamResponse.status,
274
+ headers: responseHeaders,
275
+ });
276
+ }
277
+ return new Response(await upstreamResponse.text(), {
278
+ status: upstreamResponse.status,
279
+ headers: responseHeaders,
280
+ });
281
+ }
282
+ catch (error) {
283
+ console.error(`[MCP-PROXY] SSE GET error:`, error);
284
+ return c.json({
285
+ error: "Failed to establish SSE connection",
286
+ message: error.message,
287
+ }, 502);
288
+ }
289
+ }
290
+ // Regular GET - return server info
291
+ const cache = getServerCache(mcpName);
292
+ return c.json({
293
+ mcpName,
294
+ ...serverConfig,
295
+ cacheKeys: cache.keys(),
296
+ cacheSize: cache.keys().length,
297
+ });
298
+ }
299
+ // POST request - proxy to MCP server with streaming support
300
+ if (c.req.method === "POST") {
301
+ const serverConfig = MCP_SERVERS[mcpName];
302
+ if (!serverConfig) {
303
+ return c.json({
304
+ error: "MCP server not found",
305
+ availableServers: Object.keys(MCP_SERVERS),
306
+ }, 404);
307
+ }
308
+ const requestBody = await c.req.text();
309
+ console.log(`[MCP-PROXY] Streaming proxy to ${mcpName}, body: ${requestBody.substring(0, 200)}`);
310
+ // Check for cached response first (for cacheable methods)
311
+ let requestData;
312
+ let cacheKey = null;
313
+ const cache = getServerCache(mcpName);
314
+ try {
315
+ requestData = JSON.parse(requestBody);
316
+ // Handle notifications/initialized locally
317
+ if (requestData === "notifications/initialized" ||
318
+ requestData?.method === "notifications/initialized") {
319
+ return c.json({ jsonrpc: "2.0", result: null, id: null });
320
+ }
321
+ cacheKey = generateCacheKey(requestData);
322
+ // Check cache for cacheable methods
323
+ if (cacheKey &&
324
+ isRequestCacheable(requestData) &&
325
+ !isRequestFallbackOnly(requestData)) {
326
+ const cached = cache.get(cacheKey);
327
+ if (cached) {
328
+ const outBody = replaceCachedResponseId(cached, requestData?.id);
329
+ const isSSE = outBody.includes("event:") || outBody.includes("\ndata:");
330
+ return new Response(outBody, {
331
+ status: 200,
332
+ headers: {
333
+ "Content-Type": isSSE
334
+ ? "text/event-stream"
335
+ : "application/json",
336
+ "X-Cache-Status": "HIT",
337
+ "X-Cache-Key": cacheKey,
338
+ "Access-Control-Allow-Origin": "*",
339
+ },
340
+ });
341
+ }
342
+ }
343
+ }
344
+ catch {
345
+ // Invalid JSON, proceed without caching
346
+ }
347
+ // Stream the request to upstream and pipe response back
348
+ try {
349
+ // Forward relevant headers from incoming request to upstream
350
+ const incomingHeaders = c.req.raw.headers;
351
+ const upstreamHeaders = {
352
+ "Content-Type": "application/json",
353
+ Accept: "application/json, text/event-stream",
354
+ };
355
+ // Forward MCP-specific headers
356
+ const headersToForward = [
357
+ "mcp-session-id",
358
+ "mcp-protocol-version",
359
+ "authorization",
360
+ ];
361
+ for (const header of headersToForward) {
362
+ const value = incomingHeaders.get(header);
363
+ if (value) {
364
+ upstreamHeaders[header] = value;
365
+ console.log(`[MCP-PROXY] Forwarding header: ${header}=${value.substring(0, 50)}`);
366
+ }
367
+ }
368
+ console.log(`[MCP-PROXY] Upstream headers:`, Object.keys(upstreamHeaders));
369
+ const upstreamResponse = await fetch(serverConfig.url, {
370
+ method: "POST",
371
+ headers: upstreamHeaders,
372
+ body: requestBody,
373
+ });
374
+ // Get all response headers from upstream
375
+ const responseHeaders = new Headers();
376
+ upstreamResponse.headers.forEach((value, key) => {
377
+ // Skip problematic headers
378
+ if (!["transfer-encoding", "content-length", "connection"].includes(key.toLowerCase())) {
379
+ responseHeaders.set(key, value);
380
+ }
381
+ // Log MCP session header
382
+ if (key.toLowerCase() === "mcp-session-id") {
383
+ console.log(`[MCP-PROXY] Response mcp-session-id: ${value}`);
384
+ }
385
+ });
386
+ responseHeaders.set("Access-Control-Allow-Origin", "*");
387
+ responseHeaders.set("Access-Control-Expose-Headers", "mcp-session-id, mcp-protocol-version");
388
+ console.log(`[MCP-PROXY] Upstream response status: ${upstreamResponse.status}`);
389
+ // If upstream returns a body stream, pipe it directly
390
+ if (upstreamResponse.body) {
391
+ // For SSE, we need to pass through the stream
392
+ const contentType = upstreamResponse.headers.get("content-type") || "";
393
+ const isSSE = contentType.includes("text/event-stream") ||
394
+ contentType.includes("application/json");
395
+ if (isSSE) {
396
+ // Clone the response to read for caching while also streaming
397
+ const [streamForResponse, streamForCache] = upstreamResponse.body.tee();
398
+ // Cache in background (non-blocking)
399
+ if (cacheKey && shouldStoreRequestInCache(requestData)) {
400
+ (async () => {
401
+ try {
402
+ const reader = streamForCache.getReader();
403
+ const chunks = [];
404
+ let done = false;
405
+ while (!done) {
406
+ const result = await reader.read();
407
+ done = result.done;
408
+ if (result.value) {
409
+ chunks.push(result.value);
410
+ }
411
+ }
412
+ const fullBody = new TextDecoder().decode(new Uint8Array(chunks.reduce((acc, chunk) => [...acc, ...chunk], [])));
413
+ // Store in cache with proper SSE format
414
+ const methods = getMethodsFromRequest(requestData);
415
+ const ttl = getTTLForMethods(methods);
416
+ let cacheValue = fullBody;
417
+ if (!fullBody.includes("event:")) {
418
+ cacheValue = `event: message\ndata: ${fullBody}\n\n`;
419
+ }
420
+ cache.set(cacheKey, cacheValue, ttl);
421
+ console.log(`[MCP-PROXY] Cached response for ${cacheKey}`);
422
+ }
423
+ catch (e) {
424
+ console.error(`[MCP-PROXY] Cache error:`, e);
425
+ }
426
+ })();
427
+ }
428
+ return new Response(streamForResponse, {
429
+ status: upstreamResponse.status,
430
+ headers: responseHeaders,
431
+ });
432
+ }
433
+ }
434
+ // Fallback: buffer and return
435
+ const body = await upstreamResponse.text();
436
+ return new Response(body, {
437
+ status: upstreamResponse.status,
438
+ headers: responseHeaders,
439
+ });
440
+ }
441
+ catch (error) {
442
+ console.error(`[MCP-PROXY] Upstream error:`, error);
443
+ // Try fallback cache
444
+ const methods = getMethodsFromRequest(requestData);
445
+ let fallbackCache = cacheKey ? getStaleCache(cache, cacheKey) : null;
446
+ if (!fallbackCache && methods?.length > 0) {
447
+ for (const method of methods) {
448
+ fallbackCache = getAnyCacheForMethod(cache, method);
449
+ if (fallbackCache)
450
+ break;
451
+ }
452
+ }
453
+ if (fallbackCache) {
454
+ const outBody = replaceCachedResponseId(fallbackCache, requestData?.id);
455
+ const isSSE = outBody.includes("event:") || outBody.includes("\ndata:");
456
+ return new Response(outBody, {
457
+ status: 200,
458
+ headers: {
459
+ "Content-Type": isSSE ? "text/event-stream" : "application/json",
460
+ "X-Cache-Status": "STALE",
461
+ "X-Cache-Reason": "upstream-error",
462
+ "Access-Control-Allow-Origin": "*",
463
+ },
464
+ });
465
+ }
466
+ return c.json({
467
+ error: "Upstream server unavailable",
468
+ code: error.code || error.name || "UNKNOWN",
469
+ message: error.message,
470
+ upstream: serverConfig.url,
471
+ }, 502);
472
+ }
473
+ }
474
+ return c.json({ error: "Method not allowed" }, 405);
475
+ };
476
+ /**
477
+ * Hono handler for listing available MCP servers
478
+ * @param c - Hono context
479
+ * @returns List of available MCP servers
480
+ */
481
+ export const mcpProxyListHandler = (MCP_SERVERS) => (c) => {
482
+ const servers = Object.entries(MCP_SERVERS).map(([name, config]) => ({
483
+ name,
484
+ url: config.url,
485
+ description: config.description,
486
+ proxyUrl: `/proxy/${name}`,
487
+ }));
488
+ return c.json({
489
+ servers,
490
+ count: servers.length,
491
+ });
492
+ };
493
+ /**
494
+ * Hono handler for MCP proxy cache inspection
495
+ * @param c - Hono context with mcpName param
496
+ * @returns Cache contents for the specified MCP server
497
+ */
498
+ export async function mcpProxyCacheHandler(c) {
499
+ const mcpName = c.req.param("mcpName");
500
+ if (!mcpName) {
501
+ // Return all caches overview
502
+ const caches = {};
503
+ serverCaches.forEach((cache, name) => {
504
+ caches[name] = cache.keys();
505
+ });
506
+ return c.json({ caches });
507
+ }
508
+ const cache = serverCaches.get(mcpName);
509
+ if (!cache) {
510
+ return c.json({ error: "No cache for this MCP server", keys: [] });
511
+ }
512
+ const cacheKey = c.req.param("cacheKey");
513
+ if (cacheKey) {
514
+ const entry = cache.get(decodeURIComponent(cacheKey), true);
515
+ if (!entry) {
516
+ return c.json({ error: "Cache key not found", cacheKey }, 404);
517
+ }
518
+ return c.json({ cacheKey, value: entry });
519
+ }
520
+ return c.json({ keys: cache.keys() });
521
+ }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.2.2",
2
+ "version": "1.3.0",
3
3
  "name": "modality-mcp-kit",
4
4
  "repository": {
5
5
  "type": "git",