@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.
- package/dist/adapters/agui-adapter.d.mts +1 -1
- package/dist/adapters/agui-adapter.d.ts +1 -1
- package/dist/adapters/agui-adapter.js +43 -9
- package/dist/adapters/agui-adapter.js.map +1 -1
- package/dist/adapters/agui-adapter.mjs +43 -9
- package/dist/adapters/agui-adapter.mjs.map +1 -1
- package/dist/adapters/agui-middleware.d.mts +1 -1
- package/dist/adapters/agui-middleware.d.ts +1 -1
- package/dist/adapters/agui-middleware.js.map +1 -1
- package/dist/adapters/agui-middleware.mjs.map +1 -1
- package/dist/adapters/ai-adapter.d.mts +1 -1
- package/dist/adapters/ai-adapter.d.ts +1 -1
- package/dist/adapters/ai-adapter.js +42 -8
- package/dist/adapters/ai-adapter.js.map +1 -1
- package/dist/adapters/ai-adapter.mjs +42 -8
- package/dist/adapters/ai-adapter.mjs.map +1 -1
- package/dist/adapters/langchain-adapter.d.mts +1 -1
- package/dist/adapters/langchain-adapter.d.ts +1 -1
- package/dist/adapters/langchain-adapter.js +42 -8
- package/dist/adapters/langchain-adapter.js.map +1 -1
- package/dist/adapters/langchain-adapter.mjs +42 -8
- package/dist/adapters/langchain-adapter.mjs.map +1 -1
- package/dist/client/react.d.mts +91 -2
- package/dist/client/react.d.ts +91 -2
- package/dist/client/react.js +339 -3
- package/dist/client/react.js.map +1 -1
- package/dist/client/react.mjs +335 -4
- package/dist/client/react.mjs.map +1 -1
- package/dist/client/vue.d.mts +10 -0
- package/dist/client/vue.d.ts +10 -0
- package/dist/client/vue.js +28 -2
- package/dist/client/vue.js.map +1 -1
- package/dist/client/vue.mjs +28 -2
- package/dist/client/vue.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +170 -37
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +170 -37
- package/dist/index.mjs.map +1 -1
- package/dist/server/index.js +55 -11
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +55 -11
- package/dist/server/index.mjs.map +1 -1
- package/dist/shared/index.d.mts +2 -2
- package/dist/shared/index.d.ts +2 -2
- package/dist/shared/index.js +115 -26
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/index.mjs +115 -26
- package/dist/shared/index.mjs.map +1 -1
- package/dist/{tool-router-XnWVxPzv.d.mts → tool-router-DK0RJblO.d.mts} +3 -0
- package/dist/{tool-router-Bo8qZbsD.d.ts → tool-router-DsKhRmJm.d.ts} +3 -0
- package/package.json +1 -1
- package/src/adapters/agui-adapter.ts +7 -7
- package/src/adapters/ai-adapter.ts +5 -5
- package/src/adapters/langchain-adapter.ts +5 -5
- package/src/client/react/index.ts +14 -0
- package/src/client/react/oauth-popup.tsx +446 -0
- package/src/client/react/use-mcp.ts +84 -3
- package/src/client/vue/use-mcp.ts +80 -3
- package/src/server/handlers/sse-handler.ts +39 -0
- package/src/server/mcp/oauth-client.ts +32 -14
- package/src/shared/meta-tools.ts +62 -13
- package/src/shared/tool-index.ts +85 -12
- package/src/shared/tool-router.ts +8 -7
- 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(
|
|
294
|
+
existing.state === 'READY' && isTransientReconnectState(normalizedState)
|
|
251
295
|
? existing.state
|
|
252
|
-
:
|
|
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
|
-
|
|
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
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
627
|
-
|
|
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)`);
|
package/src/shared/meta-tools.ts
CHANGED
|
@@ -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
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
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: '
|
|
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
|
-
|
|
110
|
+
serverId: {
|
|
109
111
|
type: 'string',
|
|
110
112
|
description:
|
|
111
|
-
'Optional: The server
|
|
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
|
-
|
|
146
|
+
serverId: {
|
|
145
147
|
type: 'string',
|
|
146
148
|
description:
|
|
147
|
-
'Optional: The server
|
|
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.
|
|
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.
|
|
335
|
+
const namespace = String(args.serverId ?? '') || undefined;
|
|
287
336
|
const toolArgs = (args.args as Record<string, unknown>) ?? {};
|
|
288
337
|
|
|
289
338
|
if (!targetToolName) {
|
package/src/shared/tool-index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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([
|
|
366
|
+
const [queryEmbedding] = await this.options.embedFn([normalizedQueryText]);
|
|
297
367
|
if (queryEmbedding) {
|
|
298
368
|
embeddingScores = new Map();
|
|
299
|
-
for (const
|
|
300
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 "
|
|
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
|
|
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.
|
|
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 [
|
|
423
|
-
this.groupsMap.set(
|
|
423
|
+
for (const [serverId, tools] of serverTools) {
|
|
424
|
+
this.groupsMap.set(serverId, {
|
|
424
425
|
tools,
|
|
425
|
-
active: this.activeGroups.size === 0 || this.activeGroups.has(
|
|
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.';
|