@mcp-ts/sdk 1.5.0 → 1.5.2

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 (66) hide show
  1. package/dist/adapters/agui-adapter.d.mts +1 -1
  2. package/dist/adapters/agui-adapter.d.ts +1 -1
  3. package/dist/adapters/agui-adapter.js +43 -9
  4. package/dist/adapters/agui-adapter.js.map +1 -1
  5. package/dist/adapters/agui-adapter.mjs +43 -9
  6. package/dist/adapters/agui-adapter.mjs.map +1 -1
  7. package/dist/adapters/agui-middleware.d.mts +1 -1
  8. package/dist/adapters/agui-middleware.d.ts +1 -1
  9. package/dist/adapters/agui-middleware.js.map +1 -1
  10. package/dist/adapters/agui-middleware.mjs.map +1 -1
  11. package/dist/adapters/ai-adapter.d.mts +1 -1
  12. package/dist/adapters/ai-adapter.d.ts +1 -1
  13. package/dist/adapters/ai-adapter.js +42 -8
  14. package/dist/adapters/ai-adapter.js.map +1 -1
  15. package/dist/adapters/ai-adapter.mjs +42 -8
  16. package/dist/adapters/ai-adapter.mjs.map +1 -1
  17. package/dist/adapters/langchain-adapter.d.mts +1 -1
  18. package/dist/adapters/langchain-adapter.d.ts +1 -1
  19. package/dist/adapters/langchain-adapter.js +42 -8
  20. package/dist/adapters/langchain-adapter.js.map +1 -1
  21. package/dist/adapters/langchain-adapter.mjs +42 -8
  22. package/dist/adapters/langchain-adapter.mjs.map +1 -1
  23. package/dist/client/react.d.mts +91 -2
  24. package/dist/client/react.d.ts +91 -2
  25. package/dist/client/react.js +339 -3
  26. package/dist/client/react.js.map +1 -1
  27. package/dist/client/react.mjs +335 -4
  28. package/dist/client/react.mjs.map +1 -1
  29. package/dist/client/vue.d.mts +10 -0
  30. package/dist/client/vue.d.ts +10 -0
  31. package/dist/client/vue.js +28 -2
  32. package/dist/client/vue.js.map +1 -1
  33. package/dist/client/vue.mjs +28 -2
  34. package/dist/client/vue.mjs.map +1 -1
  35. package/dist/index.d.mts +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +170 -37
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +170 -37
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/server/index.js +55 -11
  42. package/dist/server/index.js.map +1 -1
  43. package/dist/server/index.mjs +55 -11
  44. package/dist/server/index.mjs.map +1 -1
  45. package/dist/shared/index.d.mts +2 -2
  46. package/dist/shared/index.d.ts +2 -2
  47. package/dist/shared/index.js +115 -26
  48. package/dist/shared/index.js.map +1 -1
  49. package/dist/shared/index.mjs +115 -26
  50. package/dist/shared/index.mjs.map +1 -1
  51. package/dist/{tool-router-XnWVxPzv.d.mts → tool-router-DK0RJblO.d.mts} +3 -0
  52. package/dist/{tool-router-Bo8qZbsD.d.ts → tool-router-DsKhRmJm.d.ts} +3 -0
  53. package/package.json +1 -1
  54. package/src/adapters/agui-adapter.ts +7 -7
  55. package/src/adapters/ai-adapter.ts +5 -5
  56. package/src/adapters/langchain-adapter.ts +5 -5
  57. package/src/client/react/index.ts +14 -0
  58. package/src/client/react/oauth-popup.tsx +446 -0
  59. package/src/client/react/use-mcp.ts +84 -3
  60. package/src/client/vue/use-mcp.ts +80 -3
  61. package/src/server/handlers/sse-handler.ts +39 -0
  62. package/src/server/mcp/oauth-client.ts +32 -14
  63. package/src/shared/meta-tools.ts +62 -13
  64. package/src/shared/tool-index.ts +85 -12
  65. package/src/shared/tool-router.ts +8 -7
  66. package/supabase/migrations/20260421010000_add_session_cleanup_cron.sql +32 -0
