@retrivora-ai/rag-engine 1.9.7 → 1.9.9
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/README.md +136 -136
- package/dist/{ILLMProvider-Bhk6zJOK.d.mts → ILLMProvider-DMxLyTdq.d.mts} +59 -4
- package/dist/{ILLMProvider-Bhk6zJOK.d.ts → ILLMProvider-DMxLyTdq.d.ts} +59 -4
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +2327 -474
- package/dist/handlers/index.mjs +2329 -473
- package/dist/{index-B9J_XEh0.d.ts → index-CfkqZd2Y.d.ts} +12 -2
- package/dist/{index-C3SVtPYg.d.mts → index-DXd29KMq.d.mts} +27 -52
- package/dist/{index-Bu7T6xgr.d.ts → index-D_bOdJML.d.ts} +27 -52
- package/dist/{index-BJ4cd-t5.d.mts → index-xygonxpW.d.mts} +12 -2
- package/dist/index.css +783 -550
- package/dist/index.d.mts +35 -7
- package/dist/index.d.ts +35 -7
- package/dist/index.js +419 -282
- package/dist/index.mjs +426 -292
- package/dist/server.d.mts +65 -6
- package/dist/server.d.ts +65 -6
- package/dist/server.js +2185 -317
- package/dist/server.mjs +2185 -317
- package/package.json +13 -8
- package/src/app/constants.tsx +37 -7
- package/src/app/page.tsx +2 -0
- package/src/components/ChatWidget.tsx +2 -1
- package/src/components/ChatWindow.tsx +4 -1
- package/src/components/CodeViewer.tsx +19 -14
- package/src/components/MarkdownComponents.tsx +44 -1
- package/src/components/MessageBubble.tsx +162 -50
- package/src/components/constants.tsx +228 -0
- package/src/config/RagConfig.ts +48 -2
- package/src/config/constants.ts +4 -0
- package/src/config/serverConfig.ts +15 -0
- package/src/core/ConfigResolver.ts +38 -6
- package/src/core/DatabaseStorage.ts +469 -0
- package/src/core/LicenseVerifier.ts +260 -0
- package/src/core/MultiAgentCoordinator.ts +239 -0
- package/src/core/Pipeline.ts +151 -18
- package/src/core/Retrivora.ts +7 -0
- package/src/core/VectorPlugin.ts +12 -3
- package/src/core/mcp.ts +261 -0
- package/src/handlers/index.ts +449 -63
- package/src/hooks/useRagChat.ts +96 -42
- package/src/hooks/useStoredMessages.ts +15 -4
- package/src/index.ts +3 -0
- package/src/llm/LLMFactory.ts +9 -1
- package/src/llm/providers/GroqProvider.ts +176 -0
- package/src/llm/providers/QwenProvider.ts +191 -0
- package/src/server.ts +7 -0
- package/src/types/chat.ts +14 -0
- package/src/types/props.ts +12 -0
- package/.env.example +0 -80
- package/LICENSE.txt +0 -21
- package/src/components/AmbientBackground.tsx +0 -29
- package/src/components/ArchitectureCard.tsx +0 -17
- package/src/components/ArchitectureCardsSection.tsx +0 -15
- package/src/components/DocViewer.tsx +0 -103
- package/src/components/Documentation.tsx +0 -121
- package/src/components/Hero.tsx +0 -59
- package/src/components/Lifecycle.tsx +0 -37
- package/src/components/Navbar.tsx +0 -55
package/src/core/Pipeline.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { Reranker } from '../rag/Reranker';
|
|
|
9
9
|
import { LlamaIndexIngestor } from '../rag/LlamaIndexIngestor';
|
|
10
10
|
import { LangChainAgent } from './LangChainAgent';
|
|
11
11
|
import { RagConfig } from '../config/RagConfig';
|
|
12
|
+
import { MCPRegistry } from './mcp';
|
|
13
|
+
import { MultiAgentCoordinator } from './MultiAgentCoordinator';
|
|
12
14
|
import { ProviderRegistry } from './ProviderRegistry';
|
|
13
15
|
import { BatchProcessor, BatchOptions } from './BatchProcessor';
|
|
14
16
|
import { EmbeddingStrategyResolver } from '../config/EmbeddingStrategy';
|
|
@@ -165,9 +167,13 @@ export class Pipeline {
|
|
|
165
167
|
private entityExtractor?: EntityExtractor;
|
|
166
168
|
private reranker: Reranker;
|
|
167
169
|
private agent?: LangChainAgent;
|
|
170
|
+
private mcpRegistry?: MCPRegistry;
|
|
171
|
+
private multiAgentCoordinator?: MultiAgentCoordinator;
|
|
168
172
|
/** LRU-bounded cache: avoids re-embedding identical queries within the same process. */
|
|
169
173
|
private embeddingCache = new LRUEmbeddingCache(500);
|
|
170
174
|
private initialised = false;
|
|
175
|
+
/** Namespace-specific static cold context cache for CAG */
|
|
176
|
+
private coldContexts = new Map<string, string>();
|
|
171
177
|
|
|
172
178
|
constructor(private config: RagConfig) {
|
|
173
179
|
this.chunker = new DocumentChunker(
|
|
@@ -250,14 +256,52 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
250
256
|
|
|
251
257
|
await this.vectorDB.initialize();
|
|
252
258
|
|
|
253
|
-
if (this.config.rag?.architecture === 'agentic') {
|
|
259
|
+
if (this.config.rag?.architecture === 'agentic' || this.config.rag?.architecture === 'multi-agent' || (this.config.mcpServers && this.config.mcpServers.length > 0)) {
|
|
260
|
+
this.mcpRegistry = new MCPRegistry(this.config.mcpServers || []);
|
|
261
|
+
this.multiAgentCoordinator = new MultiAgentCoordinator({
|
|
262
|
+
llmProvider: this.llmProvider,
|
|
263
|
+
mcpRegistry: this.mcpRegistry,
|
|
264
|
+
documentSearch: async (query: string) => {
|
|
265
|
+
return this.runNormalQuery(query);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
254
268
|
this.agent = new LangChainAgent(this, this.config);
|
|
255
269
|
await this.agent.initialize(this.llmProvider as unknown);
|
|
256
270
|
}
|
|
257
271
|
|
|
272
|
+
if (this.config.rag?.cag?.enabled) {
|
|
273
|
+
await this.loadColdContext(this.config.projectId);
|
|
274
|
+
}
|
|
275
|
+
|
|
258
276
|
this.initialised = true;
|
|
259
277
|
}
|
|
260
278
|
|
|
279
|
+
private async loadColdContext(ns: string): Promise<void> {
|
|
280
|
+
const cagConfig = this.config.rag?.cag;
|
|
281
|
+
if (!cagConfig || !cagConfig.enabled) return;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const coldNs = cagConfig.coldNamespace || ns;
|
|
285
|
+
const limit = cagConfig.coldLimit ?? 20;
|
|
286
|
+
|
|
287
|
+
const queryText = cagConfig.coldQuery || 'documentation';
|
|
288
|
+
const queryVector = await this.embeddingProvider.embed(queryText, { taskType: 'query' });
|
|
289
|
+
const coldMatches = await this.vectorDB.query(queryVector, limit, coldNs);
|
|
290
|
+
|
|
291
|
+
if (coldMatches.length > 0) {
|
|
292
|
+
const formatted = coldMatches.map((m, i) => `[Cold Source ${i + 1}]\n${m.content}`).join('\n\n---\n\n');
|
|
293
|
+
this.coldContexts.set(ns, formatted);
|
|
294
|
+
console.log(`[Pipeline] Cold RAG (CAG) loaded ${coldMatches.length} documents into cached context for namespace: ${ns}`);
|
|
295
|
+
} else {
|
|
296
|
+
this.coldContexts.set(ns, 'No cold context found.');
|
|
297
|
+
console.warn(`[Pipeline] Cold RAG (CAG) initialized but no documents were found in namespace: ${coldNs}`);
|
|
298
|
+
}
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error(`[Pipeline] Failed to load Cold RAG (CAG) context for namespace ${ns}:`, err);
|
|
301
|
+
this.coldContexts.set(ns, 'Failed to load cold context.');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
261
305
|
/**
|
|
262
306
|
* Ingest documents with automatic chunking, embedding, and batch upsert.
|
|
263
307
|
*/
|
|
@@ -386,12 +430,26 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
386
430
|
);
|
|
387
431
|
}
|
|
388
432
|
|
|
433
|
+
async runNormalQuery(question: string, history: ChatMessage[] = [], namespace?: string): Promise<{ reply: string; sources: any[] }> {
|
|
434
|
+
const stream = this.askStreamInternal(question, history, namespace);
|
|
435
|
+
let reply = '';
|
|
436
|
+
let sources: any[] = [];
|
|
437
|
+
for await (const chunk of stream) {
|
|
438
|
+
if (typeof chunk === 'string') {
|
|
439
|
+
reply += chunk;
|
|
440
|
+
} else if (typeof chunk === 'object' && chunk !== null) {
|
|
441
|
+
if ('reply' in chunk && chunk.reply) reply += chunk.reply;
|
|
442
|
+
if ('sources' in chunk && chunk.sources) sources = chunk.sources;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { reply, sources };
|
|
446
|
+
}
|
|
447
|
+
|
|
389
448
|
async ask(question: string, history: ChatMessage[] = [], namespace?: string): Promise<ChatResponse> {
|
|
390
449
|
await this.initialize();
|
|
391
450
|
|
|
392
|
-
if (this.config.rag?.architecture === 'agentic' && this.
|
|
393
|
-
|
|
394
|
-
return { reply: agentReply, sources: [] };
|
|
451
|
+
if ((this.config.rag?.architecture === 'agentic' || this.config.rag?.architecture === 'multi-agent' || (this.config.mcpServers && this.config.mcpServers.length > 0)) && this.multiAgentCoordinator) {
|
|
452
|
+
return await this.multiAgentCoordinator.run(question, history);
|
|
395
453
|
}
|
|
396
454
|
|
|
397
455
|
const stream = this.askStream(question, history, namespace);
|
|
@@ -415,6 +473,21 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
415
473
|
return { reply, sources, graphData, ui_transformation: uiTransformation, trace };
|
|
416
474
|
}
|
|
417
475
|
|
|
476
|
+
async *askStream(question: string, history: ChatMessage[] = [], namespace?: string): AsyncIterable<string | ChatResponse> {
|
|
477
|
+
await this.initialize();
|
|
478
|
+
if ((this.config.rag?.architecture === 'agentic' || this.config.rag?.architecture === 'multi-agent' || (this.config.mcpServers && this.config.mcpServers.length > 0)) && this.multiAgentCoordinator) {
|
|
479
|
+
const stream = this.multiAgentCoordinator.runStream(question, history);
|
|
480
|
+
for await (const chunk of stream) {
|
|
481
|
+
yield chunk;
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const stream = this.askStreamInternal(question, history, namespace);
|
|
486
|
+
for await (const chunk of stream) {
|
|
487
|
+
yield chunk;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
418
491
|
/**
|
|
419
492
|
* High-performance streaming RAG flow.
|
|
420
493
|
* Yields text chunks first, then the retrieval metadata + observability trace at the end.
|
|
@@ -425,7 +498,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
425
498
|
* - UITransformation is computed after text streaming and emitted with metadata
|
|
426
499
|
* - SchemaMapper.train runs while answer generation streams
|
|
427
500
|
*/
|
|
428
|
-
async *
|
|
501
|
+
async *askStreamInternal(question: string, history: ChatMessage[] = [], namespace?: string): AsyncIterable<string | ChatResponse> {
|
|
429
502
|
await this.initialize();
|
|
430
503
|
const ns = namespace ?? this.config.projectId;
|
|
431
504
|
const topK = this.config.rag?.topK ?? 5;
|
|
@@ -462,7 +535,8 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
462
535
|
const cacheKey = `${ns}::${searchQuery}`;
|
|
463
536
|
const cachedVector = this.embeddingCache.get(cacheKey);
|
|
464
537
|
|
|
465
|
-
const
|
|
538
|
+
const arch = this.config.rag?.architecture;
|
|
539
|
+
const useGraph = arch !== 'naive' && arch !== 'retrieve-and-rerank' && this.graphDB && this.config.rag?.useGraphRetrieval;
|
|
466
540
|
const [strategyResult, embeddedVector] = await Promise.all([
|
|
467
541
|
QueryProcessor.determineRetrievalStrategy(
|
|
468
542
|
searchQuery,
|
|
@@ -506,7 +580,8 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
506
580
|
? structuredSources
|
|
507
581
|
: structuredSources.filter((m) => m.score >= scoreThreshold);
|
|
508
582
|
const rerankLimit = wantsExhaustiveList ? retrievalLimit : topK;
|
|
509
|
-
|
|
583
|
+
const useReranking = arch !== 'naive' && (arch === 'retrieve-and-rerank' || this.config.rag?.useReranking);
|
|
584
|
+
if (!hasNumericPredicates && useReranking) {
|
|
510
585
|
fullSources = await this.reranker.rerank(fullSources, question, rerankLimit);
|
|
511
586
|
} else if (!wantsExhaustiveList) {
|
|
512
587
|
fullSources = fullSources.slice(0, topK);
|
|
@@ -547,6 +622,28 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
547
622
|
context = `GRAPH KNOWLEDGE:\n${graphContext}\n\nVECTOR CONTEXT:\n${context}`;
|
|
548
623
|
}
|
|
549
624
|
|
|
625
|
+
if (this.config.rag?.cag?.enabled) {
|
|
626
|
+
if (!this.coldContexts.has(ns)) {
|
|
627
|
+
await this.loadColdContext(ns);
|
|
628
|
+
}
|
|
629
|
+
const coldContext = this.coldContexts.get(ns);
|
|
630
|
+
if (coldContext && coldContext !== 'No cold context found.' && coldContext !== 'Failed to load cold context.') {
|
|
631
|
+
context = `COLD CONTEXT (STATIC KNOWLEDGE):\n${coldContext}\n\nRETRIEVED HOT CONTEXT (REAL-TIME KNOWLEDGE):\n${context}`;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Yield sources immediately to support early UI citation rendering (highly responsive)
|
|
636
|
+
yield {
|
|
637
|
+
reply: '',
|
|
638
|
+
sources: sources.map((s) => ({
|
|
639
|
+
id: s.id,
|
|
640
|
+
score: s.score,
|
|
641
|
+
content: s.content,
|
|
642
|
+
metadata: s.metadata ?? {},
|
|
643
|
+
namespace: ns,
|
|
644
|
+
} satisfies RetrievedChunk)),
|
|
645
|
+
} as ChatResponse;
|
|
646
|
+
|
|
550
647
|
// 4.5 Schema Training — true fire-and-forget, never gates anything.
|
|
551
648
|
const allMetadataKeys = Array.from(new Set(sources.flatMap(s => Object.keys(s.metadata || {}))));
|
|
552
649
|
const cachedSchema = allMetadataKeys.length > 0
|
|
@@ -735,7 +832,7 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
735
832
|
const latency: LatencyBreakdown = {
|
|
736
833
|
embedMs: Math.round(embedMs),
|
|
737
834
|
retrieveMs: Math.round(retrieveMs),
|
|
738
|
-
rerankMs: this.config.rag?.useReranking ? Math.round(rerankMs) : undefined,
|
|
835
|
+
rerankMs: (arch !== 'naive' && (arch === 'retrieve-and-rerank' || this.config.rag?.useReranking)) ? Math.round(rerankMs) : undefined,
|
|
739
836
|
generateMs: Math.round(generateMs),
|
|
740
837
|
totalMs: Math.round(totalMs),
|
|
741
838
|
};
|
|
@@ -751,15 +848,15 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
751
848
|
estimatedCostUsd: estimateCostUsd(promptTokens, completionTokens, this.config.llm.model),
|
|
752
849
|
};
|
|
753
850
|
|
|
754
|
-
// 7. Await
|
|
755
|
-
|
|
756
|
-
|
|
851
|
+
// 7. Await UI transformation and optionally hallucination score before yielding the metadata frame.
|
|
852
|
+
const awaitHallucination = this.config.llm.options?.awaitHallucination === true;
|
|
853
|
+
|
|
757
854
|
const [uiTransformation, hallucinationResult] = await Promise.all([
|
|
758
855
|
uiTransformationPromise,
|
|
759
|
-
hallucinationScoringPromise,
|
|
856
|
+
awaitHallucination ? hallucinationScoringPromise : Promise.resolve(undefined),
|
|
760
857
|
]);
|
|
761
858
|
|
|
762
|
-
const
|
|
859
|
+
const buildTrace = (hScore?: { score: number; reason: string }): ObservabilityTrace => ({
|
|
763
860
|
requestId,
|
|
764
861
|
query: question,
|
|
765
862
|
rewrittenQuery,
|
|
@@ -775,12 +872,48 @@ You are a helpful assistant. Use the provided context to answer questions accura
|
|
|
775
872
|
latency,
|
|
776
873
|
tokens,
|
|
777
874
|
timestamp: new Date().toISOString(),
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
hallucinationReason: hallucinationResult.reason,
|
|
875
|
+
...(hScore ? {
|
|
876
|
+
hallucinationScore: hScore.score,
|
|
877
|
+
hallucinationReason: hScore.reason,
|
|
782
878
|
} : {}),
|
|
783
|
-
};
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
const trace = buildTrace(hallucinationResult);
|
|
882
|
+
|
|
883
|
+
// Asynchronously trigger telemetry logging without blocking response yield
|
|
884
|
+
if (this.config.telemetry?.enabled) {
|
|
885
|
+
const telemetryUrl = this.config.telemetry.url || '/api/telemetry';
|
|
886
|
+
const absoluteUrl = telemetryUrl.startsWith('http')
|
|
887
|
+
? telemetryUrl
|
|
888
|
+
: (process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`) + telemetryUrl;
|
|
889
|
+
|
|
890
|
+
// Start background process for telemetry, awaiting hallucination scoring if it was backgrounded
|
|
891
|
+
(async () => {
|
|
892
|
+
try {
|
|
893
|
+
let finalTrace = trace;
|
|
894
|
+
if (!awaitHallucination && runHallucination) {
|
|
895
|
+
const backgroundScoreResult = await hallucinationScoringPromise;
|
|
896
|
+
if (backgroundScoreResult) {
|
|
897
|
+
finalTrace = buildTrace(backgroundScoreResult);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
await fetch(absoluteUrl, {
|
|
902
|
+
method: 'POST',
|
|
903
|
+
headers: {
|
|
904
|
+
'Content-Type': 'application/json',
|
|
905
|
+
},
|
|
906
|
+
body: JSON.stringify({
|
|
907
|
+
trace: finalTrace,
|
|
908
|
+
licenseKey: this.config.licenseKey,
|
|
909
|
+
projectId: ns
|
|
910
|
+
})
|
|
911
|
+
});
|
|
912
|
+
} catch (err: any) {
|
|
913
|
+
console.warn(`[Pipeline Telemetry] Failed to send trace async to ${absoluteUrl}:`, err.message);
|
|
914
|
+
}
|
|
915
|
+
})();
|
|
916
|
+
}
|
|
784
917
|
|
|
785
918
|
yield {
|
|
786
919
|
reply: '',
|
package/src/core/Retrivora.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { RagConfig, RetrievalConfig, UniversalRagConfig } from '../config/RagCon
|
|
|
2
2
|
import { ChatMessage, ChatResponse, IngestDocument } from '../types';
|
|
3
3
|
import { ConfigResolver } from './ConfigResolver';
|
|
4
4
|
import { ConfigValidator } from './ConfigValidator';
|
|
5
|
+
import { LicenseVerifier } from './LicenseVerifier';
|
|
5
6
|
import { Pipeline } from './Pipeline';
|
|
6
7
|
import { wrapError, RetrivoraErrorCode } from '../exceptions';
|
|
7
8
|
|
|
@@ -23,6 +24,12 @@ export class Retrivora {
|
|
|
23
24
|
async initialize(): Promise<void> {
|
|
24
25
|
try {
|
|
25
26
|
await ConfigValidator.validateAndThrow(this.config);
|
|
27
|
+
LicenseVerifier.verify(
|
|
28
|
+
this.config.licenseKey,
|
|
29
|
+
this.config.projectId,
|
|
30
|
+
this.config.vectorDb?.provider
|
|
31
|
+
);
|
|
32
|
+
await LicenseVerifier.verifyIP(this.config.licenseKey);
|
|
26
33
|
} catch (err) {
|
|
27
34
|
throw wrapError(err, 'CONFIGURATION_ERROR');
|
|
28
35
|
}
|
package/src/core/VectorPlugin.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Pipeline } from './Pipeline';
|
|
|
5
5
|
import { ProviderHealthCheck, HealthCheckResult } from './ProviderHealthCheck';
|
|
6
6
|
import { IngestDocument, ChatResponse } from '../types';
|
|
7
7
|
import { ChatMessage } from '../types';
|
|
8
|
+
import { LicenseVerifier } from './LicenseVerifier';
|
|
8
9
|
import { wrapError, RetrivoraErrorCode } from '../exceptions';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -24,14 +25,22 @@ export class VectorPlugin {
|
|
|
24
25
|
private validationPromise: Promise<void>;
|
|
25
26
|
|
|
26
27
|
constructor(hostConfig?: Partial<RagConfig>) {
|
|
27
|
-
|
|
28
|
+
const resolvedConfig = ConfigResolver.resolve(hostConfig);
|
|
29
|
+
this.config = resolvedConfig;
|
|
28
30
|
|
|
29
31
|
// Start validation early
|
|
30
|
-
this.validationPromise =
|
|
32
|
+
this.validationPromise = (async () => {
|
|
33
|
+
await ConfigValidator.validateAndThrow(resolvedConfig);
|
|
34
|
+
LicenseVerifier.verify(
|
|
35
|
+
resolvedConfig.licenseKey,
|
|
36
|
+
resolvedConfig.projectId,
|
|
37
|
+
resolvedConfig.vectorDb?.provider
|
|
38
|
+
);
|
|
39
|
+
})();
|
|
31
40
|
// Prevent unhandled promise rejection warning
|
|
32
41
|
this.validationPromise.catch(() => {});
|
|
33
42
|
|
|
34
|
-
this.pipeline = new Pipeline(
|
|
43
|
+
this.pipeline = new Pipeline(resolvedConfig);
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
/**
|
package/src/core/mcp.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'child_process';
|
|
2
|
+
import { MCPServerConfig } from '../config/RagConfig';
|
|
3
|
+
|
|
4
|
+
interface MCPTool {
|
|
5
|
+
name: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
inputSchema: any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface MCPCallResult {
|
|
11
|
+
content: Array<{ type: string; text?: string; [key: string]: any }>;
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class MCPClient {
|
|
16
|
+
private childProcess?: ChildProcess;
|
|
17
|
+
private sseUrl?: string;
|
|
18
|
+
private messageIdCounter = 0;
|
|
19
|
+
private pendingRequests = new Map<number, { resolve: (val: any) => void; reject: (err: any) => void }>();
|
|
20
|
+
private stdioBuffer = '';
|
|
21
|
+
private initialized = false;
|
|
22
|
+
|
|
23
|
+
constructor(private config: MCPServerConfig) {}
|
|
24
|
+
|
|
25
|
+
private getNextMessageId(): number {
|
|
26
|
+
return ++this.messageIdCounter;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Connect and initialize the MCP server connection.
|
|
31
|
+
*/
|
|
32
|
+
async connect(): Promise<void> {
|
|
33
|
+
if (this.initialized) return;
|
|
34
|
+
|
|
35
|
+
if (this.config.transport === 'stdio') {
|
|
36
|
+
await this.connectStdio();
|
|
37
|
+
} else {
|
|
38
|
+
await this.connectSSE();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Call initialize
|
|
42
|
+
try {
|
|
43
|
+
const response = await this.sendJsonRpc('initialize', {
|
|
44
|
+
protocolVersion: '2024-11-05',
|
|
45
|
+
capabilities: {},
|
|
46
|
+
clientInfo: {
|
|
47
|
+
name: 'retrivora-sdk',
|
|
48
|
+
version: '1.0.0',
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Send initialized notification
|
|
53
|
+
await this.sendNotification('notifications/initialized');
|
|
54
|
+
this.initialized = true;
|
|
55
|
+
console.log(`[MCPClient] Connected and initialized server "${this.config.name}" successfully.`);
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
this.disconnect();
|
|
58
|
+
throw new Error(`[MCPClient] Failed to initialize server "${this.config.name}": ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async connectStdio(): Promise<void> {
|
|
63
|
+
const cmd = this.config.command;
|
|
64
|
+
if (!cmd) {
|
|
65
|
+
throw new Error(`[MCPClient] Command option is required for stdio transport on "${this.config.name}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const args = this.config.args || [];
|
|
69
|
+
const env = { ...process.env, ...(this.config.env || {}) };
|
|
70
|
+
|
|
71
|
+
this.childProcess = spawn(cmd, args, { env, shell: true });
|
|
72
|
+
|
|
73
|
+
if (!this.childProcess || !this.childProcess.stdout || !this.childProcess.stdin) {
|
|
74
|
+
throw new Error(`[MCPClient] Failed to spawn stdio child process for "${this.config.name}"`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.childProcess.stdout.on('data', (data: Buffer) => {
|
|
78
|
+
this.stdioBuffer += data.toString('utf-8');
|
|
79
|
+
this.processStdioLines();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.childProcess.stderr?.on('data', (data: Buffer) => {
|
|
83
|
+
console.warn(`[MCPClient] [stderr] [${this.config.name}]:`, data.toString('utf-8'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.childProcess.on('close', (code) => {
|
|
87
|
+
console.log(`[MCPClient] Connection closed for "${this.config.name}" with exit code: ${code}`);
|
|
88
|
+
this.disconnect();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Wait a brief tick to ensure stdout is set up
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private processStdioLines() {
|
|
96
|
+
let newlineIndex: number;
|
|
97
|
+
while ((newlineIndex = this.stdioBuffer.indexOf('\n')) !== -1) {
|
|
98
|
+
const line = this.stdioBuffer.substring(0, newlineIndex).trim();
|
|
99
|
+
this.stdioBuffer = this.stdioBuffer.substring(newlineIndex + 1);
|
|
100
|
+
|
|
101
|
+
if (!line) continue;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const payload = JSON.parse(line);
|
|
105
|
+
if (payload.id !== undefined) {
|
|
106
|
+
const req = this.pendingRequests.get(Number(payload.id));
|
|
107
|
+
if (req) {
|
|
108
|
+
this.pendingRequests.delete(Number(payload.id));
|
|
109
|
+
if (payload.error) {
|
|
110
|
+
req.reject(new Error(payload.error.message || JSON.stringify(payload.error)));
|
|
111
|
+
} else {
|
|
112
|
+
req.resolve(payload.result);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.warn(`[MCPClient] Error parsing stdio JSON-RPC line from "${this.config.name}":`, e, 'Line content:', line);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async connectSSE(): Promise<void> {
|
|
123
|
+
if (!this.config.url) {
|
|
124
|
+
throw new Error(`[MCPClient] URL is required for sse transport on "${this.config.name}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Set up standard SSE handshake / SSE connection
|
|
128
|
+
// In serverless contexts, we pull initial SSE transport configuration,
|
|
129
|
+
// and route HTTP POSTs to the client URL.
|
|
130
|
+
this.sseUrl = this.config.url;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async sendJsonRpc(method: string, params: any): Promise<any> {
|
|
134
|
+
const id = this.getNextMessageId();
|
|
135
|
+
const request = {
|
|
136
|
+
jsonrpc: '2.0',
|
|
137
|
+
id,
|
|
138
|
+
method,
|
|
139
|
+
params,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (this.config.transport === 'stdio') {
|
|
143
|
+
if (!this.childProcess || !this.childProcess.stdin) {
|
|
144
|
+
throw new Error(`[MCPClient] Server "${this.config.name}" is not connected (stdio process missing)`);
|
|
145
|
+
}
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
148
|
+
this.childProcess!.stdin!.write(JSON.stringify(request) + '\n', 'utf-8');
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
// SSE / HTTP POST fallback for cloud/serverless setups
|
|
152
|
+
if (!this.sseUrl) {
|
|
153
|
+
throw new Error(`[MCPClient] Server "${this.config.name}" SSE URL is not initialized`);
|
|
154
|
+
}
|
|
155
|
+
const response = await fetch(this.sseUrl, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: { 'Content-Type': 'application/json' },
|
|
158
|
+
body: JSON.stringify(request),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new Error(`SSE JSON-RPC POST failed with status ${response.status}`);
|
|
162
|
+
}
|
|
163
|
+
const payload = await response.json();
|
|
164
|
+
if (payload.error) {
|
|
165
|
+
throw new Error(payload.error.message || JSON.stringify(payload.error));
|
|
166
|
+
}
|
|
167
|
+
return payload.result;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async sendNotification(method: string, params?: any): Promise<void> {
|
|
172
|
+
const notification = {
|
|
173
|
+
jsonrpc: '2.0',
|
|
174
|
+
method,
|
|
175
|
+
params,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (this.config.transport === 'stdio') {
|
|
179
|
+
if (this.childProcess?.stdin) {
|
|
180
|
+
this.childProcess.stdin.write(JSON.stringify(notification) + '\n', 'utf-8');
|
|
181
|
+
}
|
|
182
|
+
} else if (this.sseUrl) {
|
|
183
|
+
await fetch(this.sseUrl, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify(notification),
|
|
187
|
+
}).catch(err => console.warn('[MCPClient] Failed to send notification over SSE:', err));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* List all tools offered by the MCP server.
|
|
193
|
+
*/
|
|
194
|
+
async listTools(): Promise<MCPTool[]> {
|
|
195
|
+
await this.connect();
|
|
196
|
+
const result = await this.sendJsonRpc('tools/list', {});
|
|
197
|
+
return result?.tools || [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Execute a tool on the MCP server.
|
|
202
|
+
*/
|
|
203
|
+
async callTool(name: string, args: Record<string, any> = {}): Promise<MCPCallResult> {
|
|
204
|
+
await this.connect();
|
|
205
|
+
return await this.sendJsonRpc('tools/call', { name, arguments: args });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
disconnect() {
|
|
209
|
+
this.initialized = false;
|
|
210
|
+
this.pendingRequests.forEach(req => req.reject(new Error('MCPClient disconnected')));
|
|
211
|
+
this.pendingRequests.clear();
|
|
212
|
+
|
|
213
|
+
if (this.childProcess) {
|
|
214
|
+
try {
|
|
215
|
+
this.childProcess.kill();
|
|
216
|
+
} catch { /* silent */ }
|
|
217
|
+
this.childProcess = undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export class MCPRegistry {
|
|
223
|
+
private clients: MCPClient[] = [];
|
|
224
|
+
|
|
225
|
+
constructor(servers: MCPServerConfig[] = []) {
|
|
226
|
+
this.clients = servers.map(cfg => new MCPClient(cfg));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async getAllTools(): Promise<Array<{ serverName: string; tool: MCPTool }>> {
|
|
230
|
+
const list: Array<{ serverName: string; tool: MCPTool }> = [];
|
|
231
|
+
await Promise.all(
|
|
232
|
+
this.clients.map(async (client) => {
|
|
233
|
+
try {
|
|
234
|
+
const tools = await client.listTools();
|
|
235
|
+
tools.forEach(t => list.push({ serverName: client['config'].name, tool: t }));
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.warn(`[MCPRegistry] Failed to fetch tools from server "${client['config'].name}":`, e);
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
return list;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async callTool(toolName: string, args: Record<string, any> = {}): Promise<MCPCallResult> {
|
|
245
|
+
// Find client hosting this tool. (In standard MCP, tool names are unique per server block)
|
|
246
|
+
// We will search across all connected clients.
|
|
247
|
+
for (const client of this.clients) {
|
|
248
|
+
try {
|
|
249
|
+
const tools = await client.listTools();
|
|
250
|
+
if (tools.some(t => t.name === toolName)) {
|
|
251
|
+
return await client.callTool(toolName, args);
|
|
252
|
+
}
|
|
253
|
+
} catch { /* silent fallback */ }
|
|
254
|
+
}
|
|
255
|
+
throw new Error(`[MCPRegistry] Tool "${toolName}" not found on any registered MCP server.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
disconnectAll() {
|
|
259
|
+
this.clients.forEach(c => c.disconnect());
|
|
260
|
+
}
|
|
261
|
+
}
|