@@ -117,6 +117,17 @@ export interface McpClient {
117
117
  */
118
118
  disconnect: (sessionId: string) => Promise<void>;
119
119
 
120
+ /**
121
+ * Reconnect to an MCP server (disconnects existing session first)
122
+ */
123
+ reconnect: (params: {
124
+ serverId: string;
125
+ serverName: string;
126
+ serverUrl: string;
127
+ callbackUrl: string;
128
+ transportType?: 'sse' | 'streamable_http';
129
+ }) => Promise<string>;
130
+
120
131
  /**
121
132
  * Get connection by session ID
122
133
  */
@@ -240,16 +251,49 @@ export function useMcp(options: UseMcpOptions): McpClient {
240
251
  state === 'CONNECTED' ||
241
252
  state === 'DISCOVERING';
242
253
 
254
+ const getVisibleState = (
255
+ incomingState: McpConnectionState,
256
+ existingState?: McpConnectionState,
257
+ previousState?: McpConnectionState
258
+ ): McpConnectionState => {
259
+ // `INITIALIZING` has two meanings in practice:
260
+ // 1. genuine cold start / reconnect work
261
+ // 2. an internal setup step that happens mid-OAuth completion
262
+ //
263
+ // For case (2), showing raw `INITIALIZING` creates a confusing user-facing
264
+ // sequence like AUTHENTICATING -> INITIALIZING -> AUTHENTICATED.
265
+ // We keep the raw event stream intact for observability, but collapse the
266
+ // visible state back into the current auth phase in the UI.
267
+ if (
268
+ incomingState === 'INITIALIZING' &&
269
+ (
270
+ existingState === 'AUTHENTICATING' ||
271
+ existingState === 'AUTHENTICATED' ||
272
+ previousState === 'AUTHENTICATING' ||
273
+ previousState === 'AUTHENTICATED'
274
+ )
275
+ ) {
276
+ return existingState === 'AUTHENTICATED' || previousState === 'AUTHENTICATED'
277
+ ? 'AUTHENTICATED'
278
+ : 'AUTHENTICATING';
279
+ }
280
+
281
+ return incomingState;
282
+ };
283
+
243
284
  switch (event.type) {
244
285
  case 'state_changed': {
245
286
  const existing = connections.value.find((c) => c.sessionId === event.sessionId);
246
287
  if (existing) {
288
+ // Normalize the incoming backend state into the smoother user-facing
289
+ // state we want to render for this existing connection.
290
+ const normalizedState = getVisibleState(event.state, existing.state, event.previousState);
247
291
  // In stateless per-request transport, tool calls can emit transient reconnect states.
248
292
  // Keep READY sticky to avoid UI flicker from READY -> CONNECTING -> CONNECTED.
249
293
  const nextState =
250
- existing.state === 'READY' && isTransientReconnectState(event.state)
294
+ existing.state === 'READY' && isTransientReconnectState(normalizedState)
251
295
  ? existing.state
252
- : event.state;
296
+ : normalizedState;
253
297
 
254
298
  const index = connections.value.indexOf(existing);
255
299
  connections.value[index] = {
@@ -268,7 +312,9 @@ export function useMcp(options: UseMcpOptions): McpClient {
268
312
  sessionId: event.sessionId,
269
313
  serverId: event.serverId,
270
314
  serverName: event.serverName,
271
- state: event.state,
315
+ // New connections do not have prior local state, so we normalize
316
+ // only against the server-reported previous state.
317
+ state: getVisibleState(event.state, undefined, event.previousState),
272
318
  createdAt: event.createdAt ? new Date(event.createdAt) : undefined,
273
319
  tools: [],
274
320
  }];
@@ -457,6 +503,36 @@ export function useMcp(options: UseMcpOptions): McpClient {
457
503
  return result.sessionId;
458
504
  };
459
505
 
506
+ /**
507
+ * Reconnect to an MCP server (tears down existing session, then connects fresh)
508
+ */
509
+ const reconnect = async (params: {
510
+ serverId: string;
511
+ serverName: string;
512
+ serverUrl: string;
513
+ callbackUrl: string;
514
+ transportType?: 'sse' | 'streamable_http';
515
+ }): Promise<string> => {
516
+ if (!clientRef.value) {
517
+ throw new Error('SSE client not initialized');
518
+ }
519
+
520
+ // Find and disconnect existing session for the same server
521
+ const existing = connections.value.find(
522
+ (c) => c.serverId === params.serverId || c.serverUrl === params.serverUrl
523
+ );
524
+ if (existing) {
525
+ await clientRef.value.disconnectFromServer(existing.sessionId);
526
+ if (isMountedRef.value) {
527
+ connections.value = connections.value.filter((c) => c.sessionId !== existing.sessionId);
528
+ }
529
+ }
530
+
531
+ // Connect fresh
532
+ const result = await clientRef.value.connectToServer(params);
533
+ return result.sessionId;
534
+ };
535
+
460
536
  /**
461
537
  * Disconnect from an MCP server
462
538
  */
@@ -607,6 +683,7 @@ export function useMcp(options: UseMcpOptions): McpClient {
607
683
  status: status as unknown as { value: 'connecting' | 'connected' | 'disconnected' | 'error' },
608
684
  isInitializing: isInitializing as unknown as { value: boolean },
609
685
  connect,
686
+ reconnect,
610
687
  disconnect,
611
688
  getConnection,
612
689
  getConnectionByServerId,
@@ -363,9 +363,24 @@ export class SSEConnectionManager {
363
363
  return existing;
364
364
  }
365
365
 
366
+ const session = await storage.getSession(this.identity, sessionId);
367
+ if (!session) {
368
+ throw new Error('Session not found');
369
+ }
370
+
366
371
  const client = new MCPClient({
367
372
  identity: this.identity,
368
373
  sessionId,
374
+ // These fields are optional in MCPClient, but when rehydrating a known
375
+ // stored session on the server we pass them explicitly to preserve the
376
+ // original transport/connection metadata instead of relying on lazy
377
+ // reloading during initialize().
378
+ serverId: session.serverId,
379
+ serverName: session.serverName,
380
+ serverUrl: session.serverUrl,
381
+ callbackUrl: session.callbackUrl,
382
+ transportType: session.transportType,
383
+ headers: session.headers,
369
384
  });
370
385
 
371
386
  // Subscribe to events before connecting
@@ -437,6 +452,16 @@ export class SSEConnectionManager {
437
452
  const client = new MCPClient({
438
453
  identity: this.identity,
439
454
  sessionId,
455
+ // These fields are optional in MCPClient, but when rehydrating a known
456
+ // stored session on the server we pass them explicitly to preserve the
457
+ // original transport/connection metadata instead of relying on lazy
458
+ // reloading during initialize().
459
+ serverId: session.serverId,
460
+ serverName: session.serverName,
461
+ serverUrl: session.serverUrl,
462
+ callbackUrl: session.callbackUrl,
463
+ transportType: session.transportType,
464
+ headers: session.headers,
440
465
  ...clientMetadata,
441
466
  });
442
467
 
@@ -478,6 +503,20 @@ export class SSEConnectionManager {
478
503
  const client = new MCPClient({
479
504
  identity: this.identity,
480
505
  sessionId,
506
+ // These fields are optional in MCPClient, but when rehydrating a known
507
+ // stored session on the server we pass them explicitly to preserve the
508
+ // original connection metadata instead of relying on lazy
509
+ // reloading during initialize().
510
+ serverId: session.serverId,
511
+ serverName: session.serverName,
512
+ serverUrl: session.serverUrl,
513
+ callbackUrl: session.callbackUrl,
514
+ // NOTE: transportType is intentionally omitted here.
515
+ // The session's stored transportType is a placeholder ('streamable_http')
516
+ // set before transport negotiation. Omitting it lets MCPClient auto-negotiate
517
+ // (try streamable_http → SSE fallback), which is critical for servers like
518
+ // Neon that only support SSE transport.
519
+ headers: session.headers,
481
520
  });
482
521
 
483
522
  client.onConnectionEvent((event) => this.emitConnectionEvent(event));
@@ -500,16 +500,10 @@ export class MCPClient {
500
500
  this.emitStateChange('CONNECTED');
501
501
  this.emitProgress('Connected successfully');
502
502
 
503
- // Promote short-lived OAuth-pending session TTL to long-lived active TTL once.
504
- // Also persist when transport negotiation changed the effective transport.
505
- const existingSession = await storage.getSession(this.identity, this.sessionId);
506
- const needsTransportUpdate = !existingSession || existingSession.transportType !== this.transportType;
507
- const needsTtlPromotion = !existingSession || existingSession.active !== true;
508
-
509
- if (needsTransportUpdate || needsTtlPromotion) {
510
- console.log(`[MCPClient] Saving session ${this.sessionId} with 12hr TTL (connect success)`);
511
- await this.saveSession(SESSION_TTL_SECONDS, true);
512
- }
503
+ // Refresh session metadata on every successful connect so active sessions
504
+ // record ongoing usage and don't look dormant to storage cleanup jobs.
505
+ console.log(`[MCPClient] Saving session ${this.sessionId} with 12hr TTL (connect success)`);
506
+ await this.saveSession(SESSION_TTL_SECONDS, true);
513
507
  } catch (error) {
514
508
  /** Handle Authentication Errors */
515
509
  if (
@@ -537,11 +531,17 @@ export class MCPClient {
537
531
  : `OAuth authorization URL not available: ${detail}`;
538
532
  this.emitError(message, 'auth');
539
533
  this.emitStateChange('FAILED');
534
+
535
+ // Proactive Cleanup: This session has reached a terminal failure state.
536
+ // We remove it now to ensure the database remains lean, bypassing the
537
+ // automated lifecycle sweep.
540
538
  try {
541
539
  await storage.removeSession(this.identity, this.sessionId);
542
540
  } catch {
543
- // best-effort cleanup
541
+ // Non-blocking: Proactive cleanup failures are suppressed to prioritize
542
+ // the original error context.
544
543
  }
544
+
545
545
  throw new Error(message);
546
546
  }
547
547
 
@@ -571,6 +571,19 @@ export class MCPClient {
571
571
  const errorMessage = error instanceof Error ? error.message : 'Connection failed';
572
572
  this.emitError(errorMessage, 'connection');
573
573
  this.emitStateChange('FAILED');
574
+
575
+ // Terminal Handshake Failure: only purge transient sessions. Active
576
+ // sessions may still hold valid credentials for a later reconnect.
577
+ try {
578
+ const existingSession = await storage.getSession(this.identity, this.sessionId);
579
+ if (!existingSession || existingSession.active !== true) {
580
+ await storage.removeSession(this.identity, this.sessionId);
581
+ }
582
+ } catch {
583
+ // Non-blocking: Cleanup is performed on a best-effort basis and should
584
+ // not interfere with the primary error propagation.
585
+ }
586
+
574
587
  throw error;
575
588
  }
576
589
  }
@@ -606,6 +619,7 @@ export class MCPClient {
606
619
 
607
620
  let lastError: unknown;
608
621
  let tokensExchanged = false;
622
+ let authenticatedStateEmitted = false;
609
623
 
610
624
  for (const currentType of transportsToTry) {
611
625
  const isLastAttempt = currentType === transportsToTry[transportsToTry.length - 1];
@@ -623,10 +637,11 @@ export class MCPClient {
623
637
  this.emitProgress(`Tokens already exchanged, skipping auth step for ${currentType}...`);
624
638
  }
625
639
 
626
- /** Success! Update transport type */
627
- this.transportType = currentType;
640
+ if (!authenticatedStateEmitted) {
641
+ this.emitStateChange('AUTHENTICATED');
642
+ authenticatedStateEmitted = true;
643
+ }
628
644
 
629
- this.emitStateChange('AUTHENTICATED');
630
645
  this.emitProgress('Creating authenticated client...');
631
646
 
632
647
  this.client = new Client(
@@ -650,6 +665,9 @@ export class MCPClient {
650
665
  /** We explicitly try to connect with the transport we just auth'd with first */
651
666
  await this.client.connect(this.transport);
652
667
 
668
+ /** Connection succeeded — lock in the transport type */
669
+ this.transportType = currentType;
670
+
653
671
  this.emitStateChange('CONNECTED');
654
672
  // Update session with 12hr TTL after successful OAuth
655
673
  console.log(`[MCPClient] Updating session ${this.sessionId} to 12hr TTL (OAuth complete)`);
@@ -33,16 +33,18 @@ export function createSearchToolDefinition(): Tool {
33
33
  return {
34
34
  name: 'mcp_search_tool_bm25',
35
35
  description:
36
- 'Search the catalog of available tools using BM25 natural language ranking. ' +
37
- 'Returns tool names, descriptions, and server info. ' +
38
- 'Use this FIRST to find relevant tools before calling them. ' +
39
- 'Example queries: "database query", "send email", "github pull request".',
36
+ 'Search the catalog of available tools. Returns tool names, descriptions, and server info. ' +
37
+ 'Use this FIRST to find relevant tools before calling them.\n\n' +
38
+ 'Query forms:\n' +
39
+ '- "select:Read,Edit,Grep" fetch these exact tools by name\n' +
40
+ '- "notebook jupyter" — keyword search, up to limit best matches\n' +
41
+ '- "+slack send" — require "slack" in the name, rank by remaining terms',
40
42
  inputSchema: {
41
43
  type: 'object' as const,
42
44
  properties: {
43
45
  query: {
44
46
  type: 'string',
45
- description: 'Natural language description of the capability you need.',
47
+ description: 'Query to find tools. Use "select:<tool_name>" for direct selection, or keywords to search. Prefix keywords with + to require them.',
46
48
  },
47
49
  limit: {
48
50
  type: 'number',
@@ -105,10 +107,10 @@ export function createGetSchemaToolDefinition(): Tool {
105
107
  type: 'string',
106
108
  description: 'The exact tool name returned by mcp_search_tool_bm25.',
107
109
  },
108
- serverName: {
110
+ serverId: {
109
111
  type: 'string',
110
112
  description:
111
- 'Optional: The server name provided in mcp_search_tool_bm25. Required if multiple tools have the same name.',
113
+ 'Optional: The server ID provided in mcp_search_tool_bm25. Required if multiple tools have the same name.',
112
114
  },
113
115
  },
114
116
  required: ['toolName'],
@@ -141,10 +143,10 @@ export function createExecuteToolDefinition(): Tool {
141
143
  type: 'string',
142
144
  description: 'The exact tool name from mcp_search_tool_bm25 results.',
143
145
  },
144
- serverName: {
146
+ serverId: {
145
147
  type: 'string',
146
148
  description:
147
- 'Optional: The server name provided in mcp_search_tool_bm25. Required if multiple tools have the same name.',
149
+ 'Optional: The server ID provided in mcp_search_tool_bm25. Required if multiple tools have the same name.',
148
150
  },
149
151
  args: {
150
152
  type: 'object',
@@ -206,6 +208,53 @@ export async function executeMetaTool(
206
208
  const query = String(args.query ?? '');
207
209
  const limit = Math.min(Number(args.limit) || 5, 20);
208
210
 
211
+ // Fast path: Check for select: prefix
212
+ const selectMatch = query.match(/^select:(.+)$/i);
213
+ if (selectMatch) {
214
+ const requested = selectMatch[1]!
215
+ .split(',')
216
+ .map((s) => s.trim())
217
+ .filter(Boolean);
218
+
219
+ const found: any[] = [];
220
+ const errors: string[] = [];
221
+
222
+ for (const requestedToolName of requested) {
223
+ const { tool, error } = resolveToolSchema(requestedToolName);
224
+ if (error) {
225
+ const errorMsg = error.content[0]?.type === 'text' ? error.content[0].text : 'Unknown error';
226
+ errors.push(`- **${requestedToolName}**: ${errorMsg}`);
227
+ } else if (tool) {
228
+ found.push(tool);
229
+ } else {
230
+ errors.push(`- **${requestedToolName}**: Tool not found. Try searching with mcp_search_tool_bm25.`);
231
+ }
232
+ }
233
+
234
+ const lines: string[] = [];
235
+
236
+ if (found.length > 0) {
237
+ lines.push(...found.map((t, i) =>
238
+ `${i + 1}. **${t.name}** (server: ${t.serverName}, serverId: ${t.serverId})\n ${t.description}`
239
+ ));
240
+ }
241
+
242
+ if (errors.length > 0) {
243
+ if (lines.length > 0) lines.push(""); // Add empty line spacing
244
+ lines.push("Errors resolving some tools:");
245
+ lines.push(...errors);
246
+ }
247
+
248
+ const text = lines.length > 0
249
+ ? lines.join('\n')
250
+ : `No tools found matching select query: ${requested.join(', ')}`;
251
+
252
+ return {
253
+ content: [{ type: 'text', text }],
254
+ isError: found.length === 0,
255
+ };
256
+ }
257
+
209
258
  const results = await router.searchTools(query, limit);
210
259
 
211
260
  const text = results.length === 0
@@ -213,7 +262,7 @@ export async function executeMetaTool(
213
262
  : results
214
263
  .map(
215
264
  (t, i) =>
216
- `${i + 1}. **${t.name}** (server: ${t.serverName})\n` +
265
+ `${i + 1}. **${t.name}** (server: ${t.serverName}, serverId: ${t.serverId})\n` +
217
266
  ` ${t.description}\n` +
218
267
  ` Estimated tokens: ${t.estimatedTokens}`
219
268
  )
@@ -236,7 +285,7 @@ export async function executeMetaTool(
236
285
  : results
237
286
  .map(
238
287
  (t, i) =>
239
- `${i + 1}. **${t.name}** (server: ${t.serverName})\n` +
288
+ `${i + 1}. **${t.name}** (server: ${t.serverName}, serverId: ${t.serverId})\n` +
240
289
  ` ${t.description}\n` +
241
290
  ` Estimated tokens: ${t.estimatedTokens}`
242
291
  )
@@ -250,7 +299,7 @@ export async function executeMetaTool(
250
299
 
251
300
  case 'mcp_get_tool_schema': {
252
301
  const name = String(args.toolName ?? '');
253
- const namespace = String(args.serverName ?? '') || undefined;
302
+ const namespace = String(args.serverId ?? '') || undefined;
254
303
  const { tool, error } = resolveToolSchema(name, namespace);
255
304
 
256
305
  if (error) {
@@ -283,7 +332,7 @@ export async function executeMetaTool(
283
332
 
284
333
  case 'mcp_execute_tool': {
285
334
  const targetToolName = String(args.toolName ?? '');
286
- const namespace = String(args.serverName ?? '') || undefined;
335
+ const namespace = String(args.serverId ?? '') || undefined;
287
336
  const toolArgs = (args.args as Record<string, unknown>) ?? {};
288
337
 
289
338
  if (!targetToolName) {
@@ -24,6 +24,8 @@ export interface ToolSummary {
24
24
  description: string;
25
25
  /** Server that owns this tool */
26
26
  serverName: string;
27
+ /** Unique ID of the server */
28
+ serverId: string;
27
29
  /** Session the tool belongs to */
28
30
  sessionId: string;
29
31
  /** Estimated token cost of the full inputSchema */
@@ -33,6 +35,7 @@ export interface ToolSummary {
33
35
  /** A tool with routing metadata attached during indexing. */
34
36
  export interface IndexedTool extends Tool {
35
37
  sessionId: string;
38
+ serverId: string;
36
39
  serverName: string;
37
40
  }
38
41
 
@@ -177,6 +180,7 @@ export class ToolIndex {
177
180
  name: tool.name,
178
181
  description: tool.description ?? '',
179
182
  serverName: tool.serverName,
183
+ serverId: tool.serverId,
180
184
  sessionId: tool.sessionId,
181
185
  estimatedTokens,
182
186
  });
@@ -258,8 +262,55 @@ export class ToolIndex {
258
262
  async search(query: string, topK = 5): Promise<ToolSummary[]> {
259
263
  if (this.tools.size === 0) return [];
260
264
 
261
- const queryLower = query.toLowerCase();
262
- const queryTokens = this.tokenize(queryLower);
265
+ const queryLower = query.toLowerCase().trim();
266
+
267
+ // Fast path: Exact tool name match (supports duplicate names across servers)
268
+ const exactMatches = [...this.toolSummaries.values()].filter(
269
+ (summary) => summary.name.toLowerCase() === queryLower
270
+ );
271
+ if (exactMatches.length > 0) {
272
+ return exactMatches.slice(0, topK);
273
+ }
274
+
275
+ // Fast path: MCP prefix match (e.g. "mcp__github")
276
+ if (queryLower.startsWith('mcp__') && queryLower.length > 5) {
277
+ const prefixMatches = [...this.toolSummaries.values()]
278
+ .filter((t) => t.name.toLowerCase().startsWith(queryLower))
279
+ .slice(0, topK);
280
+ if (prefixMatches.length > 0) return prefixMatches;
281
+ }
282
+
283
+ const queryTermsRaw = queryLower.split(/\s+/).filter((t) => t.length > 0);
284
+ const requiredTerms: string[] = [];
285
+ const optionalTerms: string[] = [];
286
+
287
+ for (const term of queryTermsRaw) {
288
+ if (term.startsWith('+') && term.length > 1) {
289
+ requiredTerms.push(term.slice(1));
290
+ } else {
291
+ optionalTerms.push(term);
292
+ }
293
+ }
294
+
295
+ const allScoringTerms =
296
+ requiredTerms.length > 0 ? [...requiredTerms, ...optionalTerms] : queryTermsRaw;
297
+ const normalizedQueryText = allScoringTerms.join(' ').trim();
298
+ const queryTokens = this.tokenize(allScoringTerms.join(' '));
299
+
300
+ // Pre-filter: only keep documents that contain ALL required terms
301
+ const candidateKeys = new Set<string>();
302
+ for (const docKey of this.toolSummaries.keys()) {
303
+ if (requiredTerms.length > 0) {
304
+ const text = this.searchTexts.get(docKey) || '';
305
+ const summary = this.toolSummaries.get(docKey)!;
306
+ const nameLower = summary.name.toLowerCase();
307
+ const matchesAll = requiredTerms.every(
308
+ (term) => text.includes(term) || nameLower.includes(term)
309
+ );
310
+ if (!matchesAll) continue;
311
+ }
312
+ candidateKeys.add(docKey);
313
+ }
263
314
 
264
315
  // 1. Keyword scores (BM25)
265
316
  const keywordScores = new Map<string, number>();
@@ -267,7 +318,12 @@ export class ToolIndex {
267
318
  const k1 = 1.2;
268
319
  const b = 0.75;
269
320
 
270
- for (const [docKey, docTf] of this.tfVectors) {
321
+ for (const docKey of candidateKeys) {
322
+ const docTf = this.tfVectors.get(docKey);
323
+ if (!docTf) continue;
324
+
325
+ const summary = this.toolSummaries.get(docKey)!;
326
+
271
327
  let score = 0;
272
328
  const docLen = this.docLengths.get(docKey) ?? 0;
273
329
 
@@ -276,16 +332,30 @@ export class ToolIndex {
276
332
  if (tfVal === 0) continue;
277
333
 
278
334
  const idf = this.idf.get(tok) ?? 0;
279
-
280
335
  // BM25 formula:
281
336
  // score = idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (docLen / avgDocLength)))
282
337
  const numerator = tfVal * (k1 + 1);
283
338
  const denominator = tfVal + k1 * (1 - b + b * (docLen / this.avgDocLength));
284
-
339
+
285
340
  score += idf * (numerator / denominator);
286
341
  }
287
342
 
288
- keywordScores.set(docKey, score);
343
+ // Name heuristics: give massive boosts for exact server/tool name matches
344
+ const serverLower = (summary.serverName || summary.serverId || '').toLowerCase();
345
+ const toolLower = summary.name.toLowerCase();
346
+
347
+ for (const term of allScoringTerms) {
348
+ if (serverLower.includes(term)) {
349
+ score += 10;
350
+ }
351
+ if (toolLower.includes(term)) {
352
+ score += 5;
353
+ }
354
+ }
355
+
356
+ if (score > 0) {
357
+ keywordScores.set(docKey, score);
358
+ }
289
359
  }
290
360
 
291
361
  // 2. Embedding scores (optional)
@@ -293,11 +363,14 @@ export class ToolIndex {
293
363
 
294
364
  if (this.options.embedFn && this.embeddings.size > 0) {
295
365
  try {
296
- const [queryEmbedding] = await this.options.embedFn([queryLower]);
366
+ const [queryEmbedding] = await this.options.embedFn([normalizedQueryText]);
297
367
  if (queryEmbedding) {
298
368
  embeddingScores = new Map();
299
- for (const [docKey, vec] of this.embeddings) {
300
- embeddingScores.set(docKey, this.cosineSimilarity(queryEmbedding, vec));
369
+ for (const docKey of candidateKeys) {
370
+ const vec = this.embeddings.get(docKey);
371
+ if (vec) {
372
+ embeddingScores.set(docKey, this.cosineSimilarity(queryEmbedding, vec));
373
+ }
301
374
  }
302
375
  }
303
376
  } catch {
@@ -309,7 +382,7 @@ export class ToolIndex {
309
382
  const kw = this.options.keywordWeight;
310
383
  const finalScores: Array<{ docKey: string; score: number }> = [];
311
384
 
312
- for (const docKey of this.toolSummaries.keys()) {
385
+ for (const docKey of candidateKeys) {
313
386
  const kwScore = keywordScores.get(docKey) ?? 0;
314
387
  const embScore = embeddingScores?.get(docKey) ?? 0;
315
388
 
@@ -389,7 +462,7 @@ export class ToolIndex {
389
462
  const list = this.tools.get(name) ?? [];
390
463
  if (!namespace) return list;
391
464
 
392
- return list.filter((t) => t.sessionId === namespace || t.serverName === namespace);
465
+ return list.filter((t) => t.sessionId === namespace || t.serverId === namespace);
393
466
  }
394
467
 
395
468
  /** All indexed tool names. */
@@ -463,7 +536,7 @@ export class ToolIndex {
463
536
  }
464
537
 
465
538
  private getDocumentKey(tool: IndexedTool): string {
466
- return `${tool.sessionId}::${tool.serverName}::${tool.name}`;
539
+ return `${tool.sessionId}::${tool.serverId}::${tool.name}`;
467
540
  }
468
541
 
469
542
  /** Simple whitespace + camelCase + snake_case tokenizer. */
@@ -219,10 +219,10 @@ export class ToolRouter {
219
219
  if (matches.length === 0) return undefined;
220
220
 
221
221
  if (matches.length > 1) {
222
- const servers = matches.map((m) => m.serverName).join(', ');
222
+ const servers = matches.map((m) => m.serverId).join(', ');
223
223
  throw new Error(
224
224
  `Tool "${toolName}" is provided by multiple servers: [${servers}]. ` +
225
- `Please specify the desired "serverName" as a namespace.`
225
+ `Please specify the desired "serverId" as a namespace.`
226
226
  );
227
227
  }
228
228
 
@@ -372,6 +372,7 @@ export class ToolRouter {
372
372
  for (const tool of tools) {
373
373
  result.push({
374
374
  ...tool,
375
+ serverId,
375
376
  serverName: serverName,
376
377
  sessionId,
377
378
  });
@@ -409,20 +410,20 @@ export class ToolRouter {
409
410
  });
410
411
  }
411
412
  } else {
412
- // Auto-group by server name
413
+ // Auto-group by server ID
413
414
  const serverTools = new Map<string, string[]>();
414
415
  for (const tool of this.allTools) {
415
- const group = tool.serverName;
416
+ const group = tool.serverId;
416
417
  if (!serverTools.has(group)) {
417
418
  serverTools.set(group, []);
418
419
  }
419
420
  serverTools.get(group)!.push(tool.name);
420
421
  }
421
422
 
422
- for (const [serverName, tools] of serverTools) {
423
- this.groupsMap.set(serverName, {
423
+ for (const [serverId, tools] of serverTools) {
424
+ this.groupsMap.set(serverId, {
424
425
  tools,
425
- active: this.activeGroups.size === 0 || this.activeGroups.has(serverName),
426
+ active: this.activeGroups.size === 0 || this.activeGroups.has(serverId),
426
427
  });
427
428
  }
428
429
  }
@@ -0,0 +1,32 @@
1
+ -- Enable the pg_cron extension (available on all Supabase plans).
2
+ -- This is idempotent and safe to run multiple times.
3
+ CREATE EXTENSION IF NOT EXISTS pg_cron;
4
+
5
+ -- ─────────────────────────────────────────────────────────────────────────────
6
+ -- Stage 1: Short-term Transient Purge (every 5 minutes)
7
+ -- ─────────────────────────────────────────────────────────────────────────────
8
+ -- Targets sessions that are NOT active (failed connections, abandoned OAuth
9
+ -- flows, mid-flow errors) whose TTL has expired. Active sessions are explicitly
10
+ -- excluded from this sweep to preserve automation credentials.
11
+ --
12
+ -- The idx_mcp_sessions_expires_at index ensures this is a fast indexed scan.
13
+ SELECT cron.schedule(
14
+ 'cleanup-transient-sessions',
15
+ '*/5 * * * *',
16
+ $$DELETE FROM public.mcp_sessions WHERE expires_at < now() AND active IS NOT TRUE;$$
17
+ );
18
+
19
+ -- ─────────────────────────────────────────────────────────────────────────────
20
+ -- Stage 2: Long-term Dormancy Eviction (daily at midnight UTC)
21
+ -- ─────────────────────────────────────────────────────────────────────────────
22
+ -- Safety net for sessions that were successfully established (active = true)
23
+ -- but have been completely untouched for 30+ days. This prevents "active"
24
+ -- records from persisting indefinitely if they are genuinely abandoned.
25
+ SELECT cron.schedule(
26
+ 'cleanup-dormant-sessions',
27
+ '0 0 * * *',
28
+ $$DELETE FROM public.mcp_sessions WHERE active = true AND updated_at < now() - interval '30 days';$$
29
+ );
30
+
31
+ -- Add a comment on the extension for visibility in Supabase Dashboard
32
+ COMMENT ON EXTENSION pg_cron IS 'Automated Session Lifecycle Management.